From 2cb71c6d66996a97eaa4894810ddf73e0816bf5f Mon Sep 17 00:00:00 2001 From: jottakka Date: Mon, 20 Jul 2026 16:42:30 -0300 Subject: [PATCH 1/5] Share normalizeCategory across cards, routes, and sidebar. Extract a client-safe toolkit-category helper so integration index links and catalog enrichment use the same category allow-list as static routes, instead of raw catalog categories that can miss pages or 404. Co-authored-by: Cursor --- app/_lib/integration-catalog.ts | 34 +++++++++++-------- app/_lib/integration-index.ts | 3 +- app/_lib/toolkit-category.ts | 34 +++++++++++++++++++ app/_lib/toolkit-static-params.ts | 32 +++-------------- .../integrations/_lib/toolkit-docs-page.tsx | 2 +- tests/integration-index-links.test.ts | 14 +++++++- .../scripts/sync-toolkit-sidebar.ts | 9 +++-- .../tests/app-lib/toolkit-category.test.ts | 21 ++++++++++++ 8 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 app/_lib/toolkit-category.ts create mode 100644 toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index f92cc7d31..87aee3dfb 100644 --- a/app/_lib/integration-catalog.ts +++ b/app/_lib/integration-catalog.ts @@ -14,38 +14,44 @@ const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => { /** * 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. */ export const getToolkitsWithDocsLinks = async (): Promise< ToolkitWithDocsLink[] > => { const docsLinkById = new Map(); + const categoryById = new Map(); await Promise.all( TOOLKITS.map(async (toolkit) => { - const existing = getToolkitDocsLink(toolkit); - if (existing) { + const data = await readToolkitData(toolkit.id); + if (!data) { return; } - const data = await readToolkitData(toolkit.id); - if (data?.metadata?.docsLink) { - docsLinkById.set( - normalizeToolkitId(toolkit.id), - data.metadata.docsLink - ); + const key = normalizeToolkitId(toolkit.id); + if (data.metadata?.docsLink) { + docsLinkById.set(key, data.metadata.docsLink); + } + if (data.metadata?.category) { + categoryById.set(key, data.metadata.category); } }) ); const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => { + const key = normalizeToolkitId(toolkit.id); const existing = getToolkitDocsLink(toolkit); - const docsLink = - existing ?? docsLinkById.get(normalizeToolkitId(toolkit.id)); + const docsLink = existing ?? docsLinkById.get(key); + // Toolkit JSON is source of truth for category (same as route generation). + const category = categoryById.get(key) ?? toolkit.category; - return docsLink ? { ...toolkit, docsLink } : toolkit; + return { + ...toolkit, + ...(docsLink ? { docsLink } : {}), + ...(category ? { category } : {}), + }; }); return [...dsToolkits, ...PARTNER_TOOLKITS]; diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 12d9cacbf..2e5f87379 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -1,3 +1,4 @@ +import { normalizeCategory } from "./toolkit-category"; import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug"; const INTEGRATIONS_BASE = "/en/resources/integrations"; @@ -13,7 +14,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}`; } diff --git a/app/_lib/toolkit-category.ts b/app/_lib/toolkit-category.ts new file mode 100644 index 000000000..8ca46e409 --- /dev/null +++ b/app/_lib/toolkit-category.ts @@ -0,0 +1,34 @@ +/** + * 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]; + +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-static-params.ts b/app/_lib/toolkit-static-params.ts index 1e07c5c33..3707d867f 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -1,24 +1,14 @@ 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 { + INTEGRATION_CATEGORIES, + type IntegrationCategory, + normalizeCategory, +} 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 +32,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 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/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index de50d8f9d..1037d37e8 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,18 @@ describe("resolveIndexToolkits (logic)", () => { expect(resolved.some((toolkit) => toolkit.id === "Zeta")).toBe(false); }); + test("normalizes unknown categories to others so cards match routes", () => { + 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("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/toolkit-category.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts new file mode 100644 index 000000000..32f83e343 --- /dev/null +++ b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + INTEGRATION_CATEGORIES, + normalizeCategory, +} 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"); + }); +}); From 964f078b4b606a6d7b8958de18ae24ce93a2f760 Mon Sep 17 00:00:00 2001 From: jottakka Date: Mon, 20 Jul 2026 16:49:03 -0300 Subject: [PATCH 2/5] Prefer JSON docsLink and store normalized categories. Keep catalog enrichment aligned with route generation: toolkit JSON wins for docsLink, and category values are normalized before cards/filters use them. Co-authored-by: Cursor --- app/_lib/integration-catalog.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index 87aee3dfb..718c73734 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"; @@ -16,6 +17,9 @@ const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => { * The full integrations catalog the index renders: design-system 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[] @@ -42,15 +46,17 @@ export const getToolkitsWithDocsLinks = async (): Promise< const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => { const key = normalizeToolkitId(toolkit.id); - const existing = getToolkitDocsLink(toolkit); - const docsLink = existing ?? docsLinkById.get(key); - // Toolkit JSON is source of truth for category (same as route generation). - const category = categoryById.get(key) ?? toolkit.category; + const existingDocsLink = getToolkitDocsLink(toolkit); + // JSON first so card slugs match generated routes when DS metadata is stale. + const docsLink = docsLinkById.get(key) ?? existingDocsLink; + const category = normalizeCategory( + categoryById.get(key) ?? toolkit.category + ); return { ...toolkit, + category, ...(docsLink ? { docsLink } : {}), - ...(category ? { category } : {}), }; }); From f0f3a8d78b0b71b80967c12fa2ec785766f6b14b Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 12:46:15 -0300 Subject: [PATCH 3/5] Align empty JSON metadata with route resolution. Treat explicit empty-string category/docsLink as present (matching ??), and widen ToolkitWithDocsLink so normalized "others" typechecks through the build. Co-authored-by: Cursor --- app/_lib/integration-catalog.ts | 71 ++++++++----- app/_lib/toolkit-slug.ts | 8 +- .../components/use-toolkit-filters.ts | 12 ++- .../tests/app-lib/integration-catalog.test.ts | 100 ++++++++++++++++++ 4 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 toolkit-docs-generator/tests/app-lib/integration-catalog.test.ts diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index 718c73734..849a9abf4 100644 --- a/app/_lib/integration-catalog.ts +++ b/app/_lib/integration-catalog.ts @@ -13,6 +13,40 @@ 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 `docsLink` / `category` from their data file when present, so @@ -24,41 +58,28 @@ const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => { export const getToolkitsWithDocsLinks = async (): Promise< ToolkitWithDocsLink[] > => { - const docsLinkById = new Map(); - const categoryById = new Map(); + const metadataById = new Map(); await Promise.all( TOOLKITS.map(async (toolkit) => { const data = await readToolkitData(toolkit.id); - if (!data) { + if (!data?.metadata) { return; } - const key = normalizeToolkitId(toolkit.id); - if (data.metadata?.docsLink) { - docsLinkById.set(key, data.metadata.docsLink); - } - if (data.metadata?.category) { - categoryById.set(key, data.metadata.category); - } + metadataById.set(normalizeToolkitId(toolkit.id), { + docsLink: data.metadata.docsLink, + category: data.metadata.category, + }); }) ); - const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => { - const key = normalizeToolkitId(toolkit.id); - const existingDocsLink = getToolkitDocsLink(toolkit); - // JSON first so card slugs match generated routes when DS metadata is stale. - const docsLink = docsLinkById.get(key) ?? existingDocsLink; - const category = normalizeCategory( - categoryById.get(key) ?? toolkit.category - ); - - return { - ...toolkit, - category, - ...(docsLink ? { docsLink } : {}), - }; - }); + const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => + mergeToolkitCatalogFields( + toolkit, + metadataById.get(normalizeToolkitId(toolkit.id)) + ) + ); return [...dsToolkits, ...PARTNER_TOOLKITS]; }; 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/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/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"); + }); +}); From 99ec991b5328118358447eab26327077411c4505 Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 12:48:49 -0300 Subject: [PATCH 4/5] Retrigger CI after type widen for others category. Co-authored-by: Cursor From 5b344c8bdddc7efe18d608123b658e5c71389d95 Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 12:56:59 -0300 Subject: [PATCH 5/5] Do not treat /others integration URLs as clickable pages. others redirects to the index and has no [toolkitId] route, so keep it as a normalize fallback only and exclude it from valid/clickable card links. Co-authored-by: Cursor --- app/_lib/integration-index.ts | 15 ++++++++-- app/_lib/toolkit-category.ts | 19 +++++++++++++ app/_lib/toolkit-static-params.ts | 28 +++++++++++++------ tests/integration-index-links.test.ts | 14 +++++++++- .../tests/app-lib/toolkit-category.test.ts | 10 +++++++ .../app-lib/toolkit-static-params.test.ts | 23 ++++++++++++++- 6 files changed, 96 insertions(+), 13 deletions(-) diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 2e5f87379..783c7a530 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -1,4 +1,7 @@ -import { normalizeCategory } from "./toolkit-category"; +import { + isRoutableIntegrationCategory, + normalizeCategory, +} from "./toolkit-category"; import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug"; const INTEGRATIONS_BASE = "/en/resources/integrations"; @@ -7,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; @@ -34,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[], @@ -49,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 index 8ca46e409..700433a1d 100644 --- a/app/_lib/toolkit-category.ts +++ b/app/_lib/toolkit-category.ts @@ -21,6 +21,25 @@ export const INTEGRATION_CATEGORIES = [ 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 { diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 3707d867f..5f8b2e962 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -2,9 +2,10 @@ 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 { - INTEGRATION_CATEGORIES, type IntegrationCategory, + isRoutableIntegrationCategory, normalizeCategory, + ROUTABLE_INTEGRATION_CATEGORIES, } from "./toolkit-category"; import { readToolkitData, readToolkitIndex } from "./toolkit-data"; import { getToolkitSlug, normalizeToolkitId } from "./toolkit-slug"; @@ -198,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 }); @@ -228,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/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index 1037d37e8..f15990fd5 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -93,7 +93,7 @@ describe("resolveIndexToolkits (logic)", () => { expect(resolved.some((toolkit) => toolkit.id === "Zeta")).toBe(false); }); - test("normalizes unknown categories to others so cards match routes", () => { + test("normalizes unknown categories to others for a stable card identity", () => { const link = toIntegrationLink( makeToolkit("Mystery", "not-a-real-category", "mystery") ); @@ -105,6 +105,18 @@ describe("resolveIndexToolkits (logic)", () => { 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/tests/app-lib/toolkit-category.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts index 32f83e343..70cbf0793 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-category.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { INTEGRATION_CATEGORIES, + isRoutableIntegrationCategory, normalizeCategory, + ROUTABLE_INTEGRATION_CATEGORIES, } from "../../../app/_lib/toolkit-category"; describe("normalizeCategory", () => { @@ -19,3 +21,11 @@ describe("normalizeCategory", () => { 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); + }); + }); });