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..83fef3cab --- /dev/null +++ b/tests/partner-integration-nav.test.ts @@ -0,0 +1,90 @@ +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 } 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, + }), +})); + +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 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}`, + }); + 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..bc7574504 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,25 @@ 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. 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 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..2e80f91c2 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,83 @@ 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). + * + * 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 PartnerNavSource[] = 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, + }), + label: partner.label, + category, + navGroup: "partner" as const, + }; + }); +} + +/** + * 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 */ @@ -376,6 +460,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 +482,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 +584,7 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult { categoriesCreated: [], categoriesRemoved: [], toolkitCount: 0, + partnerCount: 0, errors: [], }; @@ -513,8 +600,14 @@ 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( + mergeToolkitAndPartnerInfo(toolkits, partners) + ); const activeCategories = Array.from(grouped.keys()); log(`Active categories: ${activeCategories.join(", ")}`); @@ -619,6 +712,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..89efcc3db 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getToolkitStaticParamsForCategory } from "../../../app/_lib/toolkit-static-params"; import { + buildPartnerToolkitInfoList, buildToolkitInfoList, generateCategoryMeta, generateMainMeta, @@ -18,12 +19,15 @@ import { getToolkitLabel, getToolkitLabelFromJson, groupByCategory, + mergeToolkitAndPartnerInfo, + type PartnerNavSource, parseBooleanCliFlag, resolveRemoveEmptySections, setToolkitsForTesting, 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 +794,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 +927,156 @@ describe("category move cleanup logic", () => { expect(mainMeta).not.toContain("others:"); }); }); + +// ============================================================================ +// Unit Tests: partner integrations +// ============================================================================ + +const tavilyPartner: PartnerNavSource = { + id: "Tavily", + label: "Tavily", + category: "search", + relativeDocsLink: "/en/resources/integrations/search/tavily", +}; + +describe("buildPartnerToolkitInfoList", () => { + it("derives a partner sidebar entry from the partner catalog", () => { + const result = buildPartnerToolkitInfoList([tavilyPartner]); + + expect(result).toEqual([ + { + id: "Tavily", + slug: "tavily", + label: "Tavily", + category: "search", + navGroup: "partner", + }, + ]); + }); + + // "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([{ ...tavilyPartner, category: "all" }]) + ).toThrow(/Unrecognized integration category "all"/); + }); + + 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("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[] = [ + { + 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); + }); +});