diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index f92cc7d31..849a9abf4 100644 --- a/app/_lib/integration-catalog.ts +++ b/app/_lib/integration-catalog.ts @@ -1,6 +1,7 @@ import type { Toolkit } from "@arcadeai/design-system"; import { TOOLKITS } from "@arcadeai/design-system/metadata/toolkits"; import { PARTNER_TOOLKITS } from "@/app/_data/partner-toolkits"; +import { normalizeCategory } from "./toolkit-category"; import { readToolkitData } from "./toolkit-data"; import { normalizeToolkitId, type ToolkitWithDocsLink } from "./toolkit-slug"; @@ -12,41 +13,73 @@ const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => { return; }; +type JsonToolkitMetadata = { + docsLink?: string | null; + category?: string | null; +}; + +/** + * Apply toolkit JSON metadata onto a design-system catalog entry. + * + * Presence is nullish (`typeof === "string"`), not truthy — an explicit empty + * string in JSON must win over DS fields, matching `resolveToolkitRoute`'s `??` + * so card URLs stay aligned with `listValidIntegrationLinks`. + */ +export const mergeToolkitCatalogFields = ( + toolkit: Toolkit, + jsonMetadata?: JsonToolkitMetadata +): ToolkitWithDocsLink => { + const existingDocsLink = getToolkitDocsLink(toolkit); + const docsLink = + typeof jsonMetadata?.docsLink === "string" + ? jsonMetadata.docsLink + : existingDocsLink; + const category = normalizeCategory( + typeof jsonMetadata?.category === "string" + ? jsonMetadata.category + : toolkit.category + ); + + return { + ...toolkit, + category, + ...(docsLink !== undefined && docsLink !== null ? { docsLink } : {}), + }; +}; + /** * The full integrations catalog the index renders: design-system toolkits - * (enriched with a `docsLink` from their data file when the catalog entry - * lacks one, so the card's slug matches the generated page) plus docs-local - * partner toolkits. + * (enriched with `docsLink` / `category` from their data file when present, so + * card URLs match generated routes) plus docs-local partner toolkits. + * + * Toolkit JSON wins for both fields when present — same precedence as + * `listToolkitRoutes` / `getToolkitSlug`. */ export const getToolkitsWithDocsLinks = async (): Promise< ToolkitWithDocsLink[] > => { - const docsLinkById = new Map(); + const metadataById = new Map(); await Promise.all( TOOLKITS.map(async (toolkit) => { - const existing = getToolkitDocsLink(toolkit); - if (existing) { + const data = await readToolkitData(toolkit.id); + if (!data?.metadata) { return; } - const data = await readToolkitData(toolkit.id); - if (data?.metadata?.docsLink) { - docsLinkById.set( - normalizeToolkitId(toolkit.id), - data.metadata.docsLink - ); - } + metadataById.set(normalizeToolkitId(toolkit.id), { + docsLink: data.metadata.docsLink, + category: data.metadata.category, + }); }) ); - const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => { - const existing = getToolkitDocsLink(toolkit); - const docsLink = - existing ?? docsLinkById.get(normalizeToolkitId(toolkit.id)); - - return docsLink ? { ...toolkit, docsLink } : toolkit; - }); + const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => + mergeToolkitCatalogFields( + toolkit, + metadataById.get(normalizeToolkitId(toolkit.id)) + ) + ); return [...dsToolkits, ...PARTNER_TOOLKITS]; }; diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 12d9cacbf..783c7a530 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -1,3 +1,7 @@ +import { + isRoutableIntegrationCategory, + normalizeCategory, +} from "./toolkit-category"; import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug"; const INTEGRATIONS_BASE = "/en/resources/integrations"; @@ -6,6 +10,9 @@ const INTEGRATIONS_BASE = "/en/resources/integrations"; * The integrations link a toolkit card points to: `/en/resources/integrations/ * /`. Mirrors the slug + category logic used to generate the * dynamic `[toolkitId]` routes so cards and pages agree. + * + * Unknown categories normalize to `"others"` for a stable identity, but those + * URLs are redirect-only (`next.config.ts`) — see `resolveIndexToolkits`. */ export function toIntegrationLink(toolkit: { id: string; @@ -13,7 +20,7 @@ export function toIntegrationLink(toolkit: { category?: string | null; }): string { const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); - const category = toolkit.category ?? "others"; + const category = normalizeCategory(toolkit.category); return `${INTEGRATIONS_BASE}/${category}/${slug}`; } @@ -33,6 +40,8 @@ export type ResolvedIndexToolkit = ToolkitWithDocsLink & { hasPage: boolean }; * - de-dupes entries that resolve to the same URL (e.g. Notion/NotionToolkit), * - flags the rest with `hasPage` so the caller can render doc-less toolkits * as non-clickable cards instead of as broken links. + * - never marks `"others"` links clickable — that category redirects to the + * integrations index and has no `[toolkitId]` route. */ export function resolveIndexToolkits( toolkits: ToolkitWithDocsLink[], @@ -48,7 +57,10 @@ export function resolveIndexToolkits( } const link = toIntegrationLink(toolkit); - const hasPage = validLinks.has(link); + const category = normalizeCategory(toolkit.category); + // `/others/...` is redirect-only; never treat it as a real page. + const hasPage = + isRoutableIntegrationCategory(category) && validLinks.has(link); // A bare duplicate of a real "-api" toolkit: drop it; the real card stays. if (!hasPage && validLinks.has(`${link}-api`)) { diff --git a/app/_lib/toolkit-category.ts b/app/_lib/toolkit-category.ts new file mode 100644 index 000000000..700433a1d --- /dev/null +++ b/app/_lib/toolkit-category.ts @@ -0,0 +1,53 @@ +/** + * Shared integration category allow-list and normalization. + * + * Kept free of Node/fs imports so client components (integration cards) and + * server route helpers can share one contract without pulling server-only code + * into the browser bundle. + */ + +export const INTEGRATION_CATEGORIES = [ + "productivity", + "social", + "entertainment", + "development", + "payments", + "search", + "sales", + "databases", + "customer-support", + "others", +] as const; + +export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]; + +/** + * Categories that have a real `app/.../integrations//[toolkitId]` + * route. `"others"` remains in `INTEGRATION_CATEGORIES` as the normalize + * fallback for stable card identity, but `next.config.ts` redirects + * `/integrations/others/*` to the index — so it is never clickable. + */ +export const ROUTABLE_INTEGRATION_CATEGORIES = INTEGRATION_CATEGORIES.filter( + (category): category is Exclude => + category !== "others" +); + +export function isRoutableIntegrationCategory( + category: string +): category is Exclude { + return (ROUTABLE_INTEGRATION_CATEGORIES as readonly string[]).includes( + category + ); +} + +export function normalizeCategory( + value: string | null | undefined +): IntegrationCategory { + if (!value) { + return "others"; + } + + return INTEGRATION_CATEGORIES.includes(value as IntegrationCategory) + ? (value as IntegrationCategory) + : "others"; +} diff --git a/app/_lib/toolkit-slug.ts b/app/_lib/toolkit-slug.ts index 5ff34e21d..868a28a36 100644 --- a/app/_lib/toolkit-slug.ts +++ b/app/_lib/toolkit-slug.ts @@ -1,4 +1,5 @@ import type { Toolkit } from "@arcadeai/design-system"; +import type { IntegrationCategory } from "./toolkit-category"; const TOOLKIT_ID_NORMALIZER = /[^a-z0-9]+/g; const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; @@ -14,8 +15,13 @@ export type ToolkitSlugSource = { * docs-local entries carry them at runtime (e.g. partner toolkits that * render a Partner badge on cards). This type makes the properties explicit * so both server and client code can share it. + * + * `category` is widened with `IntegrationCategory` so normalized route + * categories (including `"others"`) remain assignable after + * `normalizeCategory` — DS `ToolkitCategory` does not include `"others"`. */ -export type ToolkitWithDocsLink = Toolkit & { +export type ToolkitWithDocsLink = Omit & { + category: Toolkit["category"] | IntegrationCategory; docsLink?: string | null; isPartner?: boolean; }; diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 1e07c5c33..5f8b2e962 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -1,24 +1,15 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { TOOLKITS as DESIGN_SYSTEM_TOOLKITS } from "@arcadeai/design-system/metadata/toolkits"; +import { + type IntegrationCategory, + isRoutableIntegrationCategory, + normalizeCategory, + ROUTABLE_INTEGRATION_CATEGORIES, +} from "./toolkit-category"; import { readToolkitData, readToolkitIndex } from "./toolkit-data"; import { getToolkitSlug, normalizeToolkitId } from "./toolkit-slug"; -export const INTEGRATION_CATEGORIES = [ - "productivity", - "social", - "entertainment", - "development", - "payments", - "search", - "sales", - "databases", - "customer-support", - "others", -] as const; - -export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]; - export type ToolkitCatalogEntry = { id: string; category?: string; @@ -42,18 +33,6 @@ const DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES: ToolkitCatalogEntry[] = const loadDesignSystemToolkits = async (): Promise => DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES; -export function normalizeCategory( - value: string | null | undefined -): IntegrationCategory { - if (!value) { - return "others"; - } - - return INTEGRATION_CATEGORIES.includes(value as IntegrationCategory) - ? (value as IntegrationCategory) - : "others"; -} - /** * The canonical docs path for a toolkit: `/en/resources/integrations// * `. Category comes from the toolkit's own data (its true, linked @@ -220,12 +199,13 @@ const PAGE_FILE_NAMES = new Set(["page.mdx", "page.tsx"]); * Authored static integration pages (e.g. partner pages like `search/tavily` * and `tool-feedback`) live next to the dynamic `[toolkitId]` routes. They are * real pages but are not part of `listToolkitRoutes`, so enumerate them from - * disk under the known integration categories. + * disk under the routable integration categories (never `"others"`, which + * redirects to the index). */ const listStaticIntegrationLinks = async (): Promise => { const links: string[] = []; - for (const category of INTEGRATION_CATEGORIES) { + for (const category of ROUTABLE_INTEGRATION_CATEGORIES) { const categoryDir = join(INTEGRATIONS_APP_DIR, category); try { const slugs = await readdir(categoryDir, { withFileTypes: true }); @@ -250,18 +230,26 @@ const listStaticIntegrationLinks = async (): Promise => { * The full set of links the integrations index may point at and that actually * resolve: dynamic toolkit routes plus authored static pages. Used to decide * whether a catalog card should be clickable. + * + * `"others"` routes are excluded — `next.config.ts` redirects + * `/integrations/others/*` to the index, and there is no `[toolkitId]` page + * under that category. */ export async function listValidIntegrationLinks(options?: { dataDir?: string; toolkitsCatalog?: ToolkitCatalogEntry[]; }): Promise> { const routes = await listToolkitRoutes(options); - const links = new Set( - routes.map( - (route) => - `/en/resources/integrations/${route.category}/${route.toolkitId}` - ) - ); + const links = new Set(); + + for (const route of routes) { + if (!isRoutableIntegrationCategory(route.category)) { + continue; + } + links.add( + `/en/resources/integrations/${route.category}/${route.toolkitId}` + ); + } for (const staticLink of await listStaticIntegrationLinks()) { links.add(staticLink); diff --git a/app/en/resources/integrations/_lib/toolkit-docs-page.tsx b/app/en/resources/integrations/_lib/toolkit-docs-page.tsx index c0522c52a..9172bfde5 100644 --- a/app/en/resources/integrations/_lib/toolkit-docs-page.tsx +++ b/app/en/resources/integrations/_lib/toolkit-docs-page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { ToolkitPage } from "@/app/_components/toolkit-docs"; +import type { IntegrationCategory } from "@/app/_lib/toolkit-category"; import { readToolkitData, toToolkitSummary } from "@/app/_lib/toolkit-data"; import { normalizeToolkitId } from "@/app/_lib/toolkit-slug"; import { getToolkitCanonicalPath, getToolkitStaticParamsForCategory, - type IntegrationCategory, } from "@/app/_lib/toolkit-static-params"; type ToolkitDocsParams = { diff --git a/app/en/resources/integrations/components/use-toolkit-filters.ts b/app/en/resources/integrations/components/use-toolkit-filters.ts index 2a15c1385..9c65dccee 100644 --- a/app/en/resources/integrations/components/use-toolkit-filters.ts +++ b/app/en/resources/integrations/components/use-toolkit-filters.ts @@ -6,6 +6,14 @@ import { useFilters } from "./use-filters"; const DEFAULT_PRIORITY = 5; const DEBOUNCE_TIME = 300; +/** + * Catalog entries may carry route-normalized categories (including `"others"`) + * that are not in DS `ToolkitCategory`. Keep the filter hook open to that. + */ +type FilterableToolkit = Omit & { + category: string; +}; + const TYPE_PRIORITY: Record = { arcade: 0, arcade_starter: 1, @@ -25,7 +33,7 @@ const TYPE_LABELS: Record = { const getTypePriority = (type: string): number => TYPE_PRIORITY[type as ToolkitType] ?? DEFAULT_PRIORITY; -const compareToolkits = (a: T, b: T): number => { +const compareToolkits = (a: T, b: T): number => { // First prioritize available toolkits over coming soon toolkits if (a.isComingSoon !== b.isComingSoon) { return a.isComingSoon ? 1 : -1; @@ -45,7 +53,7 @@ const compareToolkits = (a: T, b: T): number => { return a.label.localeCompare(b.label); }; -export function useToolkitFilters(toolkits: T[]) { +export function useToolkitFilters(toolkits: T[]) { const { selectedCategory, selectedType, diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index de50d8f9d..f15990fd5 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -8,6 +8,7 @@ import { resolveIndexToolkits, toIntegrationLink, } from "@/app/_lib/integration-index"; +import { INTEGRATION_CATEGORIES } from "@/app/_lib/toolkit-category"; import { readToolkitData } from "@/app/_lib/toolkit-data"; import { getToolkitSlug, @@ -15,7 +16,6 @@ import { } from "@/app/_lib/toolkit-slug"; import { getToolkitCanonicalPath, - INTEGRATION_CATEGORIES, listToolkitRoutes, listValidIntegrationLinks, } from "@/app/_lib/toolkit-static-params"; @@ -93,6 +93,30 @@ describe("resolveIndexToolkits (logic)", () => { expect(resolved.some((toolkit) => toolkit.id === "Zeta")).toBe(false); }); + test("normalizes unknown categories to others for a stable card identity", () => { + const link = toIntegrationLink( + makeToolkit("Mystery", "not-a-real-category", "mystery") + ); + expect(link).toBe(`${INTEGRATIONS}/others/mystery`); + }); + + test("treats empty-string category as others", () => { + const link = toIntegrationLink(makeToolkit("BlankCat", "", "blank-cat")); + expect(link).toBe(`${INTEGRATIONS}/others/blank-cat`); + }); + + test("never marks others URLs clickable (redirect-only category)", () => { + // Even if validLinks mistakenly includes /others/..., cards must stay + // non-clickable — next.config redirects those paths to the index. + const mystery = makeToolkit("Mystery", "not-a-real-category", "mystery"); + const resolvedOthers = resolveIndexToolkits( + [mystery], + new Set([`${INTEGRATIONS}/others/mystery`]) + ); + expect(resolvedOthers).toHaveLength(1); + expect(resolvedOthers[0]?.hasPage).toBe(false); + }); + test("collapses entries that resolve to the same link", () => { expect( links.filter((link) => link === `${INTEGRATIONS}/productivity/delta`) diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 01927bec6..78e8bdcff 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -28,6 +28,7 @@ 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 { normalizeCategory } from "../../app/_lib/toolkit-category"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -306,8 +307,9 @@ function resolveToolkitInfo( // Keep sidebar routes aligned with static params: toolkit JSON is source of // truth for category, with design system as fallback when JSON is missing. - const category = - jsonData?.metadata?.category ?? designSystemToolkit?.category ?? "others"; + const category = normalizeCategory( + jsonData?.metadata?.category ?? designSystemToolkit?.category + ); const labelFromDesignSystem = designSystemToolkit?.label ?? null; const labelFromJson = jsonData?.label ?? jsonData?.name ?? null; const typeFromJson = jsonData?.metadata?.type ?? null; @@ -382,6 +384,7 @@ export function generateCategoryMeta( category: string, integrationsBasePath: string ): string { + const safeCategory = normalizeCategory(category); const byLabel = (a: ToolkitInfo, b: ToolkitInfo) => a.label.localeCompare(b.label); @@ -397,7 +400,7 @@ export function generateCategoryMeta( const escapedLabel = t.label.replace(/"/g, '\\"'); return ` ${renderObjectKey(t.slug)}: { title: "${escapedLabel}", - href: "${integrationsBasePath}/${category}/${t.slug}", + href: "${integrationsBasePath}/${safeCategory}/${t.slug}", }`; }; diff --git a/toolkit-docs-generator/tests/app-lib/integration-catalog.test.ts b/toolkit-docs-generator/tests/app-lib/integration-catalog.test.ts new file mode 100644 index 000000000..0b1d5262b --- /dev/null +++ b/toolkit-docs-generator/tests/app-lib/integration-catalog.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { mergeToolkitCatalogFields } from "../../../app/_lib/integration-catalog"; +import { toIntegrationLink } from "../../../app/_lib/integration-index"; +import type { ToolkitWithDocsLink } from "../../../app/_lib/toolkit-slug"; + +const makeDsToolkit = ( + overrides: Partial & { id: string } +): ToolkitWithDocsLink => + ({ + label: overrides.id, + type: "arcade", + category: "development", + isHidden: false, + isComingSoon: false, + isBYOC: false, + isPro: false, + docsLink: + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api", + ...overrides, + }) as ToolkitWithDocsLink; + +describe("mergeToolkitCatalogFields", () => { + it("lets JSON docsLink and category override design-system fields", () => { + const merged = mergeToolkitCatalogFields( + makeDsToolkit({ id: "AlphaApi" }), + { + docsLink: + "https://docs.arcade.dev/en/resources/integrations/productivity/alpha", + category: "productivity", + } + ); + + expect(merged.docsLink).toBe( + "https://docs.arcade.dev/en/resources/integrations/productivity/alpha" + ); + expect(merged.category).toBe("productivity"); + expect(toIntegrationLink(merged)).toBe( + "/en/resources/integrations/productivity/alpha" + ); + }); + + it("keeps empty-string JSON metadata instead of falling back to DS", () => { + const merged = mergeToolkitCatalogFields( + makeDsToolkit({ + id: "AlphaApi", + category: "development", + docsLink: + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api", + }), + { + docsLink: "", + category: "", + } + ); + + expect(merged.docsLink).toBe(""); + expect(merged.category).toBe("others"); + // Empty docsLink → kebab id; empty category → others. Matches resolveToolkitRoute. + expect(toIntegrationLink(merged)).toBe( + "/en/resources/integrations/others/alpha-api" + ); + }); + + it("falls back to design-system fields when JSON omits the keys", () => { + const merged = mergeToolkitCatalogFields( + makeDsToolkit({ + id: "AlphaApi", + category: "development", + docsLink: + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api", + }), + {} + ); + + expect(merged.docsLink).toBe( + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api" + ); + expect(merged.category).toBe("development"); + }); + + it("falls back when JSON fields are null (same as ?? in resolveToolkitRoute)", () => { + const merged = mergeToolkitCatalogFields( + makeDsToolkit({ + id: "AlphaApi", + category: "development", + docsLink: + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api", + }), + { + docsLink: null, + category: null, + } + ); + + expect(merged.docsLink).toBe( + "https://docs.arcade.dev/en/resources/integrations/development/alpha-api" + ); + expect(merged.category).toBe("development"); + }); +}); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts new file mode 100644 index 000000000..70cbf0793 --- /dev/null +++ b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + INTEGRATION_CATEGORIES, + isRoutableIntegrationCategory, + normalizeCategory, + ROUTABLE_INTEGRATION_CATEGORIES, +} from "../../../app/_lib/toolkit-category"; + +describe("normalizeCategory", () => { + it("keeps known categories", () => { + for (const category of INTEGRATION_CATEGORIES) { + expect(normalizeCategory(category)).toBe(category); + } + }); + + it("maps missing and unknown values to others", () => { + expect(normalizeCategory(undefined)).toBe("others"); + expect(normalizeCategory(null)).toBe("others"); + expect(normalizeCategory("")).toBe("others"); + expect(normalizeCategory("not-a-real-category")).toBe("others"); + }); +}); + +describe("routable integration categories", () => { + it("excludes others from routable categories", () => { + expect(ROUTABLE_INTEGRATION_CATEGORIES).not.toContain("others"); + expect(isRoutableIntegrationCategory("others")).toBe(false); + expect(isRoutableIntegrationCategory("development")).toBe(true); + }); +}); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts index 8c5c59f2c..85fc21f9e 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts @@ -6,6 +6,7 @@ import { normalizeToolkitId } from "../../../app/_lib/toolkit-slug"; import { getToolkitStaticParamsForCategory, listToolkitRoutes, + listValidIntegrationLinks, type ToolkitCatalogEntry, } from "../../../app/_lib/toolkit-static-params"; @@ -227,7 +228,7 @@ describe("toolkit static params", () => { }); }); - it('maps unknown categories to "others"', async () => { + it('maps unknown categories to "others" in routes', async () => { await withTempDir(async (dir) => { await writeIndex(dir, [{ id: "Github", category: "weird" }]); @@ -239,4 +240,24 @@ describe("toolkit static params", () => { expect(routes).toEqual([{ toolkitId: "github", category: "others" }]); }); }); + + it("excludes others from clickable valid integration links", async () => { + await withTempDir(async (dir) => { + await writeIndex(dir, [ + { id: "Github", category: "weird" }, + { id: "Gmail", category: "productivity" }, + ]); + + const links = await listValidIntegrationLinks({ + dataDir: dir, + toolkitsCatalog: [], + }); + + expect(links.has("/en/resources/integrations/productivity/gmail")).toBe( + true + ); + expect(links.has("/en/resources/integrations/others/github")).toBe(false); + expect([...links].some((link) => link.includes("/others/"))).toBe(false); + }); + }); });