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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/_lib/integration-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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.
Expand Down
56 changes: 42 additions & 14 deletions app/_lib/toolkit-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export type ToolkitIndex = {
toolkits: ToolkitIndexEntry[];
};

type ToolkitIndexEnvelope = Omit<ToolkitIndex, "toolkits"> & {
toolkits: unknown[];
};

type ToolkitDataOptions = {
dataDir?: string;
};
Expand All @@ -65,14 +69,41 @@ 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<string, unknown> =>
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<string, unknown>).metadata === "object" &&
(parsed as Record<string, unknown>).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 isToolkitIndexEnvelope = (
value: unknown
): value is ToolkitIndexEnvelope =>
isRecord(value) &&
typeof value.generatedAt === "string" &&
typeof value.version === "string" &&
Array.isArray(value.toolkits);

const readToolkitFile = async (
filePath: string
Expand Down Expand Up @@ -106,9 +137,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;
}
}
Expand Down Expand Up @@ -148,17 +179,14 @@ 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 (!isToolkitIndexEnvelope(parsed)) {
return null;
}

return parsed as ToolkitIndex;
return {
...parsed,
toolkits: parsed.toolkits.filter(isToolkitIndexEntry),
};
} catch {
return null;
}
Expand Down
16 changes: 13 additions & 3 deletions app/_lib/toolkit-slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
20 changes: 18 additions & 2 deletions app/_lib/toolkit-static-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -118,14 +128,17 @@ 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 {
// Ignore malformed toolkit data files.
}
}

return [...unique.values()];
return sortToolkitRoutes([...unique.values()]);
};

const resolveToolkitRoute = async (
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 20 additions & 3 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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(/\/+$/, "");
Expand Down Expand Up @@ -43,11 +44,27 @@ async function collectRoutes(dir: string): Promise<MetadataRoute.Sitemap> {
return entries;
}

async function collectToolkitRoutes(): Promise<MetadataRoute.Sitemap> {
const routes = await listToolkitRoutes();
return routes
.filter(({ category }) => category !== "others")
.map(({ category, toolkitId }) => ({
url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`,
}));
}

export default function sitemap(): Promise<MetadataRoute.Sitemap> {
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)
);
});
}

Expand Down
10 changes: 10 additions & 0 deletions tests/integration-index-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
36 changes: 36 additions & 0 deletions tests/sitemap-toolkit-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
12 changes: 12 additions & 0 deletions tests/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sitemap test expects excluded routes

Medium Severity

The new sitemap test expects all listToolkitRoutes URLs to be included. However, collectToolkitRoutes intentionally excludes 'others' category URLs because these paths redirect. This causes the test to fail when an 'others' toolkit exists, despite the sitemap's exclusion being correct.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 98bf485. Configure here.


// No duplicates
const duplicates = urls.filter(
(url, index, arr) => arr.indexOf(url) !== index
Expand Down
32 changes: 32 additions & 0 deletions toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ describe("toolkit data loader", () => {
});
});

it("retains valid toolkit index entries when another entry is malformed", async () => {
await withTempDir(async (dir) => {
await writeFile(
join(dir, "index.json"),
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.toMatchObject({
toolkits: [
{
id: "Github",
},
],
});
});
});

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
Expand Down
22 changes: 22 additions & 0 deletions toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
});
});
Loading
Loading