diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx index c8f51ea1ce1..3b373807a1b 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx @@ -28,6 +28,22 @@ const allIntegrations = INTEGRATIONS const INTEGRATION_COUNT = allIntegrations.length const baseUrl = SITE_URL +/** + * High-connectivity integrations (e.g. Slack, used as a notification target + * across hundreds of unrelated templates via `alsoIntegrations`) can match + * many hundreds of templates, ballooning this page's HTML/RSC payload well + * past Googlebot's 2MB crawl limit. Cap to a bounded, still-generous set, + * consistent with the related-integrations section's own cap below. + * + * `getTemplatesForBlock` returns matches in registry insertion order, not + * relevance - sorted `featured` first, then owned-by-the-viewing-integration, + * so the cap can't hide a curated `featured` template (owned or reached via + * `alsoIntegrations`) behind a larger pile of non-featured owned templates, + * nor behind unrelated `alsoIntegrations` matches that happen to iterate + * earlier in the registry. + */ +const MAX_TEMPLATES_SHOWN = 12 + /** Fast O(1) lookups - avoids repeated linear scans inside render loops. */ const bySlug = new Map(allIntegrations.map((i) => [i.slug, i])) const byType = new Map(allIntegrations.map((i) => [i.type, i])) @@ -380,6 +396,12 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl relatedIntegrations.map((i) => i.name) ) const matchingTemplates = getTemplatesForBlock(integration.type) + .sort( + (a, b) => + Number(b.featured ?? false) - Number(a.featured ?? false) || + Number(b.isOwner) - Number(a.isOwner) + ) + .slice(0, MAX_TEMPLATES_SHOWN) const breadcrumbJsonLd = { '@context': 'https://schema.org', diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx index 4b272a6943a..c2a5dfd1356 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx @@ -7,6 +7,7 @@ import { INTEGRATIONS, type Integration, POPULAR_WORKFLOWS, + toIntegrationSummary, } from '@/lib/integrations' import { withFilteredNoindex } from '@/lib/landing/seo' import { JsonLd } from '@/app/(landing)/components/json-ld' @@ -17,6 +18,7 @@ import { RequestIntegrationModal } from '@/app/(landing)/integrations/components import { integrationsSearchParamsCache } from '@/app/(landing)/integrations/search-params' const allIntegrations = INTEGRATIONS +const integrationSummaries = allIntegrations.map(toIntegrationSummary) const INTEGRATION_COUNT = allIntegrations.length const OAUTH_COUNT = allIntegrations.filter((i) => i.authType === 'oauth').length const TRIGGER_INTEGRATION_COUNT = allIntegrations.filter((i) => i.triggerCount > 0).length @@ -75,9 +77,9 @@ const INTEGRATIONS_BREADCRUMB_JSON_LD = { const FEATURED_SLUGS = ['slack', 'notion', 'github', 'gmail'] as const const bySlug = new Map(allIntegrations.map((i) => [i.slug, i])) -const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter( - (i): i is Integration => i !== undefined -) +const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)) + .filter((i): i is Integration => i !== undefined) + .map(toIntegrationSummary) /** * `q`/`category` render a genuinely different server-rendered list (see @@ -215,7 +217,7 @@ export default async function IntegrationsPage({ All Integrations - + {/* FAQ */} diff --git a/apps/sim/app/(landing)/integrations/components/integration-card.tsx b/apps/sim/app/(landing)/integrations/components/integration-card.tsx index 3749dd00a94..e7b2197cc41 100644 --- a/apps/sim/app/(landing)/integrations/components/integration-card.tsx +++ b/apps/sim/app/(landing)/integrations/components/integration-card.tsx @@ -1,14 +1,14 @@ import type { ComponentType, SVGProps } from 'react' import { memo } from 'react' import Link from 'next/link' -import type { Integration } from '@/lib/integrations' +import type { IntegrationSummary } from '@/lib/integrations' import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow' import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon' const HOVER_BG = 'transition-colors hover:bg-[var(--surface-hover)]' as const interface IntegrationItemProps { - integration: Integration + integration: IntegrationSummary IconComponent?: ComponentType> } diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx index a226fec75cc..5519b97313b 100644 --- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx +++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx @@ -3,7 +3,11 @@ import { useMemo } from 'react' import { ChipInput, Search } from '@sim/emcn' import { debounce, useQueryStates } from 'nuqs' -import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations' +import { + blockTypeToIconMap, + formatIntegrationType, + type IntegrationSummary, +} from '@/lib/integrations' import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card' import { integrationsParsers, @@ -19,7 +23,7 @@ const PILL_ACTIVE = 'bg-[var(--surface-active)]' as const const PILL_INACTIVE = 'hover:bg-[var(--surface-hover)]' as const interface IntegrationGridProps { - integrations: readonly Integration[] + integrations: readonly IntegrationSummary[] } export function IntegrationGrid({ integrations }: IntegrationGridProps) { @@ -29,30 +33,17 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) { ) const activeCategory = category || null - // Category facets and a per-integration lowercased search index, derived once - // from the (stable) integration list instead of rebuilt on every keystroke. - // The index keeps each searchable field as its own entry so matching stays - // identical to a per-field `includes` (no cross-field boundary matches). - const { availableCategories, searchIndex } = useMemo(() => { + /** Category facets, derived once from the (stable) integration list. */ + const availableCategories = useMemo(() => { const counts = new Map() - const searchIndex = new Map() for (const i of integrations) { if (i.integrationType) { counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1) } - searchIndex.set(i.type, [ - i.name.toLowerCase(), - i.description.toLowerCase(), - ...i.operations.flatMap((op) => [op.name.toLowerCase(), op.description.toLowerCase()]), - ...i.triggers.map((t) => t.name.toLowerCase()), - ]) - } - return { - availableCategories: Array.from(counts.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([key]) => key), - searchIndex, } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([key]) => key) }, [integrations]) const q = query.trim().toLowerCase() @@ -61,9 +52,9 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) { integrations.filter((i) => { if (activeCategory && i.integrationType !== activeCategory) return false if (!q) return true - return searchIndex.get(i.type)?.some((field) => field.includes(q)) ?? false + return i.searchFields.some((field) => field.includes(q)) }), - [integrations, searchIndex, q, activeCategory] + [integrations, q, activeCategory] ) return ( diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index d0019e28aaf..d87b685347a 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -183,6 +183,8 @@ export function getAllBlockMeta(): Record { export interface ScopedBlockTemplate extends BlockTemplate { /** Block types (base form) to render alongside the viewing block in the icon cluster. */ otherBlockTypes: readonly string[] + /** Whether the viewing block owns this template, vs matching only via `alsoIntegrations`. */ + isOwner: boolean } /** @@ -208,7 +210,7 @@ export function getTemplatesForBlock(type: string): ScopedBlockTemplate[] { const alsoBase = stripVersionSuffix(also) if (alsoBase !== base && !others.includes(alsoBase)) others.push(alsoBase) } - collected.push({ ...template, otherBlockTypes: others }) + collected.push({ ...template, otherBlockTypes: others, isOwner: isOwnerMatch }) } } return collected diff --git a/apps/sim/lib/integrations/index.ts b/apps/sim/lib/integrations/index.ts index 926f466ff78..023135c6286 100644 --- a/apps/sim/lib/integrations/index.ts +++ b/apps/sim/lib/integrations/index.ts @@ -14,7 +14,7 @@ import { stripVersionSuffix } from '@sim/utils/string' import integrationsJson from '@/lib/integrations/integrations.json' -import type { Integration } from '@/lib/integrations/types' +import type { Integration, IntegrationSummary } from '@/lib/integrations/types' import { getAllBlockMeta } from '@/blocks/registry' /** All integrations surfaced in the catalog, ordered by `scripts/generate-docs.ts`. */ @@ -67,13 +67,44 @@ export const POPULAR_WORKFLOWS: readonly PopularWorkflow[] = (() => { return pairs })() +/** + * Projects a full `Integration` down to the fields the `/integrations` + * catalog grid renders and searches by, replacing the full `operations`/ + * `triggers` arrays with a precomputed, lowercased `searchFields` index + * (name, description, every operation's name and description, every + * trigger's name) - the exact same fields the original per-field search + * over `Integration[]` matched against. See {@link IntegrationSummary} for + * why this exists. + */ +export function toIntegrationSummary(integration: Integration): IntegrationSummary { + const searchFields = [ + integration.name.toLowerCase(), + integration.description.toLowerCase(), + ...integration.operations.flatMap((op) => [ + op.name.toLowerCase(), + op.description.toLowerCase(), + ]), + ...integration.triggers.map((t) => t.name.toLowerCase()), + ] + + return { + type: integration.type, + slug: integration.slug, + name: integration.name, + description: integration.description, + bgColor: integration.bgColor, + integrationType: integration.integrationType, + searchFields, + } +} + export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' export { type OAuthServiceMatch, resolveOAuthServiceForIntegration, resolveOAuthServiceForSlug, } from '@/lib/integrations/oauth-service' -export type { AuthType, FAQItem, Integration } from '@/lib/integrations/types' +export type { AuthType, FAQItem, Integration, IntegrationSummary } from '@/lib/integrations/types' export { getAllBlockMeta, getBlockMeta, getTemplatesForBlock } from '@/blocks/registry' export type { BlockMeta, BlockTemplate } from '@/blocks/types' export { formatIntegrationType } from '@/blocks/types' diff --git a/apps/sim/lib/integrations/types.ts b/apps/sim/lib/integrations/types.ts index f3da5aa719a..b1df1a9d59b 100644 --- a/apps/sim/lib/integrations/types.ts +++ b/apps/sim/lib/integrations/types.ts @@ -66,3 +66,28 @@ export interface Integration { /** Hand-authored landing content baked in at generation time (see `landing-content.ts`). */ landingContent?: IntegrationLandingContent } + +/** + * The fields the `/integrations` catalog grid actually renders and searches + * by, plus a precomputed, lowercased `searchFields` index (name, description, + * every operation's name and description, every trigger's name) in place of + * the full `operations`/`triggers` arrays. Shipping the full `Integration[]` + * to that page's client component embeds every integration's complete field + * set - including data the grid never renders (`tags`, `docsUrl`, + * `landingContent`, ...) - in the initial HTML/RSC payload. + * + * `searchFields` stays an array, one entry per source field, rather than a + * single joined string: matching must still require the query to fall + * entirely within one field (as the original per-field `Integration[]` + * search did), not span a field boundary a single concatenated string would + * silently allow. + */ +export interface IntegrationSummary { + type: Integration['type'] + slug: string + name: Integration['name'] + description: Integration['description'] + bgColor: Integration['bgColor'] + integrationType: Integration['integrationType'] + searchFields: readonly string[] +}