Skip to content
Closed
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
73 changes: 53 additions & 20 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 @@ -12,41 +13,73 @@ const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => {
return;
};

type JsonToolkitMetadata = {
docsLink?: string | null;
category?: string | null;
};

/**
* Apply toolkit JSON metadata onto a design-system catalog entry.
*
* Presence is nullish (`typeof === "string"`), not truthy — an explicit empty
* string in JSON must win over DS fields, matching `resolveToolkitRoute`'s `??`
* so card URLs stay aligned with `listValidIntegrationLinks`.
*/
export const mergeToolkitCatalogFields = (
toolkit: Toolkit,
jsonMetadata?: JsonToolkitMetadata
): ToolkitWithDocsLink => {
const existingDocsLink = getToolkitDocsLink(toolkit);
const docsLink =
typeof jsonMetadata?.docsLink === "string"
? jsonMetadata.docsLink
: existingDocsLink;
const category = normalizeCategory(
typeof jsonMetadata?.category === "string"
? jsonMetadata.category
: toolkit.category
);

return {
...toolkit,
category,
...(docsLink !== undefined && docsLink !== null ? { docsLink } : {}),
};
};

/**
* The full integrations catalog the index renders: design-system toolkits
* (enriched with 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 metadataById = new Map<string, JsonToolkitMetadata>();

await Promise.all(
TOOLKITS.map(async (toolkit) => {
const existing = getToolkitDocsLink(toolkit);
if (existing) {
const data = await readToolkitData(toolkit.id);
if (!data?.metadata) {
return;
}

const data = await readToolkitData(toolkit.id);
if (data?.metadata?.docsLink) {
docsLinkById.set(
normalizeToolkitId(toolkit.id),
data.metadata.docsLink
);
}
metadataById.set(normalizeToolkitId(toolkit.id), {
docsLink: data.metadata.docsLink,
category: data.metadata.category,
});
})
);

const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => {
const existing = getToolkitDocsLink(toolkit);
const docsLink =
existing ?? docsLinkById.get(normalizeToolkitId(toolkit.id));

return docsLink ? { ...toolkit, docsLink } : toolkit;
});
const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) =>
mergeToolkitCatalogFields(
toolkit,
metadataById.get(normalizeToolkitId(toolkit.id))
)
);

return [...dsToolkits, ...PARTNER_TOOLKITS];
};
16 changes: 14 additions & 2 deletions app/_lib/integration-index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
isRoutableIntegrationCategory,
normalizeCategory,
} from "./toolkit-category";
import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug";

const INTEGRATIONS_BASE = "/en/resources/integrations";
Expand All @@ -6,14 +10,17 @@ 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.
*
* Unknown categories normalize to `"others"` for a stable identity, but those
* URLs are redirect-only (`next.config.ts`) — see `resolveIndexToolkits`.
*/
export function toIntegrationLink(toolkit: {
id: string;
docsLink?: string | null;
category?: string | null;
}): string {
const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink });
const category = toolkit.category ?? "others";
const category = normalizeCategory(toolkit.category);
Comment thread
cursor[bot] marked this conversation as resolved.
return `${INTEGRATIONS_BASE}/${category}/${slug}`;
}

Expand All @@ -33,6 +40,8 @@ export type ResolvedIndexToolkit = ToolkitWithDocsLink & { hasPage: boolean };
* - de-dupes entries that resolve to the same URL (e.g. Notion/NotionToolkit),
* - flags the rest with `hasPage` so the caller can render doc-less toolkits
* as non-clickable cards instead of as broken links.
* - never marks `"others"` links clickable — that category redirects to the
* integrations index and has no `[toolkitId]` route.
*/
export function resolveIndexToolkits(
toolkits: ToolkitWithDocsLink[],
Expand All @@ -48,7 +57,10 @@ export function resolveIndexToolkits(
}

const link = toIntegrationLink(toolkit);
const hasPage = validLinks.has(link);
const category = normalizeCategory(toolkit.category);
// `/others/...` is redirect-only; never treat it as a real page.
const hasPage =
isRoutableIntegrationCategory(category) && validLinks.has(link);

// A bare duplicate of a real "-api" toolkit: drop it; the real card stays.
if (!hasPage && validLinks.has(`${link}-api`)) {
Expand Down
53 changes: 53 additions & 0 deletions app/_lib/toolkit-category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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];

/**
* Categories that have a real `app/.../integrations/<category>/[toolkitId]`
* route. `"others"` remains in `INTEGRATION_CATEGORIES` as the normalize
* fallback for stable card identity, but `next.config.ts` redirects
* `/integrations/others/*` to the index — so it is never clickable.
*/
export const ROUTABLE_INTEGRATION_CATEGORIES = INTEGRATION_CATEGORIES.filter(
(category): category is Exclude<IntegrationCategory, "others"> =>
category !== "others"
);

export function isRoutableIntegrationCategory(
category: string
): category is Exclude<IntegrationCategory, "others"> {
return (ROUTABLE_INTEGRATION_CATEGORIES as readonly string[]).includes(
category
);
}

export function normalizeCategory(
value: string | null | undefined
): IntegrationCategory {
if (!value) {
return "others";
}

return INTEGRATION_CATEGORIES.includes(value as IntegrationCategory)
? (value as IntegrationCategory)
: "others";
}
8 changes: 7 additions & 1 deletion app/_lib/toolkit-slug.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,8 +15,13 @@ export type ToolkitSlugSource = {
* docs-local entries carry them at runtime (e.g. partner toolkits that
* render a Partner badge on cards). This type makes the properties explicit
* so both server and client code can share it.
*
* `category` is widened with `IntegrationCategory` so normalized route
* categories (including `"others"`) remain assignable after
* `normalizeCategory` — DS `ToolkitCategory` does not include `"others"`.
*/
export type ToolkitWithDocsLink = Toolkit & {
export type ToolkitWithDocsLink = Omit<Toolkit, "category"> & {
category: Toolkit["category"] | IntegrationCategory;
docsLink?: string | null;
isPartner?: boolean;
};
Expand Down
58 changes: 23 additions & 35 deletions app/_lib/toolkit-static-params.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
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 {
type IntegrationCategory,
isRoutableIntegrationCategory,
normalizeCategory,
ROUTABLE_INTEGRATION_CATEGORIES,
} 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;
Expand All @@ -42,18 +33,6 @@ const DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES: ToolkitCatalogEntry[] =
const loadDesignSystemToolkits = async (): Promise<ToolkitCatalogEntry[]> =>
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>/
* <slug>`. Category comes from the toolkit's own data (its true, linked
Expand Down Expand Up @@ -220,12 +199,13 @@ const PAGE_FILE_NAMES = new Set(["page.mdx", "page.tsx"]);
* Authored static integration pages (e.g. partner pages like `search/tavily`
* and `tool-feedback`) live next to the dynamic `[toolkitId]` routes. They are
* real pages but are not part of `listToolkitRoutes`, so enumerate them from
* disk under the known integration categories.
* disk under the routable integration categories (never `"others"`, which
* redirects to the index).
*/
const listStaticIntegrationLinks = async (): Promise<string[]> => {
const links: string[] = [];

for (const category of INTEGRATION_CATEGORIES) {
for (const category of ROUTABLE_INTEGRATION_CATEGORIES) {
const categoryDir = join(INTEGRATIONS_APP_DIR, category);
try {
const slugs = await readdir(categoryDir, { withFileTypes: true });
Expand All @@ -250,18 +230,26 @@ const listStaticIntegrationLinks = async (): Promise<string[]> => {
* The full set of links the integrations index may point at and that actually
* resolve: dynamic toolkit routes plus authored static pages. Used to decide
* whether a catalog card should be clickable.
*
* `"others"` routes are excluded — `next.config.ts` redirects
* `/integrations/others/*` to the index, and there is no `[toolkitId]` page
* under that category.
*/
export async function listValidIntegrationLinks(options?: {
dataDir?: string;
toolkitsCatalog?: ToolkitCatalogEntry[];
}): Promise<Set<string>> {
const routes = await listToolkitRoutes(options);
const links = new Set<string>(
routes.map(
(route) =>
`/en/resources/integrations/${route.category}/${route.toolkitId}`
)
);
const links = new Set<string>();

for (const route of routes) {
if (!isRoutableIntegrationCategory(route.category)) {
continue;
}
links.add(
`/en/resources/integrations/${route.category}/${route.toolkitId}`
);
}

for (const staticLink of await listStaticIntegrationLinks()) {
links.add(staticLink);
Expand Down
2 changes: 1 addition & 1 deletion app/en/resources/integrations/_lib/toolkit-docs-page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
12 changes: 10 additions & 2 deletions app/en/resources/integrations/components/use-toolkit-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { useFilters } from "./use-filters";
const DEFAULT_PRIORITY = 5;
const DEBOUNCE_TIME = 300;

/**
* Catalog entries may carry route-normalized categories (including `"others"`)
* that are not in DS `ToolkitCategory`. Keep the filter hook open to that.
*/
type FilterableToolkit = Omit<Toolkit, "category"> & {
category: string;
};

const TYPE_PRIORITY: Record<ToolkitType, number> = {
arcade: 0,
arcade_starter: 1,
Expand All @@ -25,7 +33,7 @@ const TYPE_LABELS: Record<ToolkitType, string> = {
const getTypePriority = (type: string): number =>
TYPE_PRIORITY[type as ToolkitType] ?? DEFAULT_PRIORITY;

const compareToolkits = <T extends Toolkit>(a: T, b: T): number => {
const compareToolkits = <T extends FilterableToolkit>(a: T, b: T): number => {
// First prioritize available toolkits over coming soon toolkits
if (a.isComingSoon !== b.isComingSoon) {
return a.isComingSoon ? 1 : -1;
Expand All @@ -45,7 +53,7 @@ const compareToolkits = <T extends Toolkit>(a: T, b: T): number => {
return a.label.localeCompare(b.label);
};

export function useToolkitFilters<T extends Toolkit>(toolkits: T[]) {
export function useToolkitFilters<T extends FilterableToolkit>(toolkits: T[]) {
const {
selectedCategory,
selectedType,
Expand Down
Loading
Loading