From 73693eeb779f9031bd569ca5779f66749eeeff4c Mon Sep 17 00:00:00 2001 From: Teal Larson Date: Wed, 26 Aug 2026 14:45:37 -0400 Subject: [PATCH 1/2] fix: build partner sidebar entries from the partner catalog The sidebar sync rewrites every category _meta.tsx from scratch out of the toolkit JSON data directory. Nimble and Tavily are partner pages with no toolkit JSON, so each scheduled run proposed deleting their nav entries (the Partners section in search/_meta.tsx) while leaving the pages in place. Derive those entries from PARTNER_TOOLKITS instead, the list the integrations catalog already renders its cards from, and add a test that fails when a partner has no page on disk or no sidebar entry. Co-Authored-By: Claude Opus 5 (1M context) --- app/_data/partner-toolkits.ts | 7 + tests/partner-integration-nav.test.ts | 108 +++++++++++++++ .../scripts/README-sync-toolkit-sidebar.md | 22 ++- .../scripts/sync-toolkit-sidebar.ts | 85 +++++++++--- .../scripts/sync-toolkit-sidebar.test.ts | 130 ++++++++++++++++++ 5 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/partner-integration-nav.test.ts diff --git a/app/_data/partner-toolkits.ts b/app/_data/partner-toolkits.ts index 51febe670..177c8deb0 100644 --- a/app/_data/partner-toolkits.ts +++ b/app/_data/partner-toolkits.ts @@ -8,6 +8,13 @@ import type { Toolkit } from "@arcadeai/design-system"; * a standard ToolkitType (typically "verified") plus an `isPartner: true` * flag that renders a Partner badge next to BYOC/Pro on catalog cards. * + * This list is also what the category sidebars are built from, so an entry + * here needs a matching page at the path its `relativeDocsLink` points to. + * See buildPartnerToolkitInfoList in + * toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts, and + * tests/partner-integration-nav.test.ts for the assertions that keep the + * two in step. + * * Once DS adds an explicit `isPartner` field to its Toolkit shape, migrate * these entries into the DS TOOLKITS array and delete this file. */ diff --git a/tests/partner-integration-nav.test.ts b/tests/partner-integration-nav.test.ts new file mode 100644 index 000000000..efc0c563c --- /dev/null +++ b/tests/partner-integration-nav.test.ts @@ -0,0 +1,108 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { MetaRecord } from "nextra"; +import { describe, expect, test } from "vitest"; +import { PARTNER_TOOLKITS } from "@/app/_data/partner-toolkits"; +import { + getToolkitSlug, + INTEGRATION_CATEGORIES, +} from "@/toolkit-docs-generator/src/shared/toolkit-primitives"; + +/** + * Partner integrations are hand-authored pages that no toolkit JSON file backs, + * so they are invisible to the docs generator's own data. `PARTNER_TOOLKITS` + * is what both the catalog cards and the category sidebar are built from, and + * these assertions are what keeps that list honest: adding a partner there + * without writing the page, or writing a page whose slug doesn't match, fails + * here instead of shipping a sidebar link to a 404. + * + * The sidebar entries themselves are generated (see + * toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts), so a missing entry + * below means someone hand-edited a `_meta.tsx` and skipped the sync, or the + * sync ran against a partner list the committed nav predates. + */ + +const INTEGRATIONS_DIR = join(process.cwd(), "app/en/resources/integrations"); +const INTEGRATIONS_BASE_PATH = "/en/resources/integrations"; +const PAGE_FILE_NAMES = ["page.mdx", "page.tsx"]; + +const partnerCases = PARTNER_TOOLKITS.map((partner) => ({ + partner, + slug: getToolkitSlug({ + id: partner.id, + docsLink: partner.relativeDocsLink ?? partner.docsLink ?? null, + }), +})); + +const loadCategoryMeta = async (category: string): Promise => { + const meta = await import(join(INTEGRATIONS_DIR, category, "_meta.tsx")); + return meta.default as MetaRecord; +}; + +describe("partner integrations", () => { + test("there is at least one partner to check", () => { + expect(partnerCases.length).toBeGreaterThan(0); + }); + + test.each(partnerCases)( + "$partner.id has a routable category", + ({ partner }) => { + expect(INTEGRATION_CATEGORIES).toContain(partner.category); + } + ); + + test.each(partnerCases)( + "$partner.id has a page on disk", + ({ partner, slug }) => { + const pageDir = join(INTEGRATIONS_DIR, partner.category, slug); + const hasPage = PAGE_FILE_NAMES.some((fileName) => + existsSync(join(pageDir, fileName)) + ); + + expect( + hasPage, + `Expected a page for partner "${partner.id}" at ${pageDir}/page.mdx` + ).toBe(true); + } + ); + + test.each(partnerCases)( + "$partner.id has a sidebar entry pointing at its page", + async ({ partner, slug }) => { + const meta = await loadCategoryMeta(partner.category); + const entry = meta[slug]; + + expect( + entry, + `Expected a "${slug}" key in app/en/resources/integrations/${partner.category}/_meta.tsx. ` + + "Run `npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts` to regenerate it." + ).toBeDefined(); + expect(entry).toMatchObject({ + title: partner.label, + href: `${INTEGRATIONS_BASE_PATH}/${partner.category}/${slug}`, + }); + } + ); + + test.each(partnerCases)( + "$partner.id has a sidebar Partners section", + async ({ partner }) => { + const meta = await loadCategoryMeta(partner.category); + + expect(meta["-- Partners"]).toMatchObject({ + type: "separator", + title: "Partners", + }); + } + ); + + test.each(partnerCases)( + "$partner.id docs links agree with its page path", + ({ partner, slug }) => { + const path = `${INTEGRATIONS_BASE_PATH}/${partner.category}/${slug}`; + + expect(partner.relativeDocsLink).toBe(path); + expect(partner.docsLink).toBe(`https://docs.arcade.dev${path}`); + } + ); +}); diff --git a/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md b/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md index 21f773c9d..2bcb6c53a 100644 --- a/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md +++ b/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md @@ -22,9 +22,10 @@ npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts --dry-run --verbo 1. Reads toolkit JSON files from `toolkit-docs-generator/data/toolkits/`. 2. Maps toolkits to categories using the design system catalog. -3. Creates or updates `_meta.tsx` files for each category folder. -4. Skips toolkits without a recognized integration category. -5. Updates the main integrations `_meta.tsx`. +3. Adds the partner integrations from `app/_data/partner-toolkits.ts`. +4. Creates or updates `_meta.tsx` files for each category folder. +5. Skips toolkits without a recognized integration category. +6. Updates the main integrations `_meta.tsx`. ## When to run @@ -33,8 +34,23 @@ Run this script when: - Adding a new toolkit JSON file to `toolkit-docs-generator/data/toolkits/` - Removing a toolkit JSON file - Updating toolkit categories in the design system +- Adding or removing a partner in `app/_data/partner-toolkits.ts` - Regenerating toolkit documentation +## Partner integrations + +Partner integrations (remote MCP Servers offered by Arcade partners) have +hand-authored pages and no toolkit JSON file. This script reads them from +`app/_data/partner-toolkits.ts`, the same list the integrations catalog renders +its cards from. Each one lands in a `Partners` section at the end of its +category sidebar, keyed by the last segment of its `relativeDocsLink`. + +This script rewrites every category `_meta.tsx` from scratch, so the next run +drops a partner entry that someone typed into one of those files by hand. Add +the partner to `app/_data/partner-toolkits.ts` and re-run the script instead. +`tests/partner-integration-nav.test.ts` fails when a partner has no page or no +sidebar entry. + ## Category mapping Toolkits are mapped to categories based on `@arcadeai/design-system` and diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 8b753dd86..cb1f93318 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -5,9 +5,11 @@ * This script: * 1. Reads all toolkit JSON files from data/toolkits/ * 2. Maps each toolkit to its category from the design system - * 3. Creates/updates _meta.tsx files for each category - * 4. Skips toolkits without a recognized category - * 5. Updates the main integrations _meta.tsx if needed + * 3. Adds the partner integrations from app/_data/partner-toolkits.ts, which + * have hand-authored pages and no JSON file of their own + * 4. Creates/updates _meta.tsx files for each category + * 5. Skips toolkits without a recognized category + * 6. Updates the main integrations _meta.tsx if needed * * Usage: * npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -28,6 +30,10 @@ import { import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { TOOLKITS as DESIGN_SYSTEM_TOOLKITS } from "@arcadeai/design-system/metadata/toolkits"; +import { + PARTNER_TOOLKITS, + type PartnerToolkit, +} from "../../app/_data/partner-toolkits"; import { resolveToolkitDataDir } from "../src/shared/toolkit-data-dir"; import { getToolkitSlug, @@ -113,7 +119,7 @@ export type ToolkitInfo = { slug: string; label: string; category: string; - navGroup: "optimized" | "starter"; + navGroup: "optimized" | "starter" | "partner"; }; export type CategoryData = { @@ -127,6 +133,7 @@ export type SyncResult = { categoriesCreated: string[]; categoriesRemoved: string[]; toolkitCount: number; + partnerCount: number; errors: string[]; }; @@ -330,6 +337,42 @@ export function buildToolkitInfoList(dataDir: string): ToolkitInfo[] { return Array.from(toolkitsBySlug.values()).map((entry) => entry.info); } +/** + * Sidebar entries for the partner integrations (remote MCP Servers offered by + * Arcade partners). + * + * Partner pages are hand-authored and have no JSON file in the data directory, + * so `buildToolkitInfoList` can't see them. This script rewrites every + * category's `_meta.tsx` from scratch, so an entry typed into that file by hand + * disappears on the next run. Deriving the entries from `PARTNER_TOOLKITS` — the + * same list the integrations catalog renders its cards from — keeps the sidebar + * and the catalog agreeing, and makes adding a partner a one-line change in one + * file. tests/partner-integration-nav.test.ts holds the two together. + */ +export function buildPartnerToolkitInfoList( + partners: readonly PartnerToolkit[] = PARTNER_TOOLKITS +): ToolkitInfo[] { + return partners.map((partner) => { + const category = partner.category; + if (!(INTEGRATION_CATEGORIES as readonly string[]).includes(category)) { + throw new Error( + `Unrecognized integration category "${category}" for partner "${partner.id}".` + ); + } + + return { + id: partner.id, + slug: getToolkitSlug({ + id: partner.id, + docsLink: partner.relativeDocsLink ?? partner.docsLink ?? null, + }), + label: partner.label, + category, + navGroup: "partner" as const, + }; + }); +} + /** * Group toolkits by category */ @@ -376,6 +419,9 @@ export function generateCategoryMeta( const starter = toolkits .filter((t) => t.navGroup === "starter") .sort(byLabel); + const partners = toolkits + .filter((t) => t.navGroup === "partner") + .sort(byLabel); const renderEntry = (t: ToolkitInfo) => { // Escape any quotes in the label @@ -395,18 +441,17 @@ export function generateCategoryMeta( }; const sections: string[] = []; - if (optimized.length > 0 || starter.length > 0) { - if (optimized.length > 0) { - sections.push(renderSeparator("Optimized")); - sections.push(...optimized.map(renderEntry)); - } - if (starter.length > 0) { - sections.push(renderSeparator("Starter")); - sections.push(...starter.map(renderEntry)); - } - } else { - const sortedToolkits = [...toolkits].sort(byLabel); - sections.push(...sortedToolkits.map(renderEntry)); + if (optimized.length > 0) { + sections.push(renderSeparator("Optimized")); + sections.push(...optimized.map(renderEntry)); + } + if (starter.length > 0) { + sections.push(renderSeparator("Starter")); + sections.push(...starter.map(renderEntry)); + } + if (partners.length > 0) { + sections.push(renderSeparator("Partners")); + sections.push(...partners.map(renderEntry)); } const entries = sections.join(",\n"); @@ -498,6 +543,7 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult { categoriesCreated: [], categoriesRemoved: [], toolkitCount: 0, + partnerCount: 0, errors: [], }; @@ -513,8 +559,12 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult { result.toolkitCount = toolkits.length; log(`Found ${toolkits.length} toolkit JSON files`); + const partners = buildPartnerToolkitInfoList(); + result.partnerCount = partners.length; + log(`Found ${partners.length} partner integrations`); + // Group by category - const grouped = groupByCategory(toolkits); + const grouped = groupByCategory([...toolkits, ...partners]); const activeCategories = Array.from(grouped.keys()); log(`Active categories: ${activeCategories.join(", ")}`); @@ -619,6 +669,7 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult { export function printResults(result: SyncResult): void { console.log("\n=== Toolkit Sidebar Sync Results ===\n"); console.log(`Total toolkits: ${result.toolkitCount}`); + console.log(`Partner integrations: ${result.partnerCount}`); if (result.categoriesCreated.length > 0) { console.log(`\nCategories created (${result.categoriesCreated.length}):`); diff --git a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts index c8b055ce9..0fea9becf 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -8,8 +8,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PartnerToolkit } from "../../../app/_data/partner-toolkits"; import { getToolkitStaticParamsForCategory } from "../../../app/_lib/toolkit-static-params"; import { + buildPartnerToolkitInfoList, buildToolkitInfoList, generateCategoryMeta, generateMainMeta, @@ -24,6 +26,7 @@ import { syncToolkitSidebar, type ToolkitInfo, } from "../../scripts/sync-toolkit-sidebar"; +import { INTEGRATION_CATEGORIES } from "../../src/shared/toolkit-primitives"; setToolkitsForTesting([ { id: "Gmail", label: "Gmail", category: "productivity" }, @@ -790,6 +793,7 @@ describe("syncToolkitSidebar", () => { categoriesCreated: expect.any(Array), categoriesRemoved: expect.any(Array), toolkitCount: expect.any(Number), + partnerCount: expect.any(Number), errors: expect.any(Array), }); }); @@ -922,3 +926,129 @@ describe("category move cleanup logic", () => { expect(mainMeta).not.toContain("others:"); }); }); + +// ============================================================================ +// Unit Tests: partner integrations +// ============================================================================ + +/** + * `buildPartnerToolkitInfoList` reads four fields off a partner, so the cases + * below supply those and nothing else. The cast is confined here rather than + * spelled out at every call site. + */ +const asPartner = (fields: Record): PartnerToolkit => + fields as unknown as PartnerToolkit; + +describe("buildPartnerToolkitInfoList", () => { + const partner = asPartner({ + id: "Tavily", + label: "Tavily", + category: "search", + relativeDocsLink: "/en/resources/integrations/search/tavily", + }); + + it("derives a partner sidebar entry from the partner catalog", () => { + const result = buildPartnerToolkitInfoList([partner]); + + expect(result).toEqual([ + { + id: "Tavily", + slug: "tavily", + label: "Tavily", + category: "search", + navGroup: "partner", + }, + ]); + }); + + it("falls back to the kebab-cased id when there is no docs link", () => { + const result = buildPartnerToolkitInfoList([ + asPartner({ id: "NimbleWay", label: "Nimble", category: "search" }), + ]); + + expect(result[0]?.slug).toBe("nimble-way"); + }); + + it("throws on a category with no integrations route", () => { + expect(() => + buildPartnerToolkitInfoList([ + asPartner({ ...partner, category: "nonsense" }), + ]) + ).toThrow(/Unrecognized integration category "nonsense"/); + }); + + it("keeps the real partner catalog routable", () => { + const result = buildPartnerToolkitInfoList(); + + expect(result.length).toBeGreaterThan(0); + for (const entry of result) { + expect(INTEGRATION_CATEGORIES).toContain(entry.category); + expect(entry.navGroup).toBe("partner"); + } + }); +}); + +describe("generateCategoryMeta partner section", () => { + const toolkits: ToolkitInfo[] = [ + { + id: "GoogleSearch", + slug: "google-search", + label: "Google Search", + category: "search", + navGroup: "optimized", + }, + { + id: "ExaApi", + slug: "exa-api", + label: "Exa API", + category: "search", + navGroup: "starter", + }, + { + id: "Tavily", + slug: "tavily", + label: "Tavily", + category: "search", + navGroup: "partner", + }, + ]; + + it("renders partners in their own section after the generated toolkits", () => { + const result = generateCategoryMeta( + toolkits, + "search", + "/en/resources/integrations" + ); + + expect(result).toContain('"-- Partners"'); + expect(result).toContain("tavily: {"); + expect(result).toContain( + 'href: "/en/resources/integrations/search/tavily"' + ); + expect(result.indexOf('"-- Partners"')).toBeGreaterThan( + result.indexOf('"-- Starter"') + ); + }); + + it("omits the Partners separator when the category has no partners", () => { + const result = generateCategoryMeta( + toolkits.filter((t) => t.navGroup !== "partner"), + "search", + "/en/resources/integrations" + ); + + expect(result).not.toContain('"-- Partners"'); + expect(result).not.toContain("tavily: {"); + }); + + it("renders a partner-only category without duplicating entries", () => { + const result = generateCategoryMeta( + toolkits.filter((t) => t.navGroup === "partner"), + "search", + "/en/resources/integrations" + ); + + expect(result).toContain('"-- Partners"'); + expect(result.match(/tavily: \{/g)).toHaveLength(1); + }); +}); From 000b9e1899068fdae2a6069f27549e7110a241b7 Mon Sep 17 00:00:00 2001 From: Teal Larson Date: Fri, 28 Aug 2026 11:03:39 -0400 Subject: [PATCH 2/2] fix: guard partner/toolkit slug collisions and drop unreachable cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Toolkit` declares docsLink and relativeDocsLink as required strings, so the `?? partner.docsLink ?? null` fallbacks in the slug derivation could never fire. Removing them also removes the test for a partner with no docs link — a state PartnerToolkit forbids, reachable only through the cast helper the test file used to build fixtures. buildPartnerToolkitInfoList now takes PartnerNavSource, the four fields it actually reads, so tests pass plain objects. That leaves "all" — a legal ToolkitCategory with no integrations route — as the real input the category guard rejects, and the test now uses it. mergeToolkitAndPartnerInfo replaces the bare array spread. A partner and a toolkit resolving to the same slug in one category previously emitted two _meta.tsx entries under one key; tsc and biome both rejected the result, but only after the file was written and with no hint about the second entry. It now throws before anything is written, naming both. Drops the routable-category assertion from the nav guard test (the builder throws on it and its own unit test covers it) and folds the per-partner Partners-separator check into the entry check it always accompanied. --- tests/partner-integration-nav.test.ts | 22 +---- .../scripts/README-sync-toolkit-sidebar.md | 4 +- .../scripts/sync-toolkit-sidebar.ts | 49 ++++++++++- .../scripts/sync-toolkit-sidebar.test.ts | 84 ++++++++++++------- 4 files changed, 107 insertions(+), 52 deletions(-) diff --git a/tests/partner-integration-nav.test.ts b/tests/partner-integration-nav.test.ts index efc0c563c..83fef3cab 100644 --- a/tests/partner-integration-nav.test.ts +++ b/tests/partner-integration-nav.test.ts @@ -3,10 +3,7 @@ import { join } from "node:path"; import type { MetaRecord } from "nextra"; import { describe, expect, test } from "vitest"; import { PARTNER_TOOLKITS } from "@/app/_data/partner-toolkits"; -import { - getToolkitSlug, - INTEGRATION_CATEGORIES, -} from "@/toolkit-docs-generator/src/shared/toolkit-primitives"; +import { getToolkitSlug } from "@/toolkit-docs-generator/src/shared/toolkit-primitives"; /** * Partner integrations are hand-authored pages that no toolkit JSON file backs, @@ -30,7 +27,7 @@ const partnerCases = PARTNER_TOOLKITS.map((partner) => ({ partner, slug: getToolkitSlug({ id: partner.id, - docsLink: partner.relativeDocsLink ?? partner.docsLink ?? null, + docsLink: partner.relativeDocsLink, }), })); @@ -44,13 +41,6 @@ describe("partner integrations", () => { expect(partnerCases.length).toBeGreaterThan(0); }); - test.each(partnerCases)( - "$partner.id has a routable category", - ({ partner }) => { - expect(INTEGRATION_CATEGORIES).toContain(partner.category); - } - ); - test.each(partnerCases)( "$partner.id has a page on disk", ({ partner, slug }) => { @@ -81,14 +71,6 @@ describe("partner integrations", () => { title: partner.label, href: `${INTEGRATIONS_BASE_PATH}/${partner.category}/${slug}`, }); - } - ); - - test.each(partnerCases)( - "$partner.id has a sidebar Partners section", - async ({ partner }) => { - const meta = await loadCategoryMeta(partner.category); - expect(meta["-- Partners"]).toMatchObject({ type: "separator", title: "Partners", diff --git a/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md b/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md index 2bcb6c53a..bc7574504 100644 --- a/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md +++ b/toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md @@ -49,7 +49,9 @@ This script rewrites every category `_meta.tsx` from scratch, so the next run drops a partner entry that someone typed into one of those files by hand. Add the partner to `app/_data/partner-toolkits.ts` and re-run the script instead. `tests/partner-integration-nav.test.ts` fails when a partner has no page or no -sidebar entry. +sidebar entry. The script also refuses to run when a partner and a toolkit +resolve to the same slug in the same category, since the sidebar can hold only +one entry per key. ## Category mapping diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index cb1f93318..2e80f91c2 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -337,6 +337,16 @@ export function buildToolkitInfoList(dataDir: string): ToolkitInfo[] { return Array.from(toolkitsBySlug.values()).map((entry) => entry.info); } +/** + * The fields a partner needs to become a sidebar entry. Narrower than + * `PartnerToolkit` so tests can hand in plain objects instead of casting a + * full catalog entry into existence. + */ +export type PartnerNavSource = Pick< + PartnerToolkit, + "id" | "label" | "category" | "relativeDocsLink" +>; + /** * Sidebar entries for the partner integrations (remote MCP Servers offered by * Arcade partners). @@ -350,7 +360,7 @@ export function buildToolkitInfoList(dataDir: string): ToolkitInfo[] { * file. tests/partner-integration-nav.test.ts holds the two together. */ export function buildPartnerToolkitInfoList( - partners: readonly PartnerToolkit[] = PARTNER_TOOLKITS + partners: readonly PartnerNavSource[] = PARTNER_TOOLKITS ): ToolkitInfo[] { return partners.map((partner) => { const category = partner.category; @@ -364,7 +374,7 @@ export function buildPartnerToolkitInfoList( id: partner.id, slug: getToolkitSlug({ id: partner.id, - docsLink: partner.relativeDocsLink ?? partner.docsLink ?? null, + docsLink: partner.relativeDocsLink, }), label: partner.label, category, @@ -373,6 +383,37 @@ export function buildPartnerToolkitInfoList( }); } +/** + * A partner and a JSON-backed toolkit that resolve to the same slug in the same + * category would render two `_meta.tsx` entries under one key. Fail here, naming + * both, rather than write a file that tsc rejects with a line number and no + * explanation of where the second entry came from. + */ +export function mergeToolkitAndPartnerInfo( + toolkits: ToolkitInfo[], + partners: ToolkitInfo[] +): ToolkitInfo[] { + const toolkitKeys = new Map( + toolkits.map((toolkit) => [ + `${toolkit.category}/${toolkit.slug}`, + toolkit.id, + ]) + ); + + for (const partner of partners) { + const key = `${partner.category}/${partner.slug}`; + const toolkitId = toolkitKeys.get(key); + if (toolkitId) { + throw new Error( + `Partner "${partner.id}" and toolkit "${toolkitId}" both resolve to ${key}. ` + + "Give one of them a different slug, or drop the partner from app/_data/partner-toolkits.ts." + ); + } + } + + return [...toolkits, ...partners]; +} + /** * Group toolkits by category */ @@ -564,7 +605,9 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult { log(`Found ${partners.length} partner integrations`); // Group by category - const grouped = groupByCategory([...toolkits, ...partners]); + const grouped = groupByCategory( + mergeToolkitAndPartnerInfo(toolkits, partners) + ); const activeCategories = Array.from(grouped.keys()); log(`Active categories: ${activeCategories.join(", ")}`); diff --git a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts index 0fea9becf..89efcc3db 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -8,7 +8,6 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PartnerToolkit } from "../../../app/_data/partner-toolkits"; import { getToolkitStaticParamsForCategory } from "../../../app/_lib/toolkit-static-params"; import { buildPartnerToolkitInfoList, @@ -20,6 +19,8 @@ import { getToolkitLabel, getToolkitLabelFromJson, groupByCategory, + mergeToolkitAndPartnerInfo, + type PartnerNavSource, parseBooleanCliFlag, resolveRemoveEmptySections, setToolkitsForTesting, @@ -931,24 +932,16 @@ describe("category move cleanup logic", () => { // Unit Tests: partner integrations // ============================================================================ -/** - * `buildPartnerToolkitInfoList` reads four fields off a partner, so the cases - * below supply those and nothing else. The cast is confined here rather than - * spelled out at every call site. - */ -const asPartner = (fields: Record): PartnerToolkit => - fields as unknown as PartnerToolkit; +const tavilyPartner: PartnerNavSource = { + id: "Tavily", + label: "Tavily", + category: "search", + relativeDocsLink: "/en/resources/integrations/search/tavily", +}; describe("buildPartnerToolkitInfoList", () => { - const partner = asPartner({ - id: "Tavily", - label: "Tavily", - category: "search", - relativeDocsLink: "/en/resources/integrations/search/tavily", - }); - it("derives a partner sidebar entry from the partner catalog", () => { - const result = buildPartnerToolkitInfoList([partner]); + const result = buildPartnerToolkitInfoList([tavilyPartner]); expect(result).toEqual([ { @@ -961,20 +954,12 @@ describe("buildPartnerToolkitInfoList", () => { ]); }); - it("falls back to the kebab-cased id when there is no docs link", () => { - const result = buildPartnerToolkitInfoList([ - asPartner({ id: "NimbleWay", label: "Nimble", category: "search" }), - ]); - - expect(result[0]?.slug).toBe("nimble-way"); - }); - + // "all" is the design system's filter meta-value: a legal ToolkitCategory + // with no integrations route behind it. it("throws on a category with no integrations route", () => { expect(() => - buildPartnerToolkitInfoList([ - asPartner({ ...partner, category: "nonsense" }), - ]) - ).toThrow(/Unrecognized integration category "nonsense"/); + buildPartnerToolkitInfoList([{ ...tavilyPartner, category: "all" }]) + ).toThrow(/Unrecognized integration category "all"/); }); it("keeps the real partner catalog routable", () => { @@ -988,6 +973,49 @@ describe("buildPartnerToolkitInfoList", () => { }); }); +describe("mergeToolkitAndPartnerInfo", () => { + const searchToolkit: ToolkitInfo = { + id: "GoogleSearch", + slug: "google-search", + label: "Google Search", + category: "search", + navGroup: "optimized", + }; + const searchPartner: ToolkitInfo = { + id: "Tavily", + slug: "tavily", + label: "Tavily", + category: "search", + navGroup: "partner", + }; + + it("appends the partners to the toolkits", () => { + expect( + mergeToolkitAndPartnerInfo([searchToolkit], [searchPartner]) + ).toEqual([searchToolkit, searchPartner]); + }); + + it("throws when a partner and a toolkit share a slug in one category", () => { + expect(() => + mergeToolkitAndPartnerInfo( + [{ ...searchToolkit, slug: "tavily" }], + [searchPartner] + ) + ).toThrow( + /Partner "Tavily" and toolkit "GoogleSearch" both resolve to search\/tavily/ + ); + }); + + it("allows the same slug in different categories", () => { + expect(() => + mergeToolkitAndPartnerInfo( + [{ ...searchToolkit, slug: "tavily", category: "development" }], + [searchPartner] + ) + ).not.toThrow(); + }); +}); + describe("generateCategoryMeta partner section", () => { const toolkits: ToolkitInfo[] = [ {