From 35ecefcc1607ccf621c7c274e265ef8e1c3b28b0 Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 16 Jul 2026 11:17:40 -0300 Subject: [PATCH 1/9] fix: validate and canonicalize generated toolkit routes Use one safe slug contract across route discovery, integration cards, and the sitemap so invalid or duplicate entries cannot publish broken links. Co-authored-by: Cursor --- app/_lib/integration-index.ts | 10 +++- app/_lib/toolkit-data.ts | 48 +++++++++++----- app/_lib/toolkit-slug.ts | 16 +++++- app/_lib/toolkit-static-params.ts | 20 ++++++- app/sitemap.ts | 21 ++++++- tests/integration-index-links.test.ts | 10 ++++ tests/sitemap.test.ts | 12 ++++ .../tests/app-lib/toolkit-data.test.ts | 12 ++++ .../tests/app-lib/toolkit-slug.test.ts | 22 ++++++++ .../app-lib/toolkit-static-params.test.ts | 55 ++++++++++++++++++- 10 files changed, 202 insertions(+), 24 deletions(-) diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 12d9cacbf..d0b34a39e 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -13,6 +13,9 @@ export function toIntegrationLink(toolkit: { category?: string | null; }): string { const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build an integration link for: ${toolkit.id}`); + } const category = toolkit.category ?? "others"; return `${INTEGRATIONS_BASE}/${category}/${slug}`; } @@ -47,7 +50,12 @@ export function resolveIndexToolkits( continue; } - const link = toIntegrationLink(toolkit); + let link: string; + try { + link = toIntegrationLink(toolkit); + } catch { + continue; + } const hasPage = validLinks.has(link); // A bare duplicate of a real "-api" toolkit: drop it; the real card stays. diff --git a/app/_lib/toolkit-data.ts b/app/_lib/toolkit-data.ts index f0299c74e..22904e967 100644 --- a/app/_lib/toolkit-data.ts +++ b/app/_lib/toolkit-data.ts @@ -65,14 +65,40 @@ const DEFAULT_DATA_DIR = join( const resolveDataDir = (options?: ToolkitDataOptions): string => options?.dataDir ?? process.env.TOOLKIT_DATA_DIR ?? DEFAULT_DATA_DIR; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + const isValidToolkitData = (parsed: unknown): parsed is ToolkitData => - typeof parsed === "object" && - parsed !== null && + isRecord(parsed) && "id" in parsed && ("label" in parsed || "name" in parsed) && "metadata" in parsed && - typeof (parsed as Record).metadata === "object" && - (parsed as Record).metadata !== null; + isRecord(parsed.metadata); + +const isToolkitIndexEntry = (value: unknown): value is ToolkitIndexEntry => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === "string" && + typeof value.label === "string" && + typeof value.version === "string" && + typeof value.category === "string" && + (value.type === undefined || typeof value.type === "string") && + typeof value.toolCount === "number" && + Number.isInteger(value.toolCount) && + value.toolCount >= 0 && + typeof value.authType === "string" + ); +}; + +const isToolkitIndex = (value: unknown): value is ToolkitIndex => + isRecord(value) && + typeof value.generatedAt === "string" && + typeof value.version === "string" && + Array.isArray(value.toolkits) && + value.toolkits.every(isToolkitIndexEntry); const readToolkitFile = async ( filePath: string @@ -106,9 +132,9 @@ const findToolkitDataBySlug = async ( const candidateSlug = getToolkitSlug({ id: data.id, docsLink: data.metadata?.docsLink, - }).toLowerCase(); + })?.toLowerCase(); - if (candidateSlug === slugKey) { + if (candidateSlug && candidateSlug === slugKey) { return data; } } @@ -148,17 +174,11 @@ export const readToolkitIndex = async ( const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); - // Basic runtime validation - ensure it's an object with required fields - if ( - typeof parsed !== "object" || - parsed === null || - !("toolkits" in parsed) || - !Array.isArray((parsed as { toolkits: unknown }).toolkits) - ) { + if (!isToolkitIndex(parsed)) { return null; } - return parsed as ToolkitIndex; + return parsed; } catch { return null; } diff --git a/app/_lib/toolkit-slug.ts b/app/_lib/toolkit-slug.ts index 5ff34e21d..f8939604b 100644 --- a/app/_lib/toolkit-slug.ts +++ b/app/_lib/toolkit-slug.ts @@ -2,6 +2,7 @@ import type { Toolkit } from "@arcadeai/design-system"; const TOOLKIT_ID_NORMALIZER = /[^a-z0-9]+/g; const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; +const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; export type ToolkitSlugSource = { id: string; @@ -43,10 +44,14 @@ export function toKebabCase(value: string): string { const extractSlugFromPath = (path: string): string | null => { const segments = path.split("/").filter(Boolean); - return segments.at(-1) ?? null; + const slug = segments.at(-1); + return slug && SAFE_SLUG.test(slug) ? slug : null; }; -export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { +export function getToolkitSlug({ + id, + docsLink, +}: ToolkitSlugSource): string | null { if (docsLink) { try { const url = new URL(docsLink); @@ -62,5 +67,10 @@ export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { } } - return toKebabCase(id); + const kebabId = toKebabCase(id); + if (SAFE_SLUG.test(kebabId)) { + return kebabId; + } + const normalizedId = normalizeToolkitId(id); + return normalizedId || null; } diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 1e07c5c33..79e138464 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -31,6 +31,13 @@ export type ToolkitRouteEntry = { category: IntegrationCategory; }; +const sortToolkitRoutes = (routes: ToolkitRouteEntry[]): ToolkitRouteEntry[] => + routes.sort( + (left, right) => + left.category.localeCompare(right.category) || + left.toolkitId.localeCompare(right.toolkitId) + ); + const DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES: ToolkitCatalogEntry[] = DESIGN_SYSTEM_TOOLKITS.map((toolkit) => ({ id: toolkit.id, @@ -70,6 +77,9 @@ export function getToolkitCanonicalPath(toolkit: { }): string { const category = normalizeCategory(toolkit.category); const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build a canonical path for toolkit: ${toolkit.id}`); + } return `/en/resources/integrations/${category}/${slug}`; } @@ -118,6 +128,9 @@ const listToolkitRoutesFromDataDir = async (options?: { id: parsed.id, docsLink: parsed.metadata?.docsLink, }); + if (!slug) { + continue; + } const category = normalizeCategory(parsed.metadata?.category); unique.set(slug, { toolkitId: slug, category }); } catch { @@ -125,7 +138,7 @@ const listToolkitRoutesFromDataDir = async (options?: { } } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); }; const resolveToolkitRoute = async ( @@ -152,6 +165,9 @@ const resolveToolkitRoute = async ( id: toolkit.id, docsLink: data?.metadata?.docsLink ?? catalogEntry?.docsLink, }); + if (!slug) { + return null; + } // JSON file is the source of truth for category. The generator is responsible // for writing the correct value; the design system catalog and index.json are // only used as a last resort when the JSON is missing. @@ -192,7 +208,7 @@ export async function listToolkitRoutes(options?: { unique.set(route.toolkitId, route); } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); } export async function getToolkitStaticParamsForCategory( diff --git a/app/sitemap.ts b/app/sitemap.ts index 45b6615cf..f149c268e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { MetadataRoute } from "next"; +import { listToolkitRoutes } from "./_lib/toolkit-static-params"; const SITE_URL = process.env.SITE_URL ?? "https://docs.arcade.dev"; const NORMALIZED_SITE_URL = SITE_URL.replace(/\/+$/, ""); @@ -43,11 +44,25 @@ async function collectRoutes(dir: string): Promise { return entries; } +async function collectToolkitRoutes(): Promise { + const routes = await listToolkitRoutes(); + return routes.map(({ category, toolkitId }) => ({ + url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, + })); +} + export default function sitemap(): Promise { if (!cachedRoutes) { - cachedRoutes = collectRoutes(APP_DIR).then((routes) => { - routes.sort((a, b) => a.url.localeCompare(b.url)); - return routes; + cachedRoutes = Promise.all([ + collectToolkitRoutes(), + collectRoutes(APP_DIR), + ]).then(([toolkitRoutes, authoredRoutes]) => { + const routesByUrl = new Map( + [...toolkitRoutes, ...authoredRoutes].map((route) => [route.url, route]) + ); + return [...routesByUrl.values()].sort((a, b) => + a.url.localeCompare(b.url) + ); }); } diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index de50d8f9d..fa4bc7996 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -99,6 +99,16 @@ describe("resolveIndexToolkits (logic)", () => { ).toHaveLength(1); expect(links.length).toBe(new Set(links).size); }); + + test("drops catalog entries whose IDs cannot form a route slug", () => { + const invalid = { + ...makeToolkit("---", "development", "invalid"), + id: "---", + docsLink: null, + }; + + expect(resolveIndexToolkits([invalid], new Set())).toEqual([]); + }); }); describe("integrations index links (live data)", () => { diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts index 854fa6234..6cd930117 100644 --- a/tests/sitemap.test.ts +++ b/tests/sitemap.test.ts @@ -22,6 +22,18 @@ test("sitemap lists expected URLs", async () => { // Known page should be present expect(urls).toContain("https://example.test/en/references/changelog"); + const { listToolkitRoutes } = await import( + "../app/_lib/toolkit-static-params" + ); + const toolkitUrls = (await listToolkitRoutes()).map( + ({ category, toolkitId }) => + `https://example.test/en/resources/integrations/${category}/${toolkitId}` + ); + expect(toolkitUrls.length).toBeGreaterThan(0); + for (const toolkitUrl of toolkitUrls) { + expect(urls).toContain(toolkitUrl); + } + // No duplicates const duplicates = urls.filter( (url, index, arr) => arr.indexOf(url) !== index diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts index 91a0bdf36..30660f463 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts @@ -73,6 +73,18 @@ describe("toolkit data loader", () => { }); }); + it("rejects malformed toolkit index entries", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "index.json"), + JSON.stringify({ toolkits: [{ category: "development" }] }), + "utf-8" + ); + + await expect(readToolkitIndex({ dataDir: dir })).resolves.toBeNull(); + }); + }); + it("finds toolkit data by docsLink slug when file name doesn't match", async () => { await withTempDir(async (dir) => { // File is named posthogapi.json (normalized) but we look up by the diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts index 74d7a4017..d62910789 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts @@ -146,6 +146,24 @@ describe("getToolkitSlug", () => { expect(getToolkitSlug(source)).toBe("gmail"); }); + it("falls back when docsLink contains an unsafe encoded slug", () => { + expect( + getToolkitSlug({ + id: "UnsafeToolkit", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }) + ).toBe("unsafe-toolkit"); + }); + + it("falls back for prototype-like docsLink slugs", () => { + expect( + getToolkitSlug({ + id: "Github", + docsLink: "https://docs.arcade.dev/__proto__", + }) + ).toBe("github"); + }); + it("converts mixed-case IDs to kebab-case in fallback", () => { const source: ToolkitSlugSource = { id: "GoogleDrive", @@ -170,4 +188,8 @@ describe("getToolkitSlug", () => { }; expect(getToolkitSlug(source)).toBe("slack"); }); + + it("returns null when neither docsLink nor toolkit ID can form a slug", () => { + expect(getToolkitSlug({ id: "---", docsLink: null })).toBeNull(); + }); }); 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..6d1e3376b 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 @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { normalizeToolkitId } from "../../../app/_lib/toolkit-slug"; import { + getToolkitCanonicalPath, getToolkitStaticParamsForCategory, listToolkitRoutes, type ToolkitCatalogEntry, @@ -26,7 +27,15 @@ const writeIndex = async ( { generatedAt: "2026-01-15T00:00:00.000Z", version: "1.0.0", - toolkits, + toolkits: toolkits.map(({ id, category }) => ({ + id, + label: id, + version: "1.0.0", + category: category ?? "others", + type: "arcade", + toolCount: 1, + authType: "none", + })), }, null, 2 @@ -57,6 +66,29 @@ describe("toolkit static params", () => { expect(normalizeToolkitId("GitHub API")).toBe("githubapi"); }); + it("rejects canonical paths for toolkit IDs without a valid slug", () => { + expect(() => + getToolkitCanonicalPath({ id: "---", category: "development" }) + ).toThrow("Cannot build a canonical path"); + }); + + it("skips toolkit files whose IDs cannot form a route slug", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "invalid.json"), + JSON.stringify({ + id: "---", + label: "Invalid", + metadata: { category: "development" }, + }) + ); + + await expect( + listToolkitRoutes({ dataDir: dir, toolkitsCatalog: [] }) + ).resolves.toEqual([]); + }); + }); + it("lists toolkit routes from the index", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [ @@ -78,6 +110,27 @@ describe("toolkit static params", () => { }); }); + it("returns toolkit routes in deterministic category and slug order", async () => { + await withTempDir(async (dir) => { + await writeIndex(dir, [ + { id: "Slack", category: "social" }, + { id: "Gmail", category: "productivity" }, + { id: "Github", category: "development" }, + ]); + + const routes = await listToolkitRoutes({ + dataDir: dir, + toolkitsCatalog: [], + }); + + expect(routes).toEqual([ + { toolkitId: "github", category: "development" }, + { toolkitId: "gmail", category: "productivity" }, + { toolkitId: "slack", category: "social" }, + ]); + }); + }); + it("uses catalog category as fallback when JSON file is absent", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [{ id: "Github", category: "development" }]); From 98bf48565aa681d1368dd35aab4790cd384f67ee Mon Sep 17 00:00:00 2001 From: jottakka Date: Sat, 18 Jul 2026 09:47:09 -0300 Subject: [PATCH 2/9] fix: preserve valid toolkit routes on malformed input Keep a malformed index entry from changing the source of the entire route set, and prevent redirecting fallback-category URLs from entering the sitemap. Co-authored-by: Cursor --- app/_lib/toolkit-data.ts | 18 +++++++--- app/sitemap.ts | 8 +++-- tests/sitemap-toolkit-routes.test.ts | 36 +++++++++++++++++++ .../tests/app-lib/toolkit-data.test.ts | 26 ++++++++++++-- 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 tests/sitemap-toolkit-routes.test.ts diff --git a/app/_lib/toolkit-data.ts b/app/_lib/toolkit-data.ts index 22904e967..65481e003 100644 --- a/app/_lib/toolkit-data.ts +++ b/app/_lib/toolkit-data.ts @@ -51,6 +51,10 @@ export type ToolkitIndex = { toolkits: ToolkitIndexEntry[]; }; +type ToolkitIndexEnvelope = Omit & { + toolkits: unknown[]; +}; + type ToolkitDataOptions = { dataDir?: string; }; @@ -93,12 +97,13 @@ const isToolkitIndexEntry = (value: unknown): value is ToolkitIndexEntry => { ); }; -const isToolkitIndex = (value: unknown): value is ToolkitIndex => +const isToolkitIndexEnvelope = ( + value: unknown +): value is ToolkitIndexEnvelope => isRecord(value) && typeof value.generatedAt === "string" && typeof value.version === "string" && - Array.isArray(value.toolkits) && - value.toolkits.every(isToolkitIndexEntry); + Array.isArray(value.toolkits); const readToolkitFile = async ( filePath: string @@ -174,11 +179,14 @@ export const readToolkitIndex = async ( const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); - if (!isToolkitIndex(parsed)) { + if (!isToolkitIndexEnvelope(parsed)) { return null; } - return parsed; + return { + ...parsed, + toolkits: parsed.toolkits.filter(isToolkitIndexEntry), + }; } catch { return null; } diff --git a/app/sitemap.ts b/app/sitemap.ts index f149c268e..0c7b0a9e9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -46,9 +46,11 @@ async function collectRoutes(dir: string): Promise { async function collectToolkitRoutes(): Promise { const routes = await listToolkitRoutes(); - return routes.map(({ category, toolkitId }) => ({ - url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, - })); + return routes + .filter(({ category }) => category !== "others") + .map(({ category, toolkitId }) => ({ + url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, + })); } export default function sitemap(): Promise { diff --git a/tests/sitemap-toolkit-routes.test.ts b/tests/sitemap-toolkit-routes.test.ts new file mode 100644 index 000000000..7ad7decf6 --- /dev/null +++ b/tests/sitemap-toolkit-routes.test.ts @@ -0,0 +1,36 @@ +import { expect, test, vi } from "vitest"; + +const { listToolkitRoutes } = vi.hoisted(() => ({ + listToolkitRoutes: vi.fn(), +})); + +vi.mock("../app/_lib/toolkit-static-params", () => ({ + listToolkitRoutes, +})); + +test("sitemap excludes toolkit routes that redirect to the integrations index", async () => { + const previousSiteUrl = process.env.SITE_URL; + process.env.SITE_URL = "https://example.test"; + listToolkitRoutes.mockResolvedValue([ + { category: "development", toolkitId: "github" }, + { category: "others", toolkitId: "unknown-toolkit" }, + ]); + + try { + const { default: sitemap } = await import("../app/sitemap"); + const urls = (await sitemap()).map((entry) => entry.url); + + expect(urls).toContain( + "https://example.test/en/resources/integrations/development/github" + ); + expect(urls).not.toContain( + "https://example.test/en/resources/integrations/others/unknown-toolkit" + ); + } finally { + if (previousSiteUrl === undefined) { + process.env.SITE_URL = undefined; + } else { + process.env.SITE_URL = previousSiteUrl; + } + } +}); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts index 30660f463..719bf9c84 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts @@ -73,15 +73,35 @@ describe("toolkit data loader", () => { }); }); - it("rejects malformed toolkit index entries", async () => { + it("retains valid toolkit index entries when another entry is malformed", async () => { await withTempDir(async (dir) => { await writeFile( join(dir, "index.json"), - JSON.stringify({ toolkits: [{ category: "development" }] }), + JSON.stringify({ + generatedAt: "2026-01-15T00:00:00.000Z", + version: "1.0.0", + toolkits: [ + { + id: "Github", + label: "GitHub", + version: "1.0.0", + category: "development", + toolCount: 3, + authType: "oauth2", + }, + { category: "development" }, + ], + }), "utf-8" ); - await expect(readToolkitIndex({ dataDir: dir })).resolves.toBeNull(); + await expect(readToolkitIndex({ dataDir: dir })).resolves.toMatchObject({ + toolkits: [ + { + id: "Github", + }, + ], + }); }); }); From 1734bc683fdaced5f370589c30cb31d8d8bc936e Mon Sep 17 00:00:00 2001 From: Francisco Or Something Date: Mon, 20 Jul 2026 13:40:42 -0300 Subject: [PATCH 3/9] Keep generated sidebar links aligned with toolkit routes (#1066) fix: keep generated sidebar links aligned with toolkit routes Reuse the canonical toolkit slug and category validation when rendering sidebar metadata, including safely escaped labels and keys. Co-authored-by: Cursor --- .../scripts/sync-toolkit-sidebar.ts | 58 +++++++----------- .../scripts/sync-toolkit-sidebar.test.ts | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+), 36 deletions(-) diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 01927bec6..28634a54c 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 { getToolkitSlug } from "../../app/_lib/toolkit-slug"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -83,21 +84,13 @@ const CATEGORY_ORDER = [ "customer-support", "others", ]; +const CATEGORY_SET = new Set(CATEGORY_ORDER); const CAPITAL_LETTER_REGEX = /([A-Z])/g; const FIRST_CHARACTER_REGEX = /^./; -const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; const IDENTIFIER_KEY_REGEX = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -/** - * Convert a CamelCase string to kebab-case. - * Must stay in sync with toKebabCase in app/_lib/toolkit-slug.ts. - */ -function toKebabCase(value: string): string { - return value.replace(CAMEL_BOUNDARY, "$1-$2").toLowerCase(); -} - type ToolkitJson = { id?: string; label?: string; @@ -114,10 +107,12 @@ function renderObjectKey(key: string): string { if (IDENTIFIER_KEY_REGEX.test(key)) { return key; } - const escaped = key.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; + return JSON.stringify(key); } +const normalizeCategory = (category: string | null | undefined): string => + category && CATEGORY_SET.has(category) ? category : "others"; + export type ToolkitInfo = { id: string; slug: string; @@ -231,21 +226,6 @@ function readToolkitJson(dataDir: string, slug: string): ToolkitJson | null { return null; } -function getDocsSlugFromLink(docsLink?: string | null): string | null { - if (!docsLink) { - return null; - } - - try { - const url = new URL(docsLink); - const segments = url.pathname.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } catch { - const segments = docsLink.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } -} - /** * Read toolkit JSON and extract label if available */ @@ -294,8 +274,13 @@ function resolveToolkitInfo( ): ToolkitInfoEntry | null { const jsonData = readToolkitJson(dataDir, slug); const toolkitId = jsonData?.id ?? slug; - const docsSlug = - getDocsSlugFromLink(jsonData?.metadata?.docsLink) ?? toKebabCase(toolkitId); + const docsSlug = getToolkitSlug({ + id: toolkitId, + docsLink: jsonData?.metadata?.docsLink, + }); + if (!docsSlug) { + return null; + } const designSystemToolkit = TOOLKITS.find( (t) => t.id.toLowerCase() === toolkitId.toLowerCase() ); @@ -306,8 +291,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 +368,7 @@ export function generateCategoryMeta( category: string, integrationsBasePath: string ): string { + const safeCategory = normalizeCategory(category); const byLabel = (a: ToolkitInfo, b: ToolkitInfo) => a.label.localeCompare(b.label); @@ -393,11 +380,10 @@ export function generateCategoryMeta( .sort(byLabel); const renderEntry = (t: ToolkitInfo) => { - // Escape any quotes in the label - const escapedLabel = t.label.replace(/"/g, '\\"'); + const href = `${integrationsBasePath}/${safeCategory}/${t.slug}`; return ` ${renderObjectKey(t.slug)}: { - title: "${escapedLabel}", - href: "${integrationsBasePath}/${category}/${t.slug}", + title: ${JSON.stringify(t.label)}, + href: ${JSON.stringify(href)}, }`; }; @@ -405,7 +391,7 @@ export function generateCategoryMeta( const key = `-- ${title}`; return ` ${renderObjectKey(key)}: { type: "separator", - title: "${title}", + title: ${JSON.stringify(title)}, }`; }; @@ -460,7 +446,7 @@ export function generateMainMeta(activeCategories: string[]): string { .map((cat) => { const displayName = CATEGORY_NAMES[cat] || cat; return ` ${renderObjectKey(cat)}: { - title: "${displayName}", + title: ${JSON.stringify(displayName)}, }`; }) .join(",\n"); 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 273ff1e19..98dbe62b4 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -270,6 +270,51 @@ describe("buildToolkitInfoList", () => { expect(entry?.category).toBe("sales"); }); + it("maps unknown categories to the safe others directory", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "../../outside", + docsLink: "https://docs.arcade.dev/integrations/unsafe", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.category).toBe("others"); + }); + + it("falls back when docsLink does not end in a safe slug", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafe-toolkit"); + }); + + it("uses the shared normalized fallback for legacy toolkit IDs", () => { + createToolkitJson("unsafe", { + id: "Unsafe Toolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafetoolkit"); + }); + it("should dedupe entries that share a docsLink slug", () => { createToolkitJson("upclickapi", { id: "UpclickApi", @@ -505,6 +550,22 @@ describe("generateCategoryMeta", () => { expect(result).toContain('title: "Test \\"Quoted\\" Label"'); }); + it("should escape backslashes and newlines in labels", () => { + const toolkits: ToolkitInfo[] = [ + { + id: "test", + slug: "test", + label: "Test\\Label\nNext", + category: "others", + navGroup: "optimized", + }, + ]; + + const result = generateCategoryMeta(toolkits, "others", "/preview"); + + expect(result).toContain('title: "Test\\\\Label\\nNext"'); + }); + it("should handle empty array", () => { const result = generateCategoryMeta([], "productivity", "/preview"); From 60b763cba8e11459807ae36ea5f3d23b0b034cb4 Mon Sep 17 00:00:00 2001 From: jottakka Date: Mon, 20 Jul 2026 16:33:34 -0300 Subject: [PATCH 4/9] Address review feedback on toolkit route validation. Use app-local Zod schemas for toolkit data/index parsing, align the sitemap test with the others-category exclusion, and reuse the shared normalizeCategory helper in sidebar sync. Co-authored-by: Cursor --- app/_lib/toolkit-data.ts | 87 ++++++++++--------- tests/sitemap.test.ts | 14 ++- .../scripts/sync-toolkit-sidebar.ts | 5 +- 3 files changed, 57 insertions(+), 49 deletions(-) diff --git a/app/_lib/toolkit-data.ts b/app/_lib/toolkit-data.ts index 65481e003..af968e60f 100644 --- a/app/_lib/toolkit-data.ts +++ b/app/_lib/toolkit-data.ts @@ -1,5 +1,6 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import { z } from "zod"; import type { ToolkitData, ToolkitSummary, @@ -51,10 +52,6 @@ export type ToolkitIndex = { toolkits: ToolkitIndexEntry[]; }; -type ToolkitIndexEnvelope = Omit & { - toolkits: unknown[]; -}; - type ToolkitDataOptions = { dataDir?: string; }; @@ -69,41 +66,44 @@ const DEFAULT_DATA_DIR = join( const resolveDataDir = (options?: ToolkitDataOptions): string => options?.dataDir ?? process.env.TOOLKIT_DATA_DIR ?? DEFAULT_DATA_DIR; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const isValidToolkitData = (parsed: unknown): parsed is ToolkitData => - isRecord(parsed) && - "id" in parsed && - ("label" in parsed || "name" in parsed) && - "metadata" in parsed && - isRecord(parsed.metadata); - -const isToolkitIndexEntry = (value: unknown): value is ToolkitIndexEntry => { - if (!isRecord(value)) { - return false; - } - - return ( - typeof value.id === "string" && - typeof value.label === "string" && - typeof value.version === "string" && - typeof value.category === "string" && - (value.type === undefined || typeof value.type === "string") && - typeof value.toolCount === "number" && - Number.isInteger(value.toolCount) && - value.toolCount >= 0 && - typeof value.authType === "string" - ); +// App-local Zod schemas (generator schemas live under toolkit-docs-generator and +// can't be imported here due to module resolution). Keep the index envelope +// loose so one bad entry is filtered instead of dropping the whole catalog. +const ToolkitIndexEntrySchema = z.object({ + id: z.string(), + label: z.string(), + version: z.string(), + category: z.string(), + type: z.string().optional(), + toolCount: z.number().int().nonnegative(), + authType: z.string(), +}); + +const ToolkitIndexEnvelopeSchema = z.object({ + generatedAt: z.string(), + version: z.string(), + toolkits: z.array(z.unknown()), +}); + +const ToolkitDataSchema = z + .object({ + id: z.string(), + label: z.string().optional(), + name: z.string().optional(), + metadata: z.object({}).passthrough(), + }) + .passthrough() + .refine((value) => value.label !== undefined || value.name !== undefined); + +const parseToolkitData = (parsed: unknown): ToolkitData | null => { + const result = ToolkitDataSchema.safeParse(parsed); + return result.success ? (result.data as ToolkitData) : null; }; -const isToolkitIndexEnvelope = ( - value: unknown -): value is ToolkitIndexEnvelope => - isRecord(value) && - typeof value.generatedAt === "string" && - typeof value.version === "string" && - Array.isArray(value.toolkits); +const parseToolkitIndexEntry = (value: unknown): ToolkitIndexEntry | null => { + const result = ToolkitIndexEntrySchema.safeParse(value); + return result.success ? result.data : null; +}; const readToolkitFile = async ( filePath: string @@ -111,7 +111,7 @@ const readToolkitFile = async ( try { const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); - return isValidToolkitData(parsed) ? (parsed as ToolkitData) : null; + return parseToolkitData(parsed); } catch { return null; } @@ -178,14 +178,19 @@ export const readToolkitIndex = async ( try { const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); + const envelope = ToolkitIndexEnvelopeSchema.safeParse(parsed); - if (!isToolkitIndexEnvelope(parsed)) { + if (!envelope.success) { return null; } return { - ...parsed, - toolkits: parsed.toolkits.filter(isToolkitIndexEntry), + generatedAt: envelope.data.generatedAt, + version: envelope.data.version, + toolkits: envelope.data.toolkits.flatMap((entry) => { + const parsedEntry = parseToolkitIndexEntry(entry); + return parsedEntry ? [parsedEntry] : []; + }), }; } catch { return null; diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts index 6cd930117..9b8e0ccea 100644 --- a/tests/sitemap.test.ts +++ b/tests/sitemap.test.ts @@ -25,14 +25,20 @@ test("sitemap lists expected URLs", async () => { const { listToolkitRoutes } = await import( "../app/_lib/toolkit-static-params" ); - const toolkitUrls = (await listToolkitRoutes()).map( - ({ category, toolkitId }) => - `https://example.test/en/resources/integrations/${category}/${toolkitId}` - ); + // Sitemap omits `others` — those paths redirect to the integrations index. + const toolkitUrls = (await listToolkitRoutes()) + .filter(({ category }) => category !== "others") + .map( + ({ category, toolkitId }) => + `https://example.test/en/resources/integrations/${category}/${toolkitId}` + ); expect(toolkitUrls.length).toBeGreaterThan(0); for (const toolkitUrl of toolkitUrls) { expect(urls).toContain(toolkitUrl); } + expect( + urls.some((url) => url.includes("/resources/integrations/others/")) + ).toBe(false); // No duplicates const duplicates = urls.filter( diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 28634a54c..f3a0c6d3f 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -29,6 +29,7 @@ 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 { getToolkitSlug } from "../../app/_lib/toolkit-slug"; +import { normalizeCategory } from "../../app/_lib/toolkit-static-params"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -84,7 +85,6 @@ const CATEGORY_ORDER = [ "customer-support", "others", ]; -const CATEGORY_SET = new Set(CATEGORY_ORDER); const CAPITAL_LETTER_REGEX = /([A-Z])/g; const FIRST_CHARACTER_REGEX = /^./; @@ -110,9 +110,6 @@ function renderObjectKey(key: string): string { return JSON.stringify(key); } -const normalizeCategory = (category: string | null | undefined): string => - category && CATEGORY_SET.has(category) ? category : "others"; - export type ToolkitInfo = { id: string; slug: string; From cec25763c6cff3032566a51ec49001115568a20d Mon Sep 17 00:00:00 2001 From: jottakka Date: Mon, 20 Jul 2026 16:42:30 -0300 Subject: [PATCH 5/9] 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 | 15 ++++---- 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 | 2 +- .../tests/app-lib/toolkit-category.test.ts | 21 ++++++++++++ 8 files changed, 103 insertions(+), 51 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 d0b34a39e..30e7597e7 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"; @@ -6,17 +7,19 @@ 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. + * + * Returns null when the toolkit cannot form a safe slug — callers skip it. */ export function toIntegrationLink(toolkit: { id: string; docsLink?: string | null; category?: string | null; -}): string { +}): string | null { const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); if (!slug) { - throw new Error(`Cannot build an integration link for: ${toolkit.id}`); + return null; } - const category = toolkit.category ?? "others"; + const category = normalizeCategory(toolkit.category); return `${INTEGRATIONS_BASE}/${category}/${slug}`; } @@ -50,10 +53,8 @@ export function resolveIndexToolkits( continue; } - let link: string; - try { - link = toIntegrationLink(toolkit); - } catch { + const link = toIntegrationLink(toolkit); + if (!link) { continue; } const hasPage = validLinks.has(link); 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 79e138464..554d271db 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; @@ -49,18 +39,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 fa4bc7996..706d3d955 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 f3a0c6d3f..500286ad6 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -28,8 +28,8 @@ 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"; import { getToolkitSlug } from "../../app/_lib/toolkit-slug"; -import { normalizeCategory } from "../../app/_lib/toolkit-static-params"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); 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 58c188dbc683c2a85bfbc584bf4947908d2cbe05 Mon Sep 17 00:00:00 2001 From: jottakka Date: Mon, 20 Jul 2026 16:49:03 -0300 Subject: [PATCH 6/9] 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 baee0a00bd3d2c5822291775c1dc3e65257db4fc Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 12:43:48 -0300 Subject: [PATCH 7/9] Address Teal review: skip bad URLs, share category helper, dedupe tests. Return null instead of throw/catch for invalid integration/canonical URLs, reuse listValidIntegrationLinks in the sitemap (excluding others), fold in client-safe category normalization for cards, and trim overlapping sitemap and sidebar tests. Co-authored-by: Cursor --- app/_lib/integration-catalog.ts | 12 +++-- app/_lib/toolkit-static-params.ts | 4 +- .../integrations/_lib/toolkit-docs-page.tsx | 2 +- .../components/toolkits-client.tsx | 6 ++- app/sitemap.ts | 22 ++++++--- scripts/generate-llmstxt.ts | 8 +++- tests/integration-index-links.test.ts | 14 +++--- tests/sitemap-toolkit-routes.test.ts | 22 +++++---- tests/sitemap.test.ts | 19 +------- .../app-lib/toolkit-static-params.test.ts | 6 +-- .../scripts/sync-toolkit-sidebar.test.ts | 45 ------------------- 11 files changed, 65 insertions(+), 95 deletions(-) diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index 718c73734..afd6d2f83 100644 --- a/app/_lib/integration-catalog.ts +++ b/app/_lib/integration-catalog.ts @@ -35,10 +35,12 @@ export const getToolkitsWithDocsLinks = async (): Promise< } const key = normalizeToolkitId(toolkit.id); - if (data.metadata?.docsLink) { + // Preserve explicit empty strings so JSON wins over design-system values + // the same way route helpers use `??` (empty → others / no docsLink slug). + if (data.metadata?.docsLink !== undefined) { docsLinkById.set(key, data.metadata.docsLink); } - if (data.metadata?.category) { + if (data.metadata?.category !== undefined) { categoryById.set(key, data.metadata.category); } }) @@ -48,9 +50,11 @@ export const getToolkitsWithDocsLinks = async (): Promise< 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 docsLink = docsLinkById.has(key) + ? docsLinkById.get(key) + : existingDocsLink; const category = normalizeCategory( - categoryById.get(key) ?? toolkit.category + categoryById.has(key) ? categoryById.get(key) : toolkit.category ); return { diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 554d271db..72e2b7ec4 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -52,11 +52,11 @@ export function getToolkitCanonicalPath(toolkit: { id: string; category?: string | null; docsLink?: string | null; -}): string { +}): string | null { const category = normalizeCategory(toolkit.category); const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); if (!slug) { - throw new Error(`Cannot build a canonical path for toolkit: ${toolkit.id}`); + return null; } return `/en/resources/integrations/${category}/${slug}`; } diff --git a/app/en/resources/integrations/_lib/toolkit-docs-page.tsx b/app/en/resources/integrations/_lib/toolkit-docs-page.tsx index 9172bfde5..2be97fc16 100644 --- a/app/en/resources/integrations/_lib/toolkit-docs-page.tsx +++ b/app/en/resources/integrations/_lib/toolkit-docs-page.tsx @@ -57,7 +57,7 @@ export function createToolkitDocsPage(category: IntegrationCategory) { const metadata: Metadata = { title: data.label || data.id, description: data.description || "Generated MCP server documentation.", - alternates: { canonical }, + ...(canonical ? { alternates: { canonical } } : {}), }; // Hidden toolkits stay reachable via the dynamic route (and render as diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx index 291cfb6e9..f6d941c1f 100644 --- a/app/en/resources/integrations/components/toolkits-client.tsx +++ b/app/en/resources/integrations/components/toolkits-client.tsx @@ -170,6 +170,10 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { ) : (
{filteredToolkits.map((toolkit) => { + const link = toIntegrationLink(toolkit); + if (!link) { + return null; + } // Get icon with fallback for API toolkits (e.g., GithubApi → Github) const IconComponent = getToolkitIconWithFallback(toolkit.id); // Use publicIconUrl from Design System as additional fallback @@ -191,7 +195,7 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { isPartner={toolkit.isPartner} isPro={toolkit.isPro} key={toolkit.id} - link={toIntegrationLink(toolkit)} + link={link} name={toolkit.label} type={toolkit.type} /> diff --git a/app/sitemap.ts b/app/sitemap.ts index 0c7b0a9e9..473e03dc8 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { MetadataRoute } from "next"; -import { listToolkitRoutes } from "./_lib/toolkit-static-params"; +import { listValidIntegrationLinks } from "./_lib/toolkit-static-params"; const SITE_URL = process.env.SITE_URL ?? "https://docs.arcade.dev"; const NORMALIZED_SITE_URL = SITE_URL.replace(/\/+$/, ""); const APP_DIR = path.join(process.cwd(), "app"); const SKIP_DIRS = new Set(["_meta", "_api", "_redirects", "api"]); const INDEX_SUFFIX_REGEX = /\/index$/; +const OTHERS_INTEGRATION_PATH = "/resources/integrations/others/"; let cachedRoutes: Promise | null = null; async function collectRoutes(dir: string): Promise { @@ -45,12 +46,19 @@ async function collectRoutes(dir: string): Promise { } async function collectToolkitRoutes(): Promise { - const routes = await listToolkitRoutes(); - return routes - .filter(({ category }) => category !== "others") - .map(({ category, toolkitId }) => ({ - url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, - })); + try { + // Reuse the shared valid-link helper so sitemap URLs match index cards. + // Drop `others` — those paths redirect away in next.config.ts. + const links = await listValidIntegrationLinks(); + return [...links] + .filter((link) => !link.includes(OTHERS_INTEGRATION_PATH)) + .map((link) => ({ + url: `${NORMALIZED_SITE_URL}${link}`, + })); + } catch { + // Toolkit data missing/unreadable must not wipe the authored-page sitemap. + return []; + } } export default function sitemap(): Promise { diff --git a/scripts/generate-llmstxt.ts b/scripts/generate-llmstxt.ts index 53797dc06..fa7263e09 100644 --- a/scripts/generate-llmstxt.ts +++ b/scripts/generate-llmstxt.ts @@ -313,11 +313,15 @@ async function discoverToolkitPages(): Promise< if (allowedIds && !allowedIds.has(data.id)) { continue; } - const url = `${BASE_URL}${getToolkitCanonicalPath({ + const canonical = getToolkitCanonicalPath({ id: data.id, category: data.metadata?.category, docsLink: data.metadata?.docsLink, - })}`; + }); + if (!canonical) { + continue; + } + const url = `${BASE_URL}${canonical}`; pagesByUrl.set(url, { path: path.relative(process.cwd(), filePath), url, diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index 706d3d955..a1f8dd2cf 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -74,7 +74,9 @@ describe("resolveIndexToolkits (logic)", () => { ]; const resolved = resolveIndexToolkits(catalog, validLinks); const byId = (id: string) => resolved.find((toolkit) => toolkit.id === id); - const links = resolved.map(toIntegrationLink); + const links = resolved + .map((toolkit) => toIntegrationLink(toolkit)) + .filter((link): link is string => link !== null); test("drops a bare entry when its '-api' sibling owns the page", () => { expect(resolved.some((toolkit) => toolkit.id === "Alpha")).toBe(false); @@ -140,13 +142,15 @@ describe("integrations index links (live data)", () => { const brokenClickable = resolved .filter((toolkit) => toolkit.hasPage) .map((toolkit) => toIntegrationLink(toolkit)) - .filter((link) => !validLinks.has(link)); + .filter((link): link is string => link !== null && !validLinks.has(link)); expect(brokenClickable).toEqual([]); }); test("every rendered card resolves to a unique link (duplicates collapse)", () => { - const links = resolved.map((toolkit) => toIntegrationLink(toolkit)); + const links = resolved + .map((toolkit) => toIntegrationLink(toolkit)) + .filter((link): link is string => link !== null); expect(links.length).toBe(new Set(links).size); }); @@ -403,8 +407,8 @@ describe("toolkit page canonical hygiene", () => { category: parsed.metadata?.category, docsLink: parsed.metadata?.docsLink, }); - if (!validLinks.has(canonical)) { - orphans.push(`${file} → ${canonical}`); + if (!(canonical && validLinks.has(canonical))) { + orphans.push(`${file} → ${canonical ?? "(null)"}`); } } expect(orphans).toEqual([]); diff --git a/tests/sitemap-toolkit-routes.test.ts b/tests/sitemap-toolkit-routes.test.ts index 7ad7decf6..8b9a78430 100644 --- a/tests/sitemap-toolkit-routes.test.ts +++ b/tests/sitemap-toolkit-routes.test.ts @@ -1,20 +1,23 @@ import { expect, test, vi } from "vitest"; -const { listToolkitRoutes } = vi.hoisted(() => ({ - listToolkitRoutes: vi.fn(), +const { listValidIntegrationLinks } = vi.hoisted(() => ({ + listValidIntegrationLinks: vi.fn(), })); vi.mock("../app/_lib/toolkit-static-params", () => ({ - listToolkitRoutes, + listValidIntegrationLinks, })); -test("sitemap excludes toolkit routes that redirect to the integrations index", async () => { +test("sitemap reuses valid integration links and excludes others", async () => { const previousSiteUrl = process.env.SITE_URL; process.env.SITE_URL = "https://example.test"; - listToolkitRoutes.mockResolvedValue([ - { category: "development", toolkitId: "github" }, - { category: "others", toolkitId: "unknown-toolkit" }, - ]); + listValidIntegrationLinks.mockResolvedValue( + new Set([ + "/en/resources/integrations/development/github", + "/en/resources/integrations/others/unknown-toolkit", + "/en/resources/integrations/search/tavily", + ]) + ); try { const { default: sitemap } = await import("../app/sitemap"); @@ -23,6 +26,9 @@ test("sitemap excludes toolkit routes that redirect to the integrations index", expect(urls).toContain( "https://example.test/en/resources/integrations/development/github" ); + expect(urls).toContain( + "https://example.test/en/resources/integrations/search/tavily" + ); expect(urls).not.toContain( "https://example.test/en/resources/integrations/others/unknown-toolkit" ); diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts index 9b8e0ccea..549b46fb4 100644 --- a/tests/sitemap.test.ts +++ b/tests/sitemap.test.ts @@ -22,23 +22,8 @@ test("sitemap lists expected URLs", async () => { // Known page should be present expect(urls).toContain("https://example.test/en/references/changelog"); - const { listToolkitRoutes } = await import( - "../app/_lib/toolkit-static-params" - ); - // Sitemap omits `others` — those paths redirect to the integrations index. - const toolkitUrls = (await listToolkitRoutes()) - .filter(({ category }) => category !== "others") - .map( - ({ category, toolkitId }) => - `https://example.test/en/resources/integrations/${category}/${toolkitId}` - ); - expect(toolkitUrls.length).toBeGreaterThan(0); - for (const toolkitUrl of toolkitUrls) { - expect(urls).toContain(toolkitUrl); - } - expect( - urls.some((url) => url.includes("/resources/integrations/others/")) - ).toBe(false); + // Toolkit URL inclusion / others exclusion is covered in + // tests/sitemap-toolkit-routes.test.ts // No duplicates const duplicates = urls.filter( 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 6d1e3376b..ea34c099b 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 @@ -66,10 +66,10 @@ describe("toolkit static params", () => { expect(normalizeToolkitId("GitHub API")).toBe("githubapi"); }); - it("rejects canonical paths for toolkit IDs without a valid slug", () => { - expect(() => + it("returns null canonical paths for toolkit IDs without a valid slug", () => { + expect( getToolkitCanonicalPath({ id: "---", category: "development" }) - ).toThrow("Cannot build a canonical path"); + ).toBeNull(); }); it("skips toolkit files whose IDs cannot form a route slug", async () => { 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 98dbe62b4..7a8a86570 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -270,51 +270,6 @@ describe("buildToolkitInfoList", () => { expect(entry?.category).toBe("sales"); }); - it("maps unknown categories to the safe others directory", () => { - createToolkitJson("unsafe", { - id: "UnsafeToolkit", - label: "Unsafe", - metadata: { - category: "../../outside", - docsLink: "https://docs.arcade.dev/integrations/unsafe", - }, - }); - - const [entry] = buildToolkitInfoList(TEST_DATA_DIR); - - expect(entry?.category).toBe("others"); - }); - - it("falls back when docsLink does not end in a safe slug", () => { - createToolkitJson("unsafe", { - id: "UnsafeToolkit", - label: "Unsafe", - metadata: { - category: "development", - docsLink: "https://docs.arcade.dev/bad%2Fslug", - }, - }); - - const [entry] = buildToolkitInfoList(TEST_DATA_DIR); - - expect(entry?.slug).toBe("unsafe-toolkit"); - }); - - it("uses the shared normalized fallback for legacy toolkit IDs", () => { - createToolkitJson("unsafe", { - id: "Unsafe Toolkit", - label: "Unsafe", - metadata: { - category: "development", - docsLink: "https://docs.arcade.dev/bad%2Fslug", - }, - }); - - const [entry] = buildToolkitInfoList(TEST_DATA_DIR); - - expect(entry?.slug).toBe("unsafetoolkit"); - }); - it("should dedupe entries that share a docsLink slug", () => { createToolkitJson("upclickapi", { id: "UpclickApi", From 7dd396b460eb046a2379437e88c49e52a35562f4 Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 13:58:30 -0300 Subject: [PATCH 8/9] Fix ToolkitWithDocsLink category type for others. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeCategory can return docs-only "others", which is not in the design-system ToolkitCategory union — widen the docs toolkit type and filter helper so the Next build typecheck passes. Co-authored-by: Cursor --- app/_lib/integration-catalog.ts | 8 +++++++- app/_lib/toolkit-slug.ts | 7 ++++++- .../components/use-toolkit-filters.ts | 17 ++++++++++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/_lib/integration-catalog.ts b/app/_lib/integration-catalog.ts index afd6d2f83..1b6b55646 100644 --- a/app/_lib/integration-catalog.ts +++ b/app/_lib/integration-catalog.ts @@ -64,5 +64,11 @@ export const getToolkitsWithDocsLinks = async (): Promise< }; }); - return [...dsToolkits, ...PARTNER_TOOLKITS]; + return [ + ...dsToolkits, + ...PARTNER_TOOLKITS.map((toolkit) => ({ + ...toolkit, + category: normalizeCategory(toolkit.category), + })), + ]; }; diff --git a/app/_lib/toolkit-slug.ts b/app/_lib/toolkit-slug.ts index f8939604b..b042492be 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; @@ -15,8 +16,12 @@ 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 to `IntegrationCategory` so docs routes can use + * `others` (design-system `ToolkitCategory` does not include that value). */ -export type ToolkitWithDocsLink = Toolkit & { +export type ToolkitWithDocsLink = Omit & { + 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..a52aeeaf1 100644 --- a/app/en/resources/integrations/components/use-toolkit-filters.ts +++ b/app/en/resources/integrations/components/use-toolkit-filters.ts @@ -1,4 +1,4 @@ -import type { Toolkit, ToolkitType } from "@arcadeai/design-system"; +import type { ToolkitType } from "@arcadeai/design-system"; import { useDebounce } from "@uidotdev/usehooks"; import { useMemo } from "react"; import { useFilters } from "./use-filters"; @@ -6,6 +6,17 @@ import { useFilters } from "./use-filters"; const DEFAULT_PRIORITY = 5; const DEBOUNCE_TIME = 300; +/** Minimal shape for catalog filtering — allows docs `others` category. */ +type FilterableToolkit = { + label: string; + category: string; + type: string; + isHidden?: boolean; + isPro?: boolean; + isBYOC?: boolean; + isComingSoon?: boolean; +}; + const TYPE_PRIORITY: Record = { arcade: 0, arcade_starter: 1, @@ -25,7 +36,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 +56,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, From bac2dceb9754740861ad14fc8efda4588091682e Mon Sep 17 00:00:00 2001 From: jottakka Date: Tue, 21 Jul 2026 14:10:18 -0300 Subject: [PATCH 9/9] Keep lone bare catalog entries via their -api page. Only drop a bare name when another catalog toolkit owns the -api URL; otherwise remap the card onto that page and store the resolved link on the index entry so the client does not recompute a dead bare href. Co-authored-by: Cursor --- app/_lib/integration-index.ts | 45 +++++++++++++++---- .../components/toolkits-client.tsx | 11 +---- tests/integration-index-links.test.ts | 24 ++++++---- 3 files changed, 54 insertions(+), 26 deletions(-) diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 30e7597e7..0f3eb026a 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -23,7 +23,23 @@ export function toIntegrationLink(toolkit: { return `${INTEGRATIONS_BASE}/${category}/${slug}`; } -export type ResolvedIndexToolkit = ToolkitWithDocsLink & { hasPage: boolean }; +export type ResolvedIndexToolkit = ToolkitWithDocsLink & { + hasPage: boolean; + /** Final card href (may remap bare names onto a real `-api` page). */ + link: string; +}; + +const catalogOwnsLink = ( + toolkits: ToolkitWithDocsLink[], + targetLink: string, + except: ToolkitWithDocsLink +): boolean => + toolkits.some((other) => { + if (other === except || other.isHidden) { + return false; + } + return toIntegrationLink(other) === targetLink; + }); /** * Decide which catalog toolkits the integrations index should render, and @@ -34,8 +50,10 @@ export type ResolvedIndexToolkit = ToolkitWithDocsLink & { hasPage: boolean }; * no generated docs page — linking to them 404s. Given the set of links that * actually resolve (`validLinks`: dynamic toolkit routes + authored static * pages), this: - * - drops a bare entry when its `-api` sibling owns the real page (collapses - * Datadog/DatadogApi, Vercel/VercelApi, Ashby/AshbyApi, Customerio/...), + * - drops a bare entry when another catalog toolkit owns the `-api` page + * (collapses Datadog/DatadogApi, …), + * - remaps a lone bare entry onto the `-api` page when that page exists but + * no sibling catalog entry claims it, * - 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. @@ -57,19 +75,28 @@ export function resolveIndexToolkits( if (!link) { continue; } - const hasPage = validLinks.has(link); - // A bare duplicate of a real "-api" toolkit: drop it; the real card stays. + let resolvedLink = link; + let hasPage = validLinks.has(link); + + // Bare name with no page, but a real `-api` page exists. if (!hasPage && validLinks.has(`${link}-api`)) { - continue; + const apiLink = `${link}-api`; + if (catalogOwnsLink(toolkits, apiLink, toolkit)) { + // Sibling (e.g. DatadogApi) owns the page — drop the bare duplicate. + continue; + } + // Lone bare entry: surface the generated `-api` page instead of vanishing. + resolvedLink = apiLink; + hasPage = true; } // Collapse multiple catalog entries that point at the same URL. - if (seen.has(link)) { + if (seen.has(resolvedLink)) { continue; } - seen.add(link); - resolved.push({ ...toolkit, hasPage }); + seen.add(resolvedLink); + resolved.push({ ...toolkit, hasPage, link: resolvedLink }); } return resolved; diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx index f6d941c1f..8225efbfe 100644 --- a/app/en/resources/integrations/components/toolkits-client.tsx +++ b/app/en/resources/integrations/components/toolkits-client.tsx @@ -5,10 +5,7 @@ import { cn } from "@arcadeai/design-system/lib/utils"; import { Code2, MessageSquarePlus, Search } from "lucide-react"; import Link from "next/link"; import { ComingSoonProvider } from "@/app/_components/coming-soon-context"; -import { - type ResolvedIndexToolkit, - toIntegrationLink, -} from "@/app/_lib/integration-index"; +import type { ResolvedIndexToolkit } from "@/app/_lib/integration-index"; import { FiltersBar } from "./filters-bar"; import { ToolCard } from "./tool-card"; import { TYPE_CONFIG, TYPE_DESCRIPTIONS } from "./type-config"; @@ -170,10 +167,6 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { ) : (
{filteredToolkits.map((toolkit) => { - const link = toIntegrationLink(toolkit); - if (!link) { - return null; - } // Get icon with fallback for API toolkits (e.g., GithubApi → Github) const IconComponent = getToolkitIconWithFallback(toolkit.id); // Use publicIconUrl from Design System as additional fallback @@ -195,7 +188,7 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { isPartner={toolkit.isPartner} isPro={toolkit.isPro} key={toolkit.id} - link={link} + link={toolkit.link} name={toolkit.label} type={toolkit.type} /> diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index a1f8dd2cf..09639b316 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -74,15 +74,25 @@ describe("resolveIndexToolkits (logic)", () => { ]; const resolved = resolveIndexToolkits(catalog, validLinks); const byId = (id: string) => resolved.find((toolkit) => toolkit.id === id); - const links = resolved - .map((toolkit) => toIntegrationLink(toolkit)) - .filter((link): link is string => link !== null); + const links = resolved.map((toolkit) => toolkit.link); test("drops a bare entry when its '-api' sibling owns the page", () => { expect(resolved.some((toolkit) => toolkit.id === "Alpha")).toBe(false); expect(byId("AlphaApi")?.hasPage).toBe(true); }); + test("remaps a lone bare entry onto the -api page when no sibling owns it", () => { + const loneBare = resolveIndexToolkits( + [makeToolkit("Lone", "development", "lone")], + new Set([`${INTEGRATIONS}/development/lone-api`]) + ); + + expect(loneBare).toHaveLength(1); + expect(loneBare[0]?.id).toBe("Lone"); + expect(loneBare[0]?.hasPage).toBe(true); + expect(loneBare[0]?.link).toBe(`${INTEGRATIONS}/development/lone-api`); + }); + test("keeps a toolkit without a page, but marks it non-clickable", () => { expect(byId("Beta")?.hasPage).toBe(false); }); @@ -141,16 +151,14 @@ describe("integrations index links (live data)", () => { test("no clickable card links to a page that does not exist", () => { const brokenClickable = resolved .filter((toolkit) => toolkit.hasPage) - .map((toolkit) => toIntegrationLink(toolkit)) - .filter((link): link is string => link !== null && !validLinks.has(link)); + .map((toolkit) => toolkit.link) + .filter((link) => !validLinks.has(link)); expect(brokenClickable).toEqual([]); }); test("every rendered card resolves to a unique link (duplicates collapse)", () => { - const links = resolved - .map((toolkit) => toIntegrationLink(toolkit)) - .filter((link): link is string => link !== null); + const links = resolved.map((toolkit) => toolkit.link); expect(links.length).toBe(new Set(links).size); });