diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9295e1d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + checks: + name: Lint & typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + # Keep in step with the version developers run locally so + # --frozen-lockfile behaves identically in CI. + bun-version: 1.3.1 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Biome + run: bunx biome ci . + + - name: Typecheck + working-directory: apps/cursor + run: bun run typecheck diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example index f895fdf6..7f75066d 100644 --- a/apps/cursor/.env.example +++ b/apps/cursor/.env.example @@ -34,14 +34,5 @@ GITHUB_TOKEN= # Vercel Web Analytics is enabled in the Vercel dashboard (Project → Analytics) # and requires no env vars; the @vercel/analytics client is a no-op in dev. -# Airtable — source for the ambassadors cron sync -# (src/app/api/cron/sync-ambassadors/route.ts). Only required if you run that -# cron locally. -AIRTABLE_API_KEY= -AIRTABLE_BASE_ID= -AIRTABLE_AMBASSADORS_TABLE=Directory -# Comma-separated list of field names to read emails from (case-sensitive). -AIRTABLE_AMBASSADORS_EMAIL_FIELD=Email,Cursor email - # Shared secret guarding /api/cron/* routes against unauthenticated callers. CRON_SECRET= diff --git a/apps/cursor/next.config.mjs b/apps/cursor/next.config.mjs index 931c48fe..94234941 100644 --- a/apps/cursor/next.config.mjs +++ b/apps/cursor/next.config.mjs @@ -5,6 +5,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** @type {import('next').NextConfig} */ const nextConfig = { + cacheComponents: true, turbopack: { root: resolve(__dirname, "../.."), }, @@ -36,6 +37,14 @@ const nextConfig = { destination: "/", permanent: true, }, + { + // Legacy MCP detail URLs map to their plugin page. Excludes `new`, + // which is the MCP submission form route. Edit pages (`/mcp/x/edit`) + // are two segments deep and never match this single-segment source. + source: "/mcp/:slug((?!new$)[^/]+)", + destination: "/plugins/mcp-:slug", + permanent: true, + }, { source: "/official/:path*", destination: "/", diff --git a/apps/cursor/src/actions/create-plugin.ts b/apps/cursor/src/actions/create-plugin.ts index 347fb27e..697ce926 100644 --- a/apps/cursor/src/actions/create-plugin.ts +++ b/apps/cursor/src/actions/create-plugin.ts @@ -1,29 +1,13 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { updateTag } from "next/cache"; import { z } from "zod"; import { resolveGithubRepoIdFromRepository } from "@/lib/github-plugin/parse"; import { InsertPluginError, insertPlugin } from "@/lib/plugins/insert"; +import { componentInputSchema } from "@/lib/plugins/types"; import { pluginScanLimit } from "@/lib/rate-limit"; import { ActionError, authActionClient } from "./safe-action"; -const componentSchema = z.object({ - type: z.enum([ - "rule", - "mcp_server", - "skill", - "agent", - "hook", - "lsp_server", - "command", - ]), - name: z.string().min(1), - slug: z.string().optional(), - description: z.string().optional(), - content: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), -}); - export const createPluginAction = authActionClient .metadata({ actionName: "create-plugin", @@ -39,7 +23,7 @@ export const createPluginAction = authActionClient homepage: z.string().url().nullable().optional(), keywords: z.array(z.string()).optional(), components: z - .array(componentSchema) + .array(componentInputSchema) .min(1, "At least one component is required"), }), ) @@ -100,7 +84,7 @@ export const createPluginAction = authActionClient throw err; } - revalidatePath("/"); + updateTag("plugins"); return { slug: result.slug }; }, diff --git a/apps/cursor/src/actions/delete-plugin.ts b/apps/cursor/src/actions/delete-plugin.ts index 59c94ddb..1b74e971 100644 --- a/apps/cursor/src/actions/delete-plugin.ts +++ b/apps/cursor/src/actions/delete-plugin.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { ActionError, authActionClient } from "./safe-action"; @@ -28,7 +28,9 @@ export const deletePluginAction = authActionClient } if (existing.owner_id !== userId) { - throw new ActionError("You do not have permission to delete this plugin."); + throw new ActionError( + "You do not have permission to delete this plugin.", + ); } const { error } = await supabase @@ -41,7 +43,9 @@ export const deletePluginAction = authActionClient throw new ActionError(`Failed to delete plugin: ${error.message}`); } - revalidatePath("/"); + // Deletions must disappear from cached lists immediately for the owner. + updateTag("plugins"); + updateTag(`plugin-${existing.slug}`); revalidatePath("/admin/plugins"); return { slug: existing.slug }; diff --git a/apps/cursor/src/actions/request-plugin-verification.ts b/apps/cursor/src/actions/request-plugin-verification.ts index b533b387..32371fa5 100644 --- a/apps/cursor/src/actions/request-plugin-verification.ts +++ b/apps/cursor/src/actions/request-plugin-verification.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/admin-client"; import { ActionError, authActionClient } from "./safe-action"; @@ -40,6 +40,6 @@ export const requestPluginVerificationAction = authActionClient } revalidatePath("/admin/plugins"); - revalidatePath(`/plugins/${plugin.slug}`); + updateTag(`plugin-${plugin.slug}`); return { success: true }; }); diff --git a/apps/cursor/src/actions/review-flagged-plugin.ts b/apps/cursor/src/actions/review-flagged-plugin.ts index dfc63a3c..6731dcaf 100644 --- a/apps/cursor/src/actions/review-flagged-plugin.ts +++ b/apps/cursor/src/actions/review-flagged-plugin.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { enqueuePluginScan, kickDrainAfterResponse } from "@/lib/plugins/queue"; import { createClient } from "@/utils/supabase/admin-client"; @@ -36,8 +36,8 @@ export const approveFlaggedPluginAction = adminActionClient } revalidatePath("/admin/plugins"); - revalidatePath("/"); - revalidatePath(`/plugins/${plugin.slug}`); + updateTag("plugins"); + updateTag(`plugin-${plugin.slug}`); return { success: true }; }); @@ -65,8 +65,8 @@ export const confirmFlagAction = adminActionClient } revalidatePath("/admin/plugins"); - revalidatePath("/"); - revalidatePath(`/plugins/${plugin.slug}`); + updateTag("plugins"); + updateTag(`plugin-${plugin.slug}`); return { success: true }; }); @@ -76,17 +76,27 @@ export const rescanPluginAction = adminActionClient .action(async ({ parsedInput: { pluginId } }) => { const supabase = await createClient(); - const { error: resetError } = await supabase + const { data: plugin, error: resetError } = await supabase .from("plugins") .update({ scan_status: "pending" }) - .eq("id", pluginId); + .eq("id", pluginId) + .select("slug") + .single(); - if (resetError) { + if (resetError || !plugin) { throw new ActionError( - `Failed to reset scan state: ${resetError.message}`, + `Failed to reset scan state: ${resetError?.message ?? "not found"}`, ); } + // The status reset must reach cached readers (detail banner, leaderboard) + // right away, so invalidate before enqueueing — a queue failure must not + // leave cached views showing the stale status when the row is already + // pending. The drain route only invalidates again once the scan ends. + revalidatePath("/admin/plugins"); + updateTag("plugins"); + updateTag(`plugin-${plugin.slug}`); + try { await enqueuePluginScan(pluginId); kickDrainAfterResponse(); @@ -96,6 +106,5 @@ export const rescanPluginAction = adminActionClient ); } - revalidatePath("/admin/plugins"); return { success: true }; }); diff --git a/apps/cursor/src/actions/review-plugin.ts b/apps/cursor/src/actions/review-plugin.ts index 9edb23b8..28b3e790 100644 --- a/apps/cursor/src/actions/review-plugin.ts +++ b/apps/cursor/src/actions/review-plugin.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/admin-client"; import { ActionError, adminActionClient } from "./safe-action"; @@ -27,10 +27,10 @@ export const approvePluginAction = adminActionClient .single(); revalidatePath("/admin/plugins"); - revalidatePath("/"); + updateTag("plugins"); if (plugin?.slug) { - revalidatePath(`/plugins/${plugin.slug}`); + updateTag(`plugin-${plugin.slug}`); } return { success: true }; @@ -52,7 +52,7 @@ export const declinePluginAction = adminActionClient } revalidatePath("/admin/plugins"); - revalidatePath("/"); + updateTag("plugins"); return { success: true }; }); diff --git a/apps/cursor/src/actions/safe-action.ts b/apps/cursor/src/actions/safe-action.ts index 346260d0..a2cbd5ff 100644 --- a/apps/cursor/src/actions/safe-action.ts +++ b/apps/cursor/src/actions/safe-action.ts @@ -24,13 +24,18 @@ export const actionClient = createSafeActionClient({ actionName: z.string(), }); }, - // Define logging middleware. -}).use(async ({ next, clientInput, metadata }) => { +}).use(async ({ next, metadata }) => { const result = await next(); - console.log("Result ->", result); - console.log("Client input ->", clientInput); - console.log("Metadata ->", metadata); + // Log failures only — never inputs or results, which can contain user PII + // (emails, profile fields). Server errors are already logged with their + // message in handleServerError; this adds which action failed. + if (result.serverError || result.validationErrors) { + console.error(`[action:${metadata?.actionName}] failed`, { + serverError: result.serverError, + validationErrors: result.validationErrors, + }); + } return result; }); diff --git a/apps/cursor/src/actions/star-plugin.ts b/apps/cursor/src/actions/star-plugin.ts index 8e8628cf..d01602f4 100644 --- a/apps/cursor/src/actions/star-plugin.ts +++ b/apps/cursor/src/actions/star-plugin.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidateTag, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { ActionError, authActionClient } from "./safe-action"; @@ -15,7 +15,7 @@ export const starPluginAction = authActionClient slug: z.string(), }), ) - .action(async ({ parsedInput: { pluginId, slug } }) => { + .action(async ({ parsedInput: { pluginId, slug }, ctx: { userId } }) => { // User-scoped client so `auth.uid()` inside the SECURITY DEFINER RPC // authorizes against the caller, not the service role. const supabase = await createClient(); @@ -28,6 +28,9 @@ export const starPluginAction = authActionClient throw new ActionError(`Failed to update star: ${error.message}`); } - revalidatePath("/"); - revalidatePath(`/plugins/${slug}`); + // Star counts can refresh in the background, but the user's own starred + // list must reflect the change immediately. + revalidateTag("plugins", "max"); + revalidateTag(`plugin-${slug}`, "max"); + updateTag(`stars-${userId}`); }); diff --git a/apps/cursor/src/actions/subscribe-action.ts b/apps/cursor/src/actions/subscribe-action.ts deleted file mode 100644 index f41807c7..00000000 --- a/apps/cursor/src/actions/subscribe-action.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use server"; - -export async function subscribeAction(formData: FormData, userGroup: string) { - const email = formData.get("email") as string; - - const res = await fetch( - "https://app.loops.so/api/newsletter-form/cm0bd20vj03imyjzv74y1crnb", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - userGroup, - }), - }, - ); - - const json = await res.json(); - - return json; -} diff --git a/apps/cursor/src/actions/toggle-follow-action.ts b/apps/cursor/src/actions/toggle-follow-action.ts index 8e0153fe..8c338708 100644 --- a/apps/cursor/src/actions/toggle-follow-action.ts +++ b/apps/cursor/src/actions/toggle-follow-action.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidateTag, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { authActionClient } from "./safe-action"; @@ -23,6 +23,19 @@ export const toggleFollowAction = authActionClient }) => { const supabase = await createClient(); + const invalidate = () => { + // Profile follower counts, the followed user's followers list, and + // the current user's following list must all reflect the change. + updateTag(`user-${slug}`); + updateTag(`followers-${userId}`); + updateTag(`following-${currentUserId}`); + // The members directory caches rows (incl. follower_count) under + // `users`. Background-refresh it like other ambient counters + // (see star-plugin) instead of synchronously flushing every + // users-tagged entry on each follow click. + revalidateTag("users", "max"); + }; + if (action === "follow") { const { error } = await supabase .from("followers") @@ -32,7 +45,7 @@ export const toggleFollowAction = authActionClient throw new Error(error.message); } - revalidatePath(`/u/${slug}`); + invalidate(); return; } @@ -44,6 +57,6 @@ export const toggleFollowAction = authActionClient .eq("following_id", userId); } - revalidatePath(`/u/${slug}`); + invalidate(); }, ); diff --git a/apps/cursor/src/actions/toggle-mcp-listing.ts b/apps/cursor/src/actions/toggle-mcp-listing.ts index 20eeb92b..fc38815b 100644 --- a/apps/cursor/src/actions/toggle-mcp-listing.ts +++ b/apps/cursor/src/actions/toggle-mcp-listing.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { authActionClient } from "./safe-action"; @@ -32,9 +32,8 @@ export const toggleMCPListingAction = authActionClient throw new Error(error.message); } - revalidatePath(`/mcp/${data.slug}`); - revalidatePath("/mcp"); - revalidatePath("/"); + updateTag("mcps"); + updateTag(`mcp-${data.slug}`); return data; }); diff --git a/apps/cursor/src/actions/toggle-plugin-listing.ts b/apps/cursor/src/actions/toggle-plugin-listing.ts index 77e6ac12..624c28cc 100644 --- a/apps/cursor/src/actions/toggle-plugin-listing.ts +++ b/apps/cursor/src/actions/toggle-plugin-listing.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { ActionError, authActionClient } from "./safe-action"; @@ -29,7 +29,9 @@ export const togglePluginListingAction = authActionClient } if (existing.owner_id !== userId) { - throw new ActionError("You do not have permission to update this plugin."); + throw new ActionError( + "You do not have permission to update this plugin.", + ); } if (active && existing.permanently_blocked) { @@ -50,8 +52,9 @@ export const togglePluginListingAction = authActionClient throw new ActionError(error.message); } - revalidatePath("/"); - revalidatePath(`/plugins/${data.slug}`); + // Publish/unpublish must be visible to the owner on the next render. + updateTag("plugins"); + updateTag(`plugin-${data.slug}`); return data; }); diff --git a/apps/cursor/src/actions/track-install.ts b/apps/cursor/src/actions/track-install.ts index 1197578b..4bd5aa86 100644 --- a/apps/cursor/src/actions/track-install.ts +++ b/apps/cursor/src/actions/track-install.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidateTag } from "next/cache"; import { z } from "zod"; import { installGlobalLimit, installPerPluginLimit } from "@/lib/rate-limit"; import { createClient as createAdminClient } from "@/utils/supabase/admin-client"; @@ -36,8 +36,11 @@ export const trackInstallAction = actionClient plugin_id_input: pluginId, }); - revalidatePath("/"); - revalidatePath(`/plugins/${slug}`); + // Counters tolerate stale-while-revalidate: serve the cached leaderboard + // and plugin page instantly while fresh counts regenerate in the + // background (avoids a blocking re-render per install). + revalidateTag("plugins", "max"); + revalidateTag(`plugin-${slug}`, "max"); return { tracked: true } satisfies TrackInstallResult; }); diff --git a/apps/cursor/src/actions/update-mcp-listing.tsx b/apps/cursor/src/actions/update-mcp-listing.ts similarity index 89% rename from apps/cursor/src/actions/update-mcp-listing.tsx rename to apps/cursor/src/actions/update-mcp-listing.ts index a4a568db..0965df7a 100644 --- a/apps/cursor/src/actions/update-mcp-listing.tsx +++ b/apps/cursor/src/actions/update-mcp-listing.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { authActionClient } from "./safe-action"; @@ -39,15 +39,15 @@ export const updateMCPListingAction = authActionClient }) .eq("id", id) .eq("owner_id", userId) - .select("id") + .select("id, slug") .single(); if (error) { throw new Error(error.message); } - revalidatePath("/mcps"); - revalidatePath("/"); + updateTag("mcps"); + updateTag(`mcp-${data.slug}`); return data; }, diff --git a/apps/cursor/src/actions/update-plugin.ts b/apps/cursor/src/actions/update-plugin.ts index 3c281cf2..19c164a4 100644 --- a/apps/cursor/src/actions/update-plugin.ts +++ b/apps/cursor/src/actions/update-plugin.ts @@ -1,32 +1,14 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { updateTag } from "next/cache"; import { z } from "zod"; import { enqueuePluginScan, kickDrainAfterResponse } from "@/lib/plugins/queue"; +import { type ComponentInput, componentInputSchema } from "@/lib/plugins/types"; import { pluginScanLimit } from "@/lib/rate-limit"; import { resolveComponentSlug } from "@/lib/slug"; import { createClient } from "@/utils/supabase/admin-client"; import { ActionError, authActionClient } from "./safe-action"; -const componentSchema = z.object({ - type: z.enum([ - "rule", - "mcp_server", - "skill", - "agent", - "hook", - "lsp_server", - "command", - ]), - name: z.string().min(1), - slug: z.string().optional(), - description: z.string().optional(), - content: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), -}); - -type ComponentInput = z.infer; - type ExistingComponent = { type: string; name: string; @@ -128,7 +110,7 @@ export const updatePluginAction = authActionClient homepage: z.string().url().nullable().optional(), keywords: z.array(z.string()).optional(), components: z - .array(componentSchema) + .array(componentInputSchema) .min(1, "At least one component is required"), }), ) @@ -246,8 +228,8 @@ export const updatePluginAction = authActionClient } } - revalidatePath("/"); - revalidatePath(`/plugins/${existing.slug}`); + updateTag("plugins"); + updateTag(`plugin-${existing.slug}`); return { slug: existing.slug, diff --git a/apps/cursor/src/actions/verify-plugin.ts b/apps/cursor/src/actions/verify-plugin.ts index 98a5a781..93f0c0e2 100644 --- a/apps/cursor/src/actions/verify-plugin.ts +++ b/apps/cursor/src/actions/verify-plugin.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, updateTag } from "next/cache"; import { z } from "zod"; import { createClient } from "@/utils/supabase/admin-client"; import { ActionError, adminActionClient } from "./safe-action"; @@ -33,8 +33,8 @@ export const setPluginVerifiedAction = adminActionClient } revalidatePath("/admin/plugins"); - revalidatePath("/"); - revalidatePath(`/plugins/${data.slug}`); + updateTag("plugins"); + updateTag(`plugin-${data.slug}`); return { success: true }; }); @@ -58,6 +58,6 @@ export const dismissVerificationRequestAction = adminActionClient } revalidatePath("/admin/plugins"); - revalidatePath(`/plugins/${data.slug}`); + updateTag(`plugin-${data.slug}`); return { success: true }; }); diff --git a/apps/cursor/src/app/[slug]/page.tsx b/apps/cursor/src/app/[slug]/page.tsx index b51bd27b..c768147a 100644 --- a/apps/cursor/src/app/[slug]/page.tsx +++ b/apps/cursor/src/app/[slug]/page.tsx @@ -3,32 +3,43 @@ import { getPlugins } from "@/data/queries"; type Params = Promise<{ slug: string }>; -export async function generateStaticParams() { +/** + * Rules predate plugins and now live as `rule`-type plugin components. + * Maps every rule component slug to its parent plugin's slug (first plugin + * wins, matching the newest-first order plugins are fetched in). + */ +async function getRuleRedirects(): Promise> { const { data: plugins } = await getPlugins({ fetchAll: true }); - if (!plugins) return []; - return plugins.flatMap((plugin) => - (plugin.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((rule) => ({ slug: rule.slug })), - ); + const redirects = new Map(); + for (const plugin of plugins ?? []) { + for (const component of plugin.plugin_components ?? []) { + if (component.type === "rule" && !redirects.has(component.slug)) { + redirects.set(component.slug, plugin.slug); + } + } + } + return redirects; +} + +export async function generateStaticParams() { + const redirects = await getRuleRedirects(); + return [...redirects.keys()].map((slug) => ({ slug })); } +/** + * Legacy rule URLs (`/{rule-slug}`) redirect to the plugin that now contains + * the rule component. + */ export default async function Page({ params }: { params: Params }) { const { slug } = await params; - const { data: plugins } = await getPlugins({ fetchAll: true }); - const parentPlugin = (plugins ?? []).find((p) => - (p.plugin_components ?? []).some( - (c) => c.type === "rule" && c.slug === slug, - ), - ); - - if (parentPlugin) { - redirect(`/plugins/${parentPlugin.slug}`); + const redirects = await getRuleRedirects(); + const pluginSlug = redirects.get(slug); + + if (pluginSlug) { + redirect(`/plugins/${pluginSlug}`); } notFound(); } - -export const revalidate = 3600; diff --git a/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx b/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx index b8674d88..fa3eda4b 100644 --- a/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx +++ b/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryState } from "nuqs"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; import { cn } from "@/lib/utils"; import { FlaggedReviewList } from "./flagged-review-list"; import { PluginReviewList } from "./plugin-review-list"; diff --git a/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx b/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx index b66eccd2..93ed9034 100644 --- a/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx +++ b/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx @@ -19,7 +19,7 @@ import { } from "@/actions/review-flagged-plugin"; import { declinePluginAction } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { FlagSeverity, PluginRow } from "@/data/queries"; +import type { FlagSeverity, PluginRow } from "@/lib/plugins/types"; import { cn } from "@/lib/utils"; const severityClass: Record = { diff --git a/apps/cursor/src/app/admin/plugins/page.tsx b/apps/cursor/src/app/admin/plugins/page.tsx index c5edf0e9..93863159 100644 --- a/apps/cursor/src/app/admin/plugins/page.tsx +++ b/apps/cursor/src/app/admin/plugins/page.tsx @@ -15,7 +15,11 @@ export const metadata: Metadata = { title: "Review Plugins | Admin", }; -export default async function AdminPluginsPage() { +/** + * Admin-gated, intentionally uncached: the session read and moderation-queue + * queries run per request and stream inside the page's Suspense boundary. + */ +async function AdminPluginsContent() { const session = await getSession(); if (!session || !isAdmin(session.user.id)) { @@ -34,6 +38,17 @@ export default async function AdminPluginsPage() { getPendingVerificationRequests(), ]); + return ( + + ); +} + +export default function AdminPluginsPage() { return (
@@ -46,13 +61,8 @@ export default async function AdminPluginsPage() {

- - + +
diff --git a/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx b/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx index bc2dd2ef..39227962 100644 --- a/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx +++ b/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx @@ -10,7 +10,7 @@ import { declinePluginAction, } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function PluginReviewCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx b/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx index 689617b7..f2f01d49 100644 --- a/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx +++ b/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { rescanPluginAction } from "@/actions/review-flagged-plugin"; import { declinePluginAction } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function StuckCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/app/admin/plugins/verification-request-list.tsx b/apps/cursor/src/app/admin/plugins/verification-request-list.tsx index 4f624915..c14b4e35 100644 --- a/apps/cursor/src/app/admin/plugins/verification-request-list.tsx +++ b/apps/cursor/src/app/admin/plugins/verification-request-list.tsx @@ -10,7 +10,7 @@ import { setPluginVerifiedAction, } from "@/actions/verify-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function VerificationRequestCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/app/api/[slug]/route.ts b/apps/cursor/src/app/api/[slug]/route.ts deleted file mode 100644 index ca8aa807..00000000 --- a/apps/cursor/src/app/api/[slug]/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const dynamic = "force-static"; -export const revalidate = 86400; - -export async function generateStaticParams() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - return (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => ({ slug: c.slug })), - ); -} - -type Params = Promise<{ slug: string }>; - -export async function GET(_: Request, segmentData: { params: Params }) { - const { slug } = await segmentData.params; - - if (!slug) { - return NextResponse.json({ error: "No slug provided" }, { status: 400 }); - } - - const { data: plugins } = await getPlugins({ fetchAll: true }); - const allRules = (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ); - - const rule = allRules.find((r) => r.slug === slug); - - if (!rule) { - return NextResponse.json({ error: "Rule not found" }, { status: 404 }); - } - - return new Response(JSON.stringify({ data: rule }), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, s-maxage=86400", - "CDN-Cache-Control": "public, s-maxage=86400", - "Vercel-CDN-Cache-Control": "public, s-maxage=86400", - }, - }); -} diff --git a/apps/cursor/src/app/api/cron/recover-stuck-scans/route.ts b/apps/cursor/src/app/api/cron/recover-stuck-scans/route.ts index 57811530..a3caa3f8 100644 --- a/apps/cursor/src/app/api/cron/recover-stuck-scans/route.ts +++ b/apps/cursor/src/app/api/cron/recover-stuck-scans/route.ts @@ -3,7 +3,6 @@ import { requireCronAuth } from "@/lib/cron-auth"; import { enqueuePluginScan } from "@/lib/plugins/queue"; import { createClient } from "@/utils/supabase/admin-client"; -export const dynamic = "force-dynamic"; export const maxDuration = 60; // Retry plugins that have been sitting in pending/scanning longer than this. diff --git a/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts b/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts deleted file mode 100644 index d4a37c36..00000000 --- a/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { revalidatePath } from "next/cache"; -import { type NextRequest, NextResponse } from "next/server"; -import { requireCronAuth } from "@/lib/cron-auth"; -import { createClient } from "@/utils/supabase/admin-client"; - -export const dynamic = "force-dynamic"; -export const maxDuration = 60; - -const AIRTABLE_BASE_URL = "https://api.airtable.com/v0"; -const PAGE_SIZE = 100; - -async function fetchAmbassadorEmails(): Promise { - const { - AIRTABLE_API_KEY, - AIRTABLE_BASE_ID, - AIRTABLE_AMBASSADORS_TABLE = "Directory", - AIRTABLE_AMBASSADORS_EMAIL_FIELD = "Email,Cursor email", - } = process.env; - - if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_ID) { - throw new Error( - "Missing AIRTABLE_API_KEY or AIRTABLE_BASE_ID environment variables", - ); - } - - const emailFields = AIRTABLE_AMBASSADORS_EMAIL_FIELD.split(",") - .map((f) => f.trim()) - .filter(Boolean); - - if (emailFields.length === 0) { - throw new Error("AIRTABLE_AMBASSADORS_EMAIL_FIELD is empty"); - } - - const emails = new Set(); - let offset: string | undefined; - - do { - const url = new URL( - `${AIRTABLE_BASE_URL}/${AIRTABLE_BASE_ID}/${encodeURIComponent( - AIRTABLE_AMBASSADORS_TABLE, - )}`, - ); - url.searchParams.set("pageSize", String(PAGE_SIZE)); - for (const field of emailFields) { - url.searchParams.append("fields[]", field); - } - if (offset) url.searchParams.set("offset", offset); - - const res = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${AIRTABLE_API_KEY}`, - }, - cache: "no-store", - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error( - `Airtable request failed: ${res.status} ${res.statusText} - ${body}`, - ); - } - - const json = (await res.json()) as { - records?: Array<{ fields?: Record }>; - offset?: string; - }; - - for (const record of json.records ?? []) { - for (const field of emailFields) { - const value = record.fields?.[field]; - if (typeof value === "string") { - const trimmed = value.trim().toLowerCase(); - if (trimmed) emails.add(trimmed); - } - } - } - - offset = json.offset; - } while (offset); - - return [...emails]; -} - -export async function GET(request: NextRequest) { - const unauthorized = requireCronAuth(request); - if (unauthorized) return unauthorized; - - try { - const emails = await fetchAmbassadorEmails(); - - const supabase = await createClient(); - - const { data, error } = await supabase.rpc("set_ambassadors_by_emails", { - target_emails: emails, - }); - - if (error) { - throw new Error(`Supabase RPC failed: ${error.message}`); - } - - const row = Array.isArray(data) ? data[0] : data; - const granted = Number(row?.granted ?? 0); - const revoked = Number(row?.revoked ?? 0); - - if (granted > 0 || revoked > 0) { - revalidatePath("/members"); - } - - return NextResponse.json({ - ok: true, - total_airtable: emails.length, - granted, - revoked, - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - console.error("sync-ambassadors failed:", message); - return NextResponse.json({ ok: false, error: message }, { status: 500 }); - } -} diff --git a/apps/cursor/src/app/api/plugins/[slug]/route.ts b/apps/cursor/src/app/api/plugins/[slug]/route.ts deleted file mode 100644 index 659828ff..00000000 --- a/apps/cursor/src/app/api/plugins/[slug]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPluginBySlug } from "@/data/queries"; - -export const dynamic = "force-dynamic"; - -type Params = Promise<{ slug: string }>; - -export async function GET(_: Request, segmentData: { params: Params }) { - const { slug } = await segmentData.params; - - if (!slug) { - return NextResponse.json({ error: "No slug provided" }, { status: 400 }); - } - - const { data: plugin, error } = await getPluginBySlug(slug); - - if (error || !plugin) { - return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); - } - - if (!plugin.active) { - return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); - } - - const components = (plugin.plugin_components ?? []).map((c) => ({ - type: c.type, - name: c.name, - slug: c.slug, - description: c.description, - content: c.content, - metadata: c.metadata, - })); - - return NextResponse.json({ - data: { - name: plugin.name, - slug: plugin.slug, - description: plugin.description, - version: plugin.version, - repository: plugin.repository, - components, - }, - }); -} diff --git a/apps/cursor/src/app/api/popular/route.ts b/apps/cursor/src/app/api/popular/route.ts deleted file mode 100644 index 1ad259a1..00000000 --- a/apps/cursor/src/app/api/popular/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const revalidate = 86400; -export const dynamic = "force-static"; - -export async function GET() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - - const allRules = (plugins ?? []) - .flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - count: p.install_count, - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ) - .sort((a, b) => b.count - a.count); - - const uniqueSlugs = new Set(); - const uniqueRules = allRules.filter((r) => { - if (uniqueSlugs.has(r.slug)) return false; - uniqueSlugs.add(r.slug); - return true; - }); - - return new NextResponse(JSON.stringify({ data: uniqueRules }), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, s-maxage=86400", - "CDN-Cache-Control": "public, s-maxage=86400", - "Vercel-CDN-Cache-Control": "public, s-maxage=86400", - }, - }); -} diff --git a/apps/cursor/src/app/api/queue/plugin-scans/drain/route.ts b/apps/cursor/src/app/api/queue/plugin-scans/drain/route.ts index d090e365..79cd02f5 100644 --- a/apps/cursor/src/app/api/queue/plugin-scans/drain/route.ts +++ b/apps/cursor/src/app/api/queue/plugin-scans/drain/route.ts @@ -1,3 +1,4 @@ +import { revalidateTag } from "next/cache"; import { type NextRequest, NextResponse } from "next/server"; import { requireCronAuth } from "@/lib/cron-auth"; import { @@ -18,9 +19,16 @@ import { // archive download is bounded by REPO_ARCHIVE_MAX_BYTES and a rate-limit // retry budget inside scan.ts. 800s gives us generous headroom for the // worst-case agent run. -export const dynamic = "force-dynamic"; export const maxDuration = 800; +/** + * Scan outcomes mutate plugin rows (scan_status, flags), so cached plugin + * reads must be refreshed. All plugin cache entries carry the `plugins` tag. + */ +function invalidatePluginCaches() { + revalidateTag("plugins", "max"); +} + // Visibility timeout: how long the message is invisible to other consumers // after a successful `read`. Set comfortably longer than `maxDuration` so we // can never hand the same message to a second drain invocation while the @@ -95,6 +103,7 @@ export async function GET(request: NextRequest) { }); await markScanFailed(pluginId, `Exceeded ${MAX_ATTEMPTS} scan attempts`); await archivePluginScan(msg_id); + invalidatePluginCaches(); return NextResponse.json({ ok: true, buried: pluginId, @@ -108,6 +117,7 @@ export async function GET(request: NextRequest) { try { await runPluginScan(pluginId); await archivePluginScan(msg_id); + invalidatePluginCaches(); logInfo("scanned ok", { pluginId, msg_id }); return NextResponse.json({ ok: true, scanned: pluginId, msg_id }); } catch (err) { @@ -118,6 +128,7 @@ export async function GET(request: NextRequest) { await archivePluginScan(msg_id).catch((archiveErr) => logError("archive (fatal) failed", archiveErr), ); + invalidatePluginCaches(); return NextResponse.json( { ok: false, diff --git a/apps/cursor/src/app/api/route.ts b/apps/cursor/src/app/api/route.ts deleted file mode 100644 index 71db5dda..00000000 --- a/apps/cursor/src/app/api/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const dynamic = "force-static"; -export const revalidate = 86400; - -export async function GET() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - const rules = (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ); - - return NextResponse.json({ data: rules }); -} diff --git a/apps/cursor/src/app/c/[slug]/opengraph-image.tsx b/apps/cursor/src/app/c/[slug]/opengraph-image.tsx index 99b75c2c..5932f118 100644 --- a/apps/cursor/src/app/c/[slug]/opengraph-image.tsx +++ b/apps/cursor/src/app/c/[slug]/opengraph-image.tsx @@ -1,26 +1,26 @@ +import { cacheLife, cacheTag } from "next/cache"; import { getCompanyProfile } from "@/data/queries"; import { - createOGResponse, OG, OGLayout, + ogResponse, + renderOGBytes, resolveOgImageUrl, } from "@/lib/og"; export const alt = "Company Profile"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; -export default async function Image({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; +async function renderImage(slug: string) { + "use cache"; + cacheLife("days"); + cacheTag("companies", `company-${slug}`); + const { data } = await getCompanyProfile(slug); if (!data) { - return createOGResponse( + return renderOGBytes(
{logoUrl && ( @@ -57,6 +57,7 @@ export default async function Image({ > , ); } + +export default async function Image({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return ogResponse(await renderImage(slug)); +} diff --git a/apps/cursor/src/app/c/[slug]/page.tsx b/apps/cursor/src/app/c/[slug]/page.tsx index 8e862851..539a22de 100644 --- a/apps/cursor/src/app/c/[slug]/page.tsx +++ b/apps/cursor/src/app/c/[slug]/page.tsx @@ -18,13 +18,20 @@ export async function generateMetadata({ params }: { params: Params }) { }; } -export default async function Page({ params }: { params: Params }) { +/** + * `params` is awaited inside the Suspense boundary so the page chrome and + * skeleton prerender into the static shell while the company profile streams. + */ +async function CompanyLoader({ params }: { params: Params }) { const { slug } = await params; + return ; +} +export default function Page({ params }: { params: Params }) { return (
}> - +
); diff --git a/apps/cursor/src/app/globals.css b/apps/cursor/src/app/globals.css index 56b5ada0..495a1783 100644 --- a/apps/cursor/src/app/globals.css +++ b/apps/cursor/src/app/globals.css @@ -6,10 +6,12 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: var(--font-cursor-gothic), ui-sans-serif, system-ui, sans-serif, + --font-sans: + var(--font-cursor-gothic), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - "Liberation Mono", "Courier New", monospace; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; --color-border: var(--border); --color-input: var(--input); diff --git a/apps/cursor/src/app/layout.tsx b/apps/cursor/src/app/layout.tsx index a354e495..99d40fda 100644 --- a/apps/cursor/src/app/layout.tsx +++ b/apps/cursor/src/app/layout.tsx @@ -2,13 +2,14 @@ import "./globals.css"; import { Analytics } from "@vercel/analytics/next"; import type { Metadata } from "next"; import { ThemeProvider } from "next-themes"; -import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Suspense } from "react"; import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; import { JoinCTA } from "@/components/join-cta"; import { GlobalModals } from "@/components/modals/global-modals"; import { ScrollToTop } from "@/components/scroll-to-top"; import { Toaster } from "@/components/ui/sonner"; +import { NuqsAdapter } from "@/lib/nuqs-static-adapter"; import { cn } from "@/lib/utils"; import { cursorGothic } from "@/styles/fonts"; @@ -76,7 +77,10 @@ export default async function RootLayout({ disableTransitionOnChange > - + {/* Reads the pathname (runtime data); renders nothing visual. */} + + +
{children} diff --git a/apps/cursor/src/app/login/opengraph-image.tsx b/apps/cursor/src/app/login/opengraph-image.tsx index 1a4c2837..c8ef2e1b 100644 --- a/apps/cursor/src/app/login/opengraph-image.tsx +++ b/apps/cursor/src/app/login/opengraph-image.tsx @@ -1,10 +1,11 @@ -import { createListingOG, OG } from "@/lib/og"; +import { OG, ogResponse, renderListingOGBytes } from "@/lib/og"; export const alt = "Sign In"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; export default async function Image() { - return createListingOG("Sign In", "Sign in to Cursor Directory"); + return ogResponse( + await renderListingOGBytes("Sign In", "Sign in to Cursor Directory"), + ); } diff --git a/apps/cursor/src/app/mcp/[slug]/edit/page.tsx b/apps/cursor/src/app/mcp/[slug]/edit/page.tsx index 6893ae26..7c39b835 100644 --- a/apps/cursor/src/app/mcp/[slug]/edit/page.tsx +++ b/apps/cursor/src/app/mcp/[slug]/edit/page.tsx @@ -14,7 +14,11 @@ export const metadata: Metadata = { description: "Edit your MCP server on Cursor Directory.", }; -export default async function Page({ params }: { params: Params }) { +/** + * Owner-gated editor: session and `params` access stream inside the page's + * Suspense boundary so the route keeps a prerendered static shell. + */ +async function EditMCPContent({ params }: { params: Params }) { const { slug } = await params; const session = await getSession(); const { data: mcp } = await getMCPBySlug(slug); @@ -23,9 +27,7 @@ export default async function Page({ params }: { params: Params }) { return (
- - - +
); @@ -48,3 +50,11 @@ export default async function Page({ params }: { params: Params }) {
); } + +export default function Page({ params }: { params: Params }) { + return ( + + + + ); +} diff --git a/apps/cursor/src/app/mcp/[slug]/page.tsx b/apps/cursor/src/app/mcp/[slug]/page.tsx deleted file mode 100644 index 8393a062..00000000 --- a/apps/cursor/src/app/mcp/[slug]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "next/navigation"; - -export default async function Page({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; - redirect(`/plugins/mcp-${slug}`); -} diff --git a/apps/cursor/src/app/members/[[...number]]/page.tsx b/apps/cursor/src/app/members/[[...number]]/page.tsx index 8bf32114..c39f87e0 100644 --- a/apps/cursor/src/app/members/[[...number]]/page.tsx +++ b/apps/cursor/src/app/members/[[...number]]/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { Suspense } from "react"; -import { MembersTabs } from "@/components/members/members-tabs"; +import { cacheLife, cacheTag } from "next/cache"; +import type { Company } from "@/components/company/company-card"; +import { JoinCommunityLink } from "@/components/members/join-community-link"; +import { type Member, MembersTabs } from "@/components/members/members-tabs"; import { getCompanies, getMembers, getTotalUsers } from "@/data/queries"; -import { formatNumber } from "@/utils/format"; -import { getSession } from "@/utils/supabase/auth"; +import { formatCount } from "@/lib/utils"; export const metadata: Metadata = { title: "Members | Cursor Directory", @@ -19,20 +19,25 @@ export const metadata: Metadata = { }, }; -export const revalidate = 300; - +/** + * The entire page is cached (stale-while-revalidate, 5-minute background + * refresh): no per-request rendering, no streaming holes. The session-aware + * "Join" CTA is a client component gated on an auth-cookie check, and the + * tab/search filters are client-only state (see `nuqs-static-adapter`), so + * nothing defers to request time. The `[[...number]]` segment is ignored — + * legacy paginated URLs all serve this same cached page. + */ export default async function Page() { - const [ - { data: totalUsers }, - { data: companies }, - { data: initialMembers }, - session, - ] = await Promise.all([ - getTotalUsers(), - getCompanies(), - getMembers({ page: 1, limit: 90 }), - getSession(), - ]); + "use cache"; + cacheLife({ stale: 300, revalidate: 300, expire: 86400 }); + cacheTag("users", "companies"); + + const [{ data: totalUsers }, { data: companies }, { data: initialMembers }] = + await Promise.all([ + getTotalUsers(), + getCompanies(), + getMembers({ page: 1, limit: 90 }), + ]); return (
@@ -40,28 +45,19 @@ export default async function Page() {

Members

- {formatNumber(totalUsers?.count ?? 0)}+ developers and companies + {formatCount(totalUsers?.count ?? 0)}+ developers and companies building with Cursor.

- {!session && ( - - Join the community - - )} +
- - - +
); } diff --git a/apps/cursor/src/app/members/opengraph-image.tsx b/apps/cursor/src/app/members/opengraph-image.tsx index 84e24e14..bafc8b7f 100644 --- a/apps/cursor/src/app/members/opengraph-image.tsx +++ b/apps/cursor/src/app/members/opengraph-image.tsx @@ -1,13 +1,14 @@ -import { createListingOG, OG } from "@/lib/og"; +import { OG, ogResponse, renderListingOGBytes } from "@/lib/og"; export const alt = "Members"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; export default async function Image() { - return createListingOG( - "Members", - "Thousands of developers and companies building with Cursor", + return ogResponse( + await renderListingOGBytes( + "Members", + "Thousands of developers and companies building with Cursor", + ), ); } diff --git a/apps/cursor/src/app/opengraph-image.tsx b/apps/cursor/src/app/opengraph-image.tsx index 36209097..5d1529e3 100644 --- a/apps/cursor/src/app/opengraph-image.tsx +++ b/apps/cursor/src/app/opengraph-image.tsx @@ -1,17 +1,14 @@ -import { - CursorIcon, - createOGResponse, - OG, - OGLayout, -} from "@/lib/og"; +import { cacheLife } from "next/cache"; +import { CursorIcon, OG, OGLayout, ogResponse, renderOGBytes } from "@/lib/og"; export const alt = "Cursor Directory"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; -export default async function Image() { - return createOGResponse( +async function renderImage() { + "use cache"; + cacheLife("max"); + return renderOGBytes(
, ); } + +export default async function Image() { + return ogResponse(await renderImage()); +} diff --git a/apps/cursor/src/app/page.tsx b/apps/cursor/src/app/page.tsx index df37eaab..253ac204 100644 --- a/apps/cursor/src/app/page.tsx +++ b/apps/cursor/src/app/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; +import { cacheLife, cacheTag } from "next/cache"; import type { LeaderboardItem } from "@/components/plugins/plugin-leaderboard"; import { Startpage } from "@/components/startpage"; import { @@ -21,12 +21,6 @@ export const metadata: Metadata = { }, }; -export const dynamic = "force-static"; -// Velocity data refreshes daily via the snapshot cron. Revalidating -// the homepage every hour keeps the leaderboard close to live install -// activity without sacrificing the static cache benefit. -export const revalidate = 3600; - function toLeaderboardItem( p: NonNullable>["data"]>[number], installs30d: number, @@ -51,7 +45,20 @@ function toLeaderboardItem( }; } +/** + * The entire page is cached (stale-while-revalidate): served from the static + * shell, refreshed in the background hourly, and expired immediately when + * actions invalidate the `plugins`/`users` tags (installs, stars, plugin + * mutations). The `?q=` search filter is client-only state (see + * `nuqs-static-adapter`), so nothing here defers to request time. The + * hourly background revalidation also covers pg_cron snapshot drift in the + * install-velocity leaderboard (20260514_plugin_install_snapshots). + */ export default async function Page() { + "use cache"; + cacheLife("hours"); + cacheTag("plugins", "users"); + const [ { data: totalUsers }, { data: allPluginsData }, @@ -67,15 +74,18 @@ export default async function Page() { toLeaderboardItem(p, velocity.get(p.id) ?? 0), ); + // Captured inside the cache scope so leaderboard age math is deterministic + // during prerendering; refreshes with each cache revalidation. + const generatedAt = Date.now(); + return (
- - - +
); diff --git a/apps/cursor/src/app/plugins/[slug]/edit/page.tsx b/apps/cursor/src/app/plugins/[slug]/edit/page.tsx index be2be469..44f1e327 100644 --- a/apps/cursor/src/app/plugins/[slug]/edit/page.tsx +++ b/apps/cursor/src/app/plugins/[slug]/edit/page.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { EditPluginForm } from "@/components/forms/edit-plugin-form"; -import { PluginOwnerMenu } from "@/components/plugins/plugin-owner-menu"; import { Login } from "@/components/login"; +import { PluginOwnerMenu } from "@/components/plugins/plugin-owner-menu"; import { getPluginBySlug } from "@/data/queries"; import { getSession } from "@/utils/supabase/auth"; @@ -14,7 +14,11 @@ export const metadata: Metadata = { description: "Edit your plugin on Cursor Directory.", }; -export default async function Page({ params }: { params: Params }) { +/** + * Owner-gated editor: session and `params` access stream inside the page's + * Suspense boundary so the route keeps a prerendered static shell. + */ +async function EditPluginContent({ params }: { params: Params }) { const { slug } = await params; const session = await getSession(); const { data: plugin } = await getPluginBySlug(slug); @@ -27,9 +31,7 @@ export default async function Page({ params }: { params: Params }) { return (
- - - +
); @@ -52,3 +54,11 @@ export default async function Page({ params }: { params: Params }) {
); } + +export default function Page({ params }: { params: Params }) { + return ( + + + + ); +} diff --git a/apps/cursor/src/app/plugins/[slug]/opengraph-image.tsx b/apps/cursor/src/app/plugins/[slug]/opengraph-image.tsx index 501a7a1d..08a51c8c 100644 --- a/apps/cursor/src/app/plugins/[slug]/opengraph-image.tsx +++ b/apps/cursor/src/app/plugins/[slug]/opengraph-image.tsx @@ -1,28 +1,27 @@ +import { cacheLife, cacheTag } from "next/cache"; import { getPluginBySlug } from "@/data/queries"; import { - createOGResponse, formatCount, OG, OGLayout, + ogResponse, + renderOGBytes, resolveOgImageUrl, } from "@/lib/og"; export const alt = "Plugin"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -// Must be a literal — Next.js segment config does not accept imported values. -export const revalidate = 86400; -export default async function Image({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; +async function renderImage(slug: string) { + "use cache"; + cacheLife("days"); + cacheTag("plugins", `plugin-${slug}`); + const { data } = await getPluginBySlug(slug); if (!data) { - return createOGResponse( + return renderOGBytes(
`${count} ${type}${count > 1 ? "s" : ""}`) .join(" · "); - return createOGResponse( + return renderOGBytes(
@@ -69,6 +68,7 @@ export default async function Image({ {logoUrl ? ( , ); } + +export default async function Image({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return ogResponse(await renderImage(slug)); +} diff --git a/apps/cursor/src/app/plugins/[slug]/page.tsx b/apps/cursor/src/app/plugins/[slug]/page.tsx index 14ee4410..5e6e6d37 100644 --- a/apps/cursor/src/app/plugins/[slug]/page.tsx +++ b/apps/cursor/src/app/plugins/[slug]/page.tsx @@ -53,5 +53,3 @@ export default async function Page({ params }: { params: Params }) { return ; } - -export const revalidate = 3600; diff --git a/apps/cursor/src/app/plugins/new/opengraph-image.tsx b/apps/cursor/src/app/plugins/new/opengraph-image.tsx index 78dafe5b..21378a2b 100644 --- a/apps/cursor/src/app/plugins/new/opengraph-image.tsx +++ b/apps/cursor/src/app/plugins/new/opengraph-image.tsx @@ -1,13 +1,14 @@ -import { createListingOG, OG } from "@/lib/og"; +import { OG, ogResponse, renderListingOGBytes } from "@/lib/og"; export const alt = "Submit a Plugin"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; export default async function Image() { - return createListingOG( - "Submit a Plugin", - "Share your Cursor plugin with the community", + return ogResponse( + await renderListingOGBytes( + "Submit a Plugin", + "Share your Cursor plugin with the community", + ), ); } diff --git a/apps/cursor/src/app/plugins/new/page.tsx b/apps/cursor/src/app/plugins/new/page.tsx index ba504e71..116690e0 100644 --- a/apps/cursor/src/app/plugins/new/page.tsx +++ b/apps/cursor/src/app/plugins/new/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { PluginForm } from "@/components/forms/plugin-form"; import { getSession } from "@/utils/supabase/auth"; @@ -19,13 +20,22 @@ export const metadata: Metadata = { }, }; -export default async function Page() { +/** + * The auth gate reads the session (runtime API), so it streams inside + * Suspense. The marketing copy above stays in the static shell, which also + * means link prefetches of this page no longer invoke a function. + */ +async function NewPluginGate() { const session = await getSession(); if (!session) { redirect("/login?next=/plugins/new"); } + return ; +} + +export default function Page() { return (
@@ -47,7 +57,9 @@ export default async function Page() {

- + + +
); diff --git a/apps/cursor/src/app/plugins/opengraph-image.tsx b/apps/cursor/src/app/plugins/opengraph-image.tsx index 3b5607c3..ba38a2f5 100644 --- a/apps/cursor/src/app/plugins/opengraph-image.tsx +++ b/apps/cursor/src/app/plugins/opengraph-image.tsx @@ -1,13 +1,14 @@ -import { createListingOG, OG } from "@/lib/og"; +import { OG, ogResponse, renderListingOGBytes } from "@/lib/og"; export const alt = "Plugins"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; export default async function Image() { - return createListingOG( - "Plugins", - "Rules, MCP servers, and integrations built by the community", + return ogResponse( + await renderListingOGBytes( + "Plugins", + "Rules, MCP servers, and integrations built by the community", + ), ); } diff --git a/apps/cursor/src/app/sitemap.ts b/apps/cursor/src/app/sitemap.ts index 6671491a..249fb55e 100644 --- a/apps/cursor/src/app/sitemap.ts +++ b/apps/cursor/src/app/sitemap.ts @@ -1,9 +1,14 @@ import type { MetadataRoute } from "next"; +import { cacheLife, cacheTag } from "next/cache"; import { getCompanies, getPlugins } from "@/data/queries"; const BASE_URL = "https://cursor.directory"; export default async function sitemap(): Promise { + "use cache"; + cacheLife("hours"); + cacheTag("plugins", "companies"); + const routes: MetadataRoute.Sitemap = [ { url: BASE_URL, diff --git a/apps/cursor/src/app/u/[slug]/followers/page.tsx b/apps/cursor/src/app/u/[slug]/followers/page.tsx index 0f21cd8a..e1e6a385 100644 --- a/apps/cursor/src/app/u/[slug]/followers/page.tsx +++ b/apps/cursor/src/app/u/[slug]/followers/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { MembersCard } from "@/components/members/members-card"; import { ProfileTop } from "@/components/profile/profile-top"; import { getUserFollowers, getUserProfile } from "@/data/queries"; @@ -16,18 +17,21 @@ export async function generateMetadata({ params }: { params: Params }) { }; } -export default async function Page({ params }: { params: Params }) { +/** + * Session-gated content: the session read and `params` access stream inside + * the page's Suspense boundary so the route still prerenders a static shell. + */ +async function FollowersList({ params }: { params: Params }) { const { slug } = await params; - const { data } = await getUserProfile(slug); - - const { data: followers } = await getUserFollowers(data?.id); const session = await getSession(); if (!session) { redirect("/login"); } + const { data } = await getUserProfile(slug); + if (!data) { return (
@@ -36,32 +40,42 @@ export default async function Page({ params }: { params: Params }) { ); } + const { data: followers } = await getUserFollowers(data.id); + return ( -
-
- +
+ -
-

Followers

-

- {followers?.length ?? 0} people follow this profile. -

-
- {followers?.length === 0 && ( -
No followers
- )} - {followers?.map((user) => ( - - ))} -
+
+

Followers

+

+ {followers?.length ?? 0} people follow this profile. +

+
+ {followers?.length === 0 && ( +
No followers
+ )} + {followers?.map((user) => ( + + ))}
); } + +export default function Page({ params }: { params: Params }) { + return ( +
+ + + +
+ ); +} diff --git a/apps/cursor/src/app/u/[slug]/following/page.tsx b/apps/cursor/src/app/u/[slug]/following/page.tsx index be8ee74a..aca29901 100644 --- a/apps/cursor/src/app/u/[slug]/following/page.tsx +++ b/apps/cursor/src/app/u/[slug]/following/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { MembersCard } from "@/components/members/members-card"; import { ProfileTop } from "@/components/profile/profile-top"; import { getUserFollowing, getUserProfile } from "@/data/queries"; @@ -16,18 +17,21 @@ export async function generateMetadata({ params }: { params: Params }) { }; } -export default async function Page({ params }: { params: Params }) { +/** + * Session-gated content: the session read and `params` access stream inside + * the page's Suspense boundary so the route still prerenders a static shell. + */ +async function FollowingList({ params }: { params: Params }) { const { slug } = await params; - const { data } = await getUserProfile(slug); - - const { data: following } = await getUserFollowing(data?.id); const session = await getSession(); if (!session) { redirect("/login"); } + const { data } = await getUserProfile(slug); + if (!data) { return (
@@ -36,32 +40,42 @@ export default async function Page({ params }: { params: Params }) { ); } + const { data: following } = await getUserFollowing(data.id); + return ( -
-
- +
+ -
-

Following

-

- Following {following?.length ?? 0} people. -

-
- {following?.length === 0 && ( -
No following
- )} - {following?.map((user) => ( - - ))} -
+
+

Following

+

+ Following {following?.length ?? 0} people. +

+
+ {following?.length === 0 && ( +
No following
+ )} + {following?.map((user) => ( + + ))}
); } + +export default function Page({ params }: { params: Params }) { + return ( +
+ + + +
+ ); +} diff --git a/apps/cursor/src/app/u/[slug]/opengraph-image.tsx b/apps/cursor/src/app/u/[slug]/opengraph-image.tsx index a644ce61..f42fb087 100644 --- a/apps/cursor/src/app/u/[slug]/opengraph-image.tsx +++ b/apps/cursor/src/app/u/[slug]/opengraph-image.tsx @@ -1,26 +1,26 @@ +import { cacheLife, cacheTag } from "next/cache"; import { getUserProfile } from "@/data/queries"; import { - createOGResponse, OG, OGLayout, + ogResponse, + renderOGBytes, resolveOgImageUrl, } from "@/lib/og"; export const alt = "User Profile"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; -export const revalidate = 86400; -export default async function Image({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; +async function renderImage(slug: string) { + "use cache"; + cacheLife("days"); + cacheTag("users", `user-${slug}`); + const { data } = await getUserProfile(slug); if (!data) { - return createOGResponse( + return renderOGBytes(
{avatarUrl && ( , ); } + +export default async function Image({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return ogResponse(await renderImage(slug)); +} diff --git a/apps/cursor/src/app/u/[slug]/page.tsx b/apps/cursor/src/app/u/[slug]/page.tsx index ccc36955..bd8019c2 100644 --- a/apps/cursor/src/app/u/[slug]/page.tsx +++ b/apps/cursor/src/app/u/[slug]/page.tsx @@ -18,13 +18,21 @@ export async function generateMetadata({ params }: { params: Params }) { }; } -export default async function Page({ params }: { params: Params }) { +/** + * Awaiting `params` (a runtime API — no static params are generated for + * profiles) happens inside the Suspense boundary so the page chrome and + * skeleton prerender into the static shell while the profile streams. + */ +async function ProfileLoader({ params }: { params: Params }) { const { slug } = await params; + return ; +} +export default function Page({ params }: { params: Params }) { return (
}> - +
); diff --git a/apps/cursor/src/app/u/[slug]/settings/page.tsx b/apps/cursor/src/app/u/[slug]/settings/page.tsx index 18ccf0f0..7cbd38f2 100644 --- a/apps/cursor/src/app/u/[slug]/settings/page.tsx +++ b/apps/cursor/src/app/u/[slug]/settings/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { NotificationSettings } from "@/components/profile/notification-settings"; import { ProfileTop } from "@/components/profile/profile-top"; import { getUserProfile } from "@/data/queries"; @@ -6,11 +7,15 @@ import { getSession } from "@/utils/supabase/auth"; type Params = Promise<{ slug: string }>; -export default async function Page({ params }: { params: Params }) { +/** + * Owner-only settings: the session read and owner-scoped (uncached) profile + * read stream inside the page's Suspense boundary. + */ +async function SettingsContent({ params }: { params: Params }) { const { slug } = await params; - const { data } = await getUserProfile(slug); const session = await getSession(); + const { data } = await getUserProfile(slug, session?.user?.id); if (!data) { return ( @@ -25,21 +30,29 @@ export default async function Page({ params }: { params: Params }) { } return ( -
-
- - -
-

Settings

-
- -
+
+ + +
+

Settings

+
+
); } + +export default function Page({ params }: { params: Params }) { + return ( +
+ + + +
+ ); +} diff --git a/apps/cursor/src/components/company/company-skeleton.tsx b/apps/cursor/src/components/company/company-skeleton.tsx index e6482563..5e06814c 100644 --- a/apps/cursor/src/components/company/company-skeleton.tsx +++ b/apps/cursor/src/components/company/company-skeleton.tsx @@ -42,9 +42,9 @@ export function CompanySkeleton() {
- {Array.from({ length: 2 }).map((_, i) => ( + {["job-1", "job-2"].map((key) => (
diff --git a/apps/cursor/src/components/filter-input.tsx b/apps/cursor/src/components/filter-input.tsx deleted file mode 100644 index 17815b27..00000000 --- a/apps/cursor/src/components/filter-input.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useState } from "react"; -import { SearchField } from "@/components/ui/search-field"; - -interface FilterInputProps { - onSearch: (term: string) => void; - clearSearch: () => void; -} - -export const FilterInput = ({ onSearch, clearSearch }: FilterInputProps) => { - const [searchTerm, setSearchTerm] = useState(""); - - const handleSearch = (event: React.ChangeEvent) => { - const term = event.target.value.toLowerCase(); - setSearchTerm(term); - onSearch(term); - }; - - const handleClear = () => { - setSearchTerm(""); - onSearch(""); - clearSearch(); - }; - - return ( -
- { - const term = value.toLowerCase(); - setSearchTerm(term); - onSearch(term); - }} - onClear={handleClear} - /> -
- ); -}; diff --git a/apps/cursor/src/components/forms/component-draft-editor.tsx b/apps/cursor/src/components/forms/component-draft-editor.tsx new file mode 100644 index 00000000..400a664d --- /dev/null +++ b/apps/cursor/src/components/forms/component-draft-editor.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + COMPONENT_TYPE_LABELS, + COMPONENT_TYPES, + type ComponentInput, + type ComponentType, +} from "@/lib/plugins/types"; +import { slugify } from "@/lib/slug"; + +/** + * A plugin component as it exists while being edited in a form, before it is + * mapped to a `ComponentInput` for the create/update actions. + */ +export type ComponentDraft = { + id: string; + type: ComponentType; + /** + * Stored DB (or parser-derived) slug, empty for components added in the + * form. Preserved through the round-trip because regenerating from `name` + * produces a different slug whenever name and slug were derived from + * different sources (GitHub-imported rules slugify the filename but use + * the frontmatter title/description as the name), which churns slugs, + * breaks the rescan diff, and drops metadata keyed by the old slug. + */ + slug: string; + name: string; + description: string; + content: string; +}; + +export function newComponentDraft(): ComponentDraft { + return { + id: crypto.randomUUID(), + type: "rule", + slug: "", + name: "", + description: "", + content: "", + }; +} + +/** + * Maps a draft to the payload shape accepted by the create/update plugin + * actions. Callers filter out unnamed drafts first and decide whether to + * attach `metadata` (create sends `{}`; update omits it so the action keeps + * the previously stored metadata). + */ +export function draftToComponentInput(draft: ComponentDraft): ComponentInput { + return { + type: draft.type, + name: draft.name.trim(), + slug: draft.slug || slugify(draft.name), + description: draft.description.trim() || undefined, + content: draft.content.trim() || undefined, + }; +} + +/** + * Editable list of plugin components: type, name, description, and content + * per component, plus add/remove. Used by the create (auto + manual tabs) + * and edit plugin forms. + */ +export function ComponentDraftEditor({ + drafts, + onChange, + header, +}: { + drafts: ComponentDraft[]; + onChange: (drafts: ComponentDraft[]) => void; + /** Rendered to the left of the Add button, e.g. a label or count. */ + header: ReactNode; +}) { + const addDraft = () => onChange([...drafts, newComponentDraft()]); + + const removeDraft = (id: string) => + onChange(drafts.filter((c) => c.id !== id)); + + const updateDraft = ( + id: string, + field: keyof Omit, + value: string, + ) => + onChange(drafts.map((c) => (c.id === id ? { ...c, [field]: value } : c))); + + return ( +
+
+ {header} + +
+ + {drafts.map((comp, index) => ( +
+
+

+ Component {index + 1} +

+ {drafts.length > 1 && ( + + )} +
+
+
+ + +
+
+ + updateDraft(comp.id, "name", e.target.value)} + placeholder="my-rule" + className="border-border placeholder:text-[#878787]" + /> +
+
+
+ + + updateDraft(comp.id, "description", e.target.value) + } + placeholder="What this component does" + className="border-border placeholder:text-[#878787]" + /> +
+
+ +