Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/_data/partner-toolkits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
90 changes: 90 additions & 0 deletions tests/partner-integration-nav.test.ts
Original file line number Diff line number Diff line change
@@ -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<MetaRecord> => {
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}`);
}
);
});
24 changes: 21 additions & 3 deletions toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
128 changes: 111 additions & 17 deletions toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -113,7 +119,7 @@ export type ToolkitInfo = {
slug: string;
label: string;
category: string;
navGroup: "optimized" | "starter";
navGroup: "optimized" | "starter" | "partner";
};

export type CategoryData = {
Expand All @@ -127,6 +133,7 @@ export type SyncResult = {
categoriesCreated: string[];
categoriesRemoved: string[];
toolkitCount: number;
partnerCount: number;
errors: string[];
};

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down Expand Up @@ -498,6 +584,7 @@ export function syncToolkitSidebar(options: SyncOptions = {}): SyncResult {
categoriesCreated: [],
categoriesRemoved: [],
toolkitCount: 0,
partnerCount: 0,
errors: [],
};

Expand All @@ -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(", ")}`);

Expand Down Expand Up @@ -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}):`);
Expand Down
Loading
Loading