Skip to content
54 changes: 38 additions & 16 deletions app/_lib/integration-catalog.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,39 +15,60 @@ 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.
*
* Toolkit JSON wins for both fields when present — same precedence as
* `listToolkitRoutes` / `getToolkitSlug`.
*/
export const getToolkitsWithDocsLinks = async (): Promise<
ToolkitWithDocsLink[]
> => {
const docsLinkById = new Map<string, string>();
const categoryById = new Map<string, string>();

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);
// 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 !== undefined) {
categoryById.set(key, data.metadata.category);
}
})
);

const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => {
const existing = getToolkitDocsLink(toolkit);
const docsLink =
existing ?? docsLinkById.get(normalizeToolkitId(toolkit.id));
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.has(key)
? docsLinkById.get(key)
: existingDocsLink;
const category = normalizeCategory(
categoryById.has(key) ? categoryById.get(key) : toolkit.category
);

return docsLink ? { ...toolkit, docsLink } : toolkit;
return {
...toolkit,
category,
...(docsLink ? { docsLink } : {}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty docsLink fails to override

Medium Severity

Explicit empty docsLink values from toolkit JSON are not correctly applied in getToolkitsWithDocsLinks. The docsLink ? { docsLink } : {} spread condition treats empty strings as falsy, allowing existing design-system docsLinks to persist. This can cause card slugs to diverge from generated routes, which correctly interpret an empty docsLink as a fallback to the ID slug.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit baee0a0. Configure here.

});

return [...dsToolkits, ...PARTNER_TOOLKITS];
return [
...dsToolkits,
...PARTNER_TOOLKITS.map((toolkit) => ({
...toolkit,
category: normalizeCategory(toolkit.category),
})),
];
};
58 changes: 47 additions & 11 deletions app/_lib/integration-index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a toolkit can’t produce a valid URL, this code throws an error in one place and catches it in another. That works, but it’s harder to follow than needed. Simpler approach: if we can’t build a URL, just skip that toolkit and move on — no throw/catch needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also: Sidebar and canonical URLs now sanitize categories (so bad values like ../../outside get mapped to others), but integration card links don’t seem to do that yet. Should those use the same category cleanup so cards and pages always match?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — toIntegrationLink now returns null for bad slugs, and resolveIndexToolkits just skips those entries (no throw/catch).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — cards now use the same client-safe normalizeCategory helper as routes/sidebar (app/_lib/toolkit-category.ts), so unknown/empty categories map to others consistently.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeCategory } from "./toolkit-category";
import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug";

const INTEGRATIONS_BASE = "/en/resources/integrations";
Expand All @@ -6,18 +7,39 @@ const INTEGRATIONS_BASE = "/en/resources/integrations";
* The integrations link a toolkit card points to: `/en/resources/integrations/
* <category>/<slug>`. 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 });
const category = toolkit.category ?? "others";
if (!slug) {
return null;
}
const category = normalizeCategory(toolkit.category);
Comment thread
cursor[bot] marked this conversation as resolved.
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
Expand All @@ -28,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.
Expand All @@ -48,19 +72,31 @@ export function resolveIndexToolkits(
}

const link = toIntegrationLink(toolkit);
const hasPage = validLinks.has(link);
if (!link) {
continue;
}

// 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;
Expand Down
34 changes: 34 additions & 0 deletions app/_lib/toolkit-category.ts
Original file line number Diff line number Diff line change
@@ -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";
}
71 changes: 52 additions & 19 deletions app/_lib/toolkit-data.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds a fair amount of validation code. Is that solving a real problem we’ve hit (bad rows in index.json), or is it defensive?

If we haven’t seen bad data in practice, we might get most of the benefit from just skipping toolkits with invalid slugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mostly defensive + consistency with the earlier request to use Zod here (generator already validates with Zod; we can’t import those schemas into app/_lib).

Practical benefit we kept: envelope validates, then filters bad index rows instead of dropping the whole catalog. Happy to slim this further to “skip invalid slugs only” if you’d rather — the slug skip path is now consistent everywhere either way.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import type {
ToolkitData,
ToolkitSummary,
Expand Down Expand Up @@ -65,22 +66,52 @@ const DEFAULT_DATA_DIR = join(
const resolveDataDir = (options?: ToolkitDataOptions): string =>
options?.dataDir ?? process.env.TOOLKIT_DATA_DIR ?? DEFAULT_DATA_DIR;

const isValidToolkitData = (parsed: unknown): parsed is ToolkitData =>
typeof parsed === "object" &&
parsed !== null &&
"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;
// 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 parseToolkitIndexEntry = (value: unknown): ToolkitIndexEntry | null => {
const result = ToolkitIndexEntrySchema.safeParse(value);
return result.success ? result.data : null;
};

const readToolkitFile = async (
filePath: string
): Promise<ToolkitData | null> => {
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;
}
Expand All @@ -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 @@ -147,18 +178,20 @@ export const readToolkitIndex = async (
try {
const content = await readFile(filePath, "utf-8");
const parsed: unknown = JSON.parse(content);
const envelope = ToolkitIndexEnvelopeSchema.safeParse(parsed);

// 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 (!envelope.success) {
return null;
}

return parsed as ToolkitIndex;
return {
generatedAt: envelope.data.generatedAt,
version: envelope.data.version,
toolkits: envelope.data.toolkits.flatMap((entry) => {
const parsedEntry = parseToolkitIndexEntry(entry);
return parsedEntry ? [parsedEntry] : [];
}),
};
} catch {
return null;
}
Expand Down
23 changes: 19 additions & 4 deletions app/_lib/toolkit-slug.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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;
const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;

export type ToolkitSlugSource = {
id: string;
Expand All @@ -14,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<Toolkit, "category"> & {
category: IntegrationCategory;
docsLink?: string | null;
isPartner?: boolean;
};
Expand Down Expand Up @@ -43,10 +49,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 +72,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;
}
Loading
Loading