From 35ecefcc1607ccf621c7c274e265ef8e1c3b28b0 Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 16 Jul 2026 11:17:40 -0300 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;