diff --git a/.gitignore b/.gitignore index 5691dd146de..ce23cae5706 100644 --- a/.gitignore +++ b/.gitignore @@ -112,7 +112,8 @@ next-env.d.ts # DynamoDB Local files .dynamodb/ -.vscode +.vscode/* +!.vscode/content-listing.code-snippets .idea .vercel diff --git a/.vscode/content-listing.code-snippets b/.vscode/content-listing.code-snippets new file mode 100644 index 00000000000..580eda912cd --- /dev/null +++ b/.vscode/content-listing.code-snippets @@ -0,0 +1,28 @@ +{ + "Content listing data export": { + "prefix": "cl-data", + "scope": "typescript", + "description": "ContentListingGroup export for overview listing blocks", + "body": [ + "export const ${1:topic}${2:Section}: ContentListingGroup = {", + " id: '${3:topic-section}',", + " heading: '${4:Section heading}',", + " description: '${5:Optional intro sentence}',", + " type: '${6|grid,list|}',", + " items: [", + " {", + " title: '${7:Link title}',", + " href: '${8:/guides/...}',", + " description: '${9:Short description}',", + " },", + " ],", + "}" + ] + }, + "Content listing inline MDX": { + "prefix": "cl-inline", + "scope": "markdown,mdx", + "description": "Inline ContentListings component in a guide MDX file", + "body": [""] + } +} diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 6450a90c812..6a3027b1770 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -197,15 +197,42 @@ Keep code lines short to avoid scrolling. For example, you can split long shell Optionally specify a filename for the codeblock by including it after the opening backticks and language specifier: -```md +````md ```ts environment.ts + ``` +```` Optionally highlight lines by using `mark=${lineNumber}`. -```md +````md ```js mark=12:13 + ``` +```` + +### Content listings + +Overview and index pages use a single `` component for curated link sections such as "Get started", "Next steps", "Examples", or "Resources". Refer to [`storage.data.ts`](data/content-listings/storage.data.ts) and [`storage.mdx`](content/guides/storage.mdx) for a full example. + +**Prompt to add content listings:** + +```text +Add a content listing block for [TOPIC] / [SECTION] (for example, Storage / Examples). +Follow CONTRIBUTING § Content listings in apps/docs. +Copy structure from `storageGetStarted` in apps/docs/data/content-listings/storage.data.ts. +Pick a globally-unique kebab-case id like `[topic]-[section]`. +Run `pnpm test:local lib/content-listings.test.ts` from apps/docs. +``` + +**Manually add content listings:** + +1. Add or update a `ContentListingGroup` export in [`data/content-listings/[topic].data.ts`](data/content-listings/). The `id` field must be globally unique across all listing groups (e.g. `storage-get-started`, not just `get-started`) — it is used both as the lookup key and as the telemetry `listingId`. +2. Place the component inline in guide MDX, for example ``. Use a partial only when the block is reused or gated with `$Show` at the partial level. +3. Run `pnpm test:local lib/content-listings.test.ts` from `apps/docs`. + +Code snippets for manually adding content listings are available in [`.vscode/content-listing.code-snippets`](../../.vscode/content-listing.code-snippets): `cl-data` (data export with namespaced id) and `cl-inline` (MDX component). + ### Footnotes @@ -284,7 +311,7 @@ Don't nest lists more than two deep. 3. List item - List item - List item - + - Overly nested list item ``` diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx new file mode 100644 index 00000000000..323478ca22e --- /dev/null +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ContentListingGroup, ContentListingItem } from '~/lib/content-listings.schema' +import { + getContentListingById, + getContentListingGroupLabel, + isExternalContentListingHref, +} from '~/lib/content-listings.utils' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import Link from 'next/link' +import { useCallback } from 'react' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { Heading } from 'ui/src/components/CustomHTMLElements' + +const GRID_ITEM_CLASS = { + 2: 'col-span-12 md:col-span-6', + 3: 'col-span-12 md:col-span-4', + 4: 'col-span-12 md:col-span-3', +} as const + +function useContentListingClickHandler(group: ContentListingGroup) { + const sendTelemetryEvent = useSendTelemetryEvent() + const groupLabel = getContentListingGroupLabel(group) + + const trackClick = useCallback( + (item: ContentListingItem) => { + sendTelemetryEvent({ + action: 'docs_content_listing_clicked', + properties: { + targetPath: item.href, + linkTitle: item.title, + ...(groupLabel ? { groupTitle: groupLabel } : {}), + listingId: group.id, + }, + }) + }, + [sendTelemetryEvent, group.id, groupLabel] + ) + + return { trackClick } +} + +function ContentListingGroupHeading({ group }: { group: ContentListingGroup }) { + if (!group.heading) return null + + return {group.heading} +} + +function ContentListingsGroup({ group }: { group: ContentListingGroup }) { + const { trackClick } = useContentListingClickHandler(group) + const isGrid = group.type === 'grid' + const listClassName = isGrid ? 'grid md:grid-cols-12 gap-4' : 'list-disc pl-6 space-y-2' + const gridItemClassName = isGrid ? GRID_ITEM_CLASS[group.columns ?? 3] : undefined + + // Heading stays outside `not-prose` so it inherits the surrounding MDX prose + // typography. The list itself opts out so its explicit Tailwind layout wins. + return ( +
+ +
+ {group.description &&

{group.description}

} +
    + {group.items.map((item) => { + const external = isExternalContentListingHref(item.href) + const key = `${group.id}-${item.href}` + + if (isGrid) { + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + + {item.description} + + +
  • + ) + } + + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + {item.title}: {item.description} + +
  • + ) + })} +
+
+
+ ) +} + +export function ContentListings({ id }: { id: string }) { + const group = getContentListingById(id) + if (!group || !group.items.length) return null + + return ( +
+ +
+ ) +} diff --git a/apps/docs/components/ContentListings/index.tsx b/apps/docs/components/ContentListings/index.tsx new file mode 100644 index 00000000000..461a4ab891b --- /dev/null +++ b/apps/docs/components/ContentListings/index.tsx @@ -0,0 +1 @@ +export { ContentListings } from './ContentListings.client' diff --git a/apps/docs/content/guides/auth.mdx b/apps/docs/content/guides/auth.mdx index a500f15609c..fd4071f7e65 100644 --- a/apps/docs/content/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -1,7 +1,7 @@ --- id: 'auth' title: 'Auth' -description: 'Use Supabase to Authenticate and Authorize your users.' +description: 'Use Supabase to authenticate and authorize your users.' subtitle: 'Use Supabase to authenticate and authorize your users.' tocVideo: '6ow_jW4epf8' --- @@ -27,19 +27,15 @@ Auth uses your project's Postgres database under the hood, storing user data and Auth also enables access control to your database's automatically generated [REST API](/docs/guides/api). When using Supabase SDKs, your data requests are automatically sent with the user's Auth Token. The Auth Token scopes database access on a row-by-row level when used along with [RLS policies](/docs/guides/database/postgres/row-level-security). + + <$Show if="authentication:show_providers"> <$Partial path="providers.mdx" /> <$Show if="billing:all"> -## Pricing - -Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (Third-Party MAU), and Monthly Active SSO Users (SSO MAU) and Advanced MFA Add-ons. For a detailed breakdown of how these charges are calculated, refer to the following pages: - -- [Pricing MAU](/docs/guides/platform/manage-your-usage/monthly-active-users) -- [Pricing Third-Party MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-third-party) -- [Pricing SSO MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-sso) -- [Advanced MFA - Phone](/docs/guides/platform/manage-your-usage/advanced-mfa-phone) - + + + diff --git a/apps/docs/content/guides/database/overview.mdx b/apps/docs/content/guides/database/overview.mdx index 6cc01e11976..953c3bfe296 100644 --- a/apps/docs/content/guides/database/overview.mdx +++ b/apps/docs/content/guides/database/overview.mdx @@ -1,44 +1,19 @@ --- id: 'database' title: 'Database' -description: 'Every Supabase project is a full Postgres database. Learn how to connect, manage, and secure your data.' +subtitle: 'Use Supabase to connect, manage, and secure your Postgres database.' +description: 'Use Supabase to connect, manage, and secure your Postgres database.' sidebar_label: 'Overview' --- -Every Supabase project has a full [Postgres](https://www.postgresql.org/) database — not a Postgres abstraction. +Every Supabase project gets a full [Postgres](https://www.postgresql.org/) database, not a Postgres abstraction. This database is the foundation that Auth, Storage, Realtime, and Edge Functions are built on, and Supabase manages daily database backups and offers point-in-time recovery on paid plans. - - -You can work with a project's database in several ways: +Work with your project's database in the following ways: - Visually using the [**Table Editor**](/dashboard/project/_/editor) section of the Dashboard. - With query syntax using the [**SQL Editor**](/dashboard/project/_/sql) section of the Dashboard. -- Programmatically using a variety of methods. +- Programmatically using a variety of different methods. -Read the [Connect to your database](/docs/guides/database/connecting-to-postgres#how-to-connect-to-your-postgres-databases) guide for details on connection strings and options. + - - -The database is the foundation that Auth, Storage, Realtime, and Edge Functions are built on, and Supabase manages daily database backups and offers point-in-time recovery on paid plans. - -## Get started - -If you're new to the database section, these are the pages to read first: - -- **[Connect to your database](/docs/guides/database/connecting-to-postgres)**: Connection strings, the Supavisor connection pooler, and when to use direct, transaction, or session mode. -- **[Tables and data](/docs/guides/database/tables)**: Create tables and relationships, and edit rows from the Dashboard. -- **[Import data](/docs/guides/database/import-data)**: Load existing data from CSV files, `pg_dump`, or another Postgres database. -- **[Secure your data](/docs/guides/database/secure-data)**: Row Level Security (RLS) is how Supabase makes the database safe to query directly from the client. Read this before exposing any table to your app. -- **[Extensions](/docs/guides/database/extensions)**: Enable Postgres extensions — including `pgvector` for embeddings, `PostGIS` for geospatial data, and `pg_cron` for scheduled jobs — from the Dashboard. -- **[Run SQL commands](/dashboard/project/_/sql)**: Use the Dashboard's SQL Editor for ad-hoc queries and saved snippets. - -## Going further - -Once you've covered the basics, these guides help with other use cases and features: - -- **[Database functions](/docs/guides/database/functions)** and **[triggers](/docs/guides/database/postgres/triggers)**: Run logic inside the database in response to inserts, updates, or deletes. -- **[Database webhooks](/docs/guides/database/webhooks)**: Send row changes to an external HTTP endpoint. -- **[Replication and read replicas](/docs/guides/database/replication)**: Stream changes to other systems or read from a geographically closer replica. -- **[Backups](/docs/guides/platform/backups)**: Daily backups on every project, with point-in-time recovery on paid plans. Backups cover the database itself; objects stored through the Storage API are not included. -- **[Query performance and optimization](/docs/guides/database/query-optimization)**: Indexes, the query planner, and tools for finding slow queries. -- **[Roles and permissions](/docs/guides/database/postgres/roles)**: The Postgres roles Supabase ships with and how to add your own. + diff --git a/apps/docs/content/guides/functions.mdx b/apps/docs/content/guides/functions.mdx index 87d1b7f42e4..2982243fa10 100644 --- a/apps/docs/content/guides/functions.mdx +++ b/apps/docs/content/guides/functions.mdx @@ -1,8 +1,8 @@ --- id: 'functions' title: 'Edge Functions' -description: 'Globally distributed TypeScript functions.' -subtitle: 'Globally distributed TypeScript functions.' +description: 'Run TypeScript functions globally at the edge.' +subtitle: 'Run TypeScript functions globally at the edge.' sidebar_label: 'Overview' tocVideo: 'za_loEtS4gs' --- @@ -41,295 +41,18 @@ Edge Functions are server-side TypeScript functions, distributed globally at the - Sending transactional emails. - Building messaging bots for Slack, Discord, etc. -
- -
+ ## Examples -Check out the [Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in our GitHub repository. +Check out [Supabase Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in GitHub or try these examples: -
-
- - - Use the Supabase client inside your Edge Function. - - -
-
- - - Combining Kysely with Deno Postgres gives you a convenient developer experience for - interacting directly with your Postgres database. - - -
-
- - - Monitor Edge Functions with the Sentry Deno SDK. - - -
-
- - - Send CORS headers for invoking from the browser. - - -
-
- - - Full example for using Supabase and Stripe, with Expo. - - -
-
- - - Full example for using Supabase and Stripe, with Flutter. - - -
-
- - - Learn how to use HTTP methods and paths to build a RESTful service for managing tasks. - - -
-
- - - An example on reading a file from Supabase Storage. - - -
-
- - - Generate Open Graph images with Deno and Supabase Edge Functions. - - -
-
- - - Cache generated images with Supabase Storage CDN. - - -
-
- - - Get user location data from user's IP address. - - -
-
- - - Protecting Forms with Cloudflare Turnstile. - - -
-
- - - Connecting to Postgres from Edge Functions. - - -
-
- - - Deploying Edge Functions with GitHub Actions. - - -
-
- - - Request Routing with Oak server middleware. - - -
-
- - - Access 100,000+ Machine Learning models. - - -
-
- - - Amazon Bedrock Image Generator - - -
-
- - - Using OpenAI in Edge Functions. - - -
-
- - - Handling signed Stripe Webhooks with Edge Functions. - - -
-
- - - Send emails in Edge Functions with Resend. - - -
-
- - - Server-Sent Events in Edge Functions. - - -
-
- - - Generate screenshots with Puppeteer. - - -
-
- - - Building a Slash Command Discord Bot with Edge Functions. - - -
-
- - - Building a Telegram Bot with Edge Functions. - - -
-
- - - Process multipart/form-data. - - -
-
- - - Build an Edge Functions Counter with Upstash Redis. - - -
-
- - - Rate Limiting Edge Functions with Upstash Redis. - - -
-
- - - Slack Bot handling Slack mentions in Edge Function - - -
-
+ + + + + + + + + diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 45a0a7892cc..f0ff033351a 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -5,36 +5,7 @@ description: 'Resources for getting started with Supabase.' hideToc: true --- -
- -
- -
- - - Develop with Supabase AI-first using plugins, MCP, and skills. - - - - - Learn about the different API keys in Supabase and how to use them. - - - - - Use the Supabase CLI to develop locally and collaborate between teams. - - -
- -
- -
+ ### Use cases diff --git a/apps/docs/content/guides/realtime.mdx b/apps/docs/content/guides/realtime.mdx index cd79fccadff..8352d90659b 100644 --- a/apps/docs/content/guides/realtime.mdx +++ b/apps/docs/content/guides/realtime.mdx @@ -20,60 +20,8 @@ Supabase provides a globally distributed [Realtime](https://github.com/supabase/ - **Multiplayer games** - Synchronized game state and player interactions - **Social features** - Live notifications, reactions, and user activity feeds -Check the [Getting Started](/docs/guides/realtime/getting_started) guide to get started. + -## Examples + -
-
- - - Showcase application displaying cursor movements and chat messages using Broadcast. - - -
-
- - - Supabase UI chat component using Broadcast to send message between users. - - -
-
- - - Supabase UI avatar stack component using Presence to track connected users. - - -
-
- - - Supabase UI realtime cursor component using Broadcast to share users' cursors to build - collaborative applications. - - -
-
- -## Resources - -Find the source code and documentation in the Supabase GitHub repository. - -
-
- - View the source code. - -
-
- - - Read more about Supabase Realtime. - - -
-
+ diff --git a/apps/docs/content/guides/storage.mdx b/apps/docs/content/guides/storage.mdx index 81643f11439..a918edf83a1 100644 --- a/apps/docs/content/guides/storage.mdx +++ b/apps/docs/content/guides/storage.mdx @@ -17,91 +17,8 @@ Supabase Storage is a robust, scalable solution for managing files of any size w - **Fine-grained Access Control** - Manage file permissions with row-level security and custom policies - **Multiple Bucket Types** - Specialized storage solutions for different use cases -## Storage bucket types + -Supabase Storage offers different bucket types optimized for specific use cases: + -### Files buckets - -Store and serve traditional files including images, videos, documents, and general-purpose content. Ideal for user-generated content, media libraries, and asset management. - -**Use cases:** Images, videos, documents, PDFs, archives - -**Features:** - -- Global CDN delivery -- Image optimization and transformation -- Row-level security integration -- Direct URL access for files - -[Learn more about Files Buckets](/docs/guides/storage/quickstart) - -### Analytics buckets - -Purpose-built for storing and analyzing data in open table formats like Apache Iceberg. Perfect for time-series data, logs, and large-scale analytical workloads. - -**Use cases:** Data lakes, analytics pipelines, ETL operations, historical data analysis - -**Features:** - -- Apache Iceberg table format support -- SQL-accessible via Postgres foreign tables -- Partitioned data organization -- Efficient data querying and transformation - -[Learn more about Analytics Buckets](/docs/guides/storage/analytics/introduction) - -### Vector buckets - -Specialized storage for vector embeddings and similarity search operations. Designed for AI and ML applications requiring semantic search capabilities. - -**Use cases:** AI-powered search, semantic similarity matching, embedding storage, RAG systems - -**Features:** - -- Optimized vector indexing (HNSW, Flat) -- Multiple distance metrics (cosine, euclidean, L2) -- Metadata filtering for vectors -- Similarity search queries - -[Learn more about Vector Buckets](/docs/guides/storage/vector/introduction) - -## Examples - -Check out all of the Storage [templates and examples](https://github.com/supabase/supabase/tree/master/examples/storage) in our GitHub repository. - -
-
- - - Use Uppy to upload files to Supabase Storage using the TUS protocol (resumable uploads). - - -
-
- -## Resources - -Find the source code and documentation in the Supabase GitHub repository. - -
-
- - View the source code. - -
-
- - - See the Swagger Documentation for Supabase Storage. - - -
-
+ diff --git a/apps/docs/data/content-listings/auth.data.ts b/apps/docs/data/content-listings/auth.data.ts new file mode 100644 index 00000000000..e70d1cd5836 --- /dev/null +++ b/apps/docs/data/content-listings/auth.data.ts @@ -0,0 +1,102 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const authGetStarted: ContentListingGroup = { + id: 'auth-get-started', + heading: 'Get started', + description: "Start here if you're new to Supabase Auth:", + type: 'grid', + items: [ + { + title: 'Auth with email and password', + href: '/guides/auth/passwords', + description: 'Sign up and sign in users with email and password.', + }, + { + title: 'Server-side rendering', + href: '/guides/auth/server-side', + description: 'Create a Supabase client for SSR frameworks like Next.js and SvelteKit.', + }, + { + title: 'Row Level Security', + href: '/guides/database/postgres/row-level-security', + description: 'Use RLS policies to authorize data access from the client.', + }, + ], +} + +export const authPricing: ContentListingGroup = { + id: 'auth-pricing', + heading: 'Pricing', + description: + 'Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (Third-Party MAU), and Monthly Active SSO Users (SSO MAU) and Advanced MFA Add-ons. For a detailed breakdown of how these charges are calculated, refer to the following pages.', + type: 'list', + items: [ + { + title: 'Pricing MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users', + description: 'How MAU usage is measured and billed.', + }, + { + title: 'Pricing Third-Party MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users-third-party', + description: 'How third-party auth MAU is measured and billed.', + }, + { + title: 'Pricing SSO MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users-sso', + description: 'How SSO MAU usage is measured and billed.', + }, + { + title: 'Advanced MFA - Phone', + href: '/guides/platform/manage-your-usage/advanced-mfa-phone', + description: 'How Advanced MFA Phone add-on usage is measured and billed.', + }, + ], +} + +export const authNextSteps: ContentListingGroup = { + id: 'auth-next-steps', + heading: 'Next steps', + description: + "Once you've covered the basics, these guides help with other use cases and features:", + type: 'grid', + columns: 4, + items: [ + { + title: 'Email (Magic link or OTP)', + href: '/guides/auth/auth-email-passwordless', + description: + 'Sign up and sign in users with a Magic Link or email OTP instead of a password.', + }, + { + title: 'Enterprise SSO', + href: '/guides/auth/enterprise-sso', + description: 'Add Single Sign-On for enterprise applications with SAML 2.0.', + }, + { + title: 'User sessions', + href: '/guides/auth/sessions', + description: 'Control session lifetime, refresh tokens, and multi-device sign-in behavior.', + }, + { + title: 'Third-party auth', + href: '/guides/auth/third-party/overview', + description: 'Use Clerk, Auth0, Firebase Auth, Cognito, or WorkOS JWTs with Supabase APIs.', + }, + { + title: 'Multi-factor authentication', + href: '/guides/auth/auth-mfa', + description: 'Add a second factor to user sign-in with TOTP or phone.', + }, + { + title: 'JWTs', + href: '/guides/auth/jwts', + description: 'Understand how Supabase Auth issues and validates JWTs.', + }, + { + title: 'Auth Hooks', + href: '/guides/auth/auth-hooks', + description: 'Customize Auth behavior with Postgres functions at key lifecycle points.', + }, + ], +} diff --git a/apps/docs/data/content-listings/database.data.ts b/apps/docs/data/content-listings/database.data.ts new file mode 100644 index 00000000000..32961aed8fb --- /dev/null +++ b/apps/docs/data/content-listings/database.data.ts @@ -0,0 +1,90 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const databaseGetStarted: ContentListingGroup = { + id: 'database-get-started', + heading: 'Get started', + description: "If you're new to the database section, these are the pages to read first:", + type: 'grid', + columns: 2, + items: [ + { + title: 'Connect to your database', + href: '/guides/database/connecting-to-postgres', + description: + 'Connection strings, the Supavisor connection pooler, and when to use direct, transaction, or session mode.', + }, + { + title: 'Tables and data', + href: '/guides/database/tables', + description: 'Create tables and relationships, and edit rows from the Dashboard.', + }, + { + title: 'Import data', + href: '/guides/database/import-data', + description: 'Load existing data from CSV files, `pg_dump`, or another Postgres database.', + }, + { + title: 'Secure your data', + href: '/guides/database/secure-data', + description: + 'Row Level Security (RLS) is how Supabase makes the database safe to query directly from the client. Read this before exposing any table to your app.', + }, + { + title: 'Extensions', + href: '/guides/database/extensions', + description: + 'Add Postgres extensions from the Dashboard, including `pgvector` for embeddings, `PostGIS` for geospatial data, and `pg_cron` for scheduled jobs.', + }, + { + title: 'Run SQL commands', + href: '/dashboard/project/_/sql', + description: "Use the Dashboard's SQL Editor for ad-hoc queries and saved snippets.", + }, + ], +} + +export const databaseNextSteps: ContentListingGroup = { + id: 'database-next-steps', + heading: 'Next steps', + description: + "Once you've covered the basics, these guides help with other use cases and features:", + type: 'grid', + items: [ + { + title: 'Database functions', + href: '/guides/database/functions', + description: 'Run logic inside the database in response to inserts, updates, or deletes.', + }, + { + title: 'Triggers', + href: '/guides/database/postgres/triggers', + description: 'Run logic inside the database in response to inserts, updates, or deletes.', + }, + { + title: 'Database webhooks', + href: '/guides/database/webhooks', + description: 'Send row changes to an external HTTP endpoint.', + }, + { + title: 'Replication and read replicas', + href: '/guides/database/replication', + description: 'Stream changes to other systems or read from a geographically closer replica.', + }, + { + title: 'Backups', + href: '/guides/platform/backups', + description: + 'Daily backups on every project, with point-in-time recovery on paid plans. Backups cover the database itself; objects stored through the Storage API are not included.', + }, + { + title: 'Query performance and optimization', + href: '/guides/database/query-optimization', + description: 'Indexes, the query planner, and tools for finding slow queries.', + }, + { + title: 'Roles and permissions', + href: '/guides/database/postgres/roles', + description: 'The Postgres roles Supabase ships with and how to add your own.', + }, + ], +} diff --git a/apps/docs/data/content-listings/functions.data.ts b/apps/docs/data/content-listings/functions.data.ts new file mode 100644 index 00000000000..bb69d870704 --- /dev/null +++ b/apps/docs/data/content-listings/functions.data.ts @@ -0,0 +1,211 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const functionsGetStarted: ContentListingGroup = { + id: 'functions-get-started', + heading: 'Get started', + type: 'grid', + columns: 2, + items: [ + { + title: 'Edge Functions quickstart', + href: '/guides/functions/quickstart', + description: 'Create, test, and deploy your first Edge Function with the Supabase CLI.', + }, + ], +} + +export const functionsExamplesSupabase: ContentListingGroup = { + id: 'functions-examples-supabase', + heading: 'Supabase integration', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'With supabase-js', + href: '/guides/functions/auth', + description: 'Use the Supabase client inside your Edge Function.', + }, + { + title: 'Connect to Postgres', + href: '/guides/functions/connect-to-postgres', + description: 'Connect to Postgres from Edge Functions.', + }, + { + title: 'Type-Safe SQL with Kysely', + href: '/guides/functions/kysely-postgres', + description: + 'Combine Kysely with Deno Postgres for a convenient developer experience when interacting directly with your Postgres database.', + }, + { + title: 'With CORS headers', + href: '/guides/functions/cors', + description: 'Send CORS headers for invoking from the browser.', + }, + { + title: 'Building a RESTful Service API', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/restful-tasks/index.ts', + icon: '/docs/img/icons/github-icon', + description: + 'Learn how to use HTTP methods and paths to build a RESTful service for managing tasks.', + }, + { + title: 'Oak Server Middleware', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/oak-server', + icon: '/docs/img/icons/github-icon', + description: 'Route requests with Oak server middleware.', + }, + { + title: 'Web Stream', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/streams', + icon: '/docs/img/icons/github-icon', + description: 'Stream Server-Sent Events from Edge Functions.', + }, + { + title: 'Get User Location', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/location', + icon: '/docs/img/icons/github-icon', + description: "Get user location data from user's IP address.", + }, + { + title: 'Working with Supabase Storage', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/read-storage/index.ts', + icon: '/docs/img/icons/github-icon', + description: 'Read a file from Supabase Storage.', + }, + { + title: 'Upload File', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/file-upload-storage', + icon: '/docs/img/icons/github-icon', + description: 'Process multipart/form-data.', + }, + ], +} + +export const functionsExamplesWebhooksPayments: ContentListingGroup = { + id: 'functions-examples-webhooks-payments', + heading: 'Webhooks & payments', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Stripe Webhooks', + href: '/guides/functions/examples/stripe-webhooks', + description: 'Handle signed Stripe webhooks with Edge Functions.', + }, + { + title: 'React Native with Stripe', + href: 'https://github.com/supabase-community/expo-stripe-payments-with-supabase-functions', + icon: '/docs/img/icons/github-icon', + description: 'Use Supabase and Stripe in a React Native app with Expo.', + }, + { + title: 'Flutter with Stripe', + href: 'https://github.com/supabase-community/flutter-stripe-payments-with-supabase-functions', + icon: '/docs/img/icons/github-icon', + description: 'Use Supabase and Stripe in a Flutter app.', + }, + ], +} + +export const functionsExamplesAiMedia: ContentListingGroup = { + id: 'functions-examples-ai-media', + heading: 'AI & media', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Hugging Face', + href: '/guides/ai/examples/huggingface-image-captioning', + description: 'Access 100,000+ Machine Learning models.', + }, + { + title: 'OpenAI', + href: '/guides/ai/examples/openai', + description: 'Use OpenAI in Edge Functions.', + }, + { + title: 'Amazon Bedrock', + href: '/guides/functions/examples/amazon-bedrock-image-generator', + description: 'Generate images with Amazon Bedrock in Edge Functions.', + }, + { + title: 'Open Graph Image Generation', + href: '/guides/functions/examples/og-image', + description: 'Generate Open Graph images with Deno and Supabase Edge Functions.', + }, + { + title: 'OG Image Generation & Storage CDN Caching', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/og-image-with-storage-cdn', + icon: '/docs/img/icons/github-icon', + description: 'Cache generated images with Supabase Storage CDN.', + }, + { + title: 'Puppeteer', + href: '/guides/functions/examples/screenshots', + description: 'Generate screenshots with Puppeteer.', + }, + ], +} + +export const functionsExamplesMessaging: ContentListingGroup = { + id: 'functions-examples-messaging', + heading: 'Bots & email', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Send emails', + href: '/guides/functions/examples/send-emails', + description: 'Send emails in Edge Functions with Resend.', + }, + { + title: 'Discord Bot', + href: '/guides/functions/examples/discord-bot', + description: 'Build a slash command Discord bot with Edge Functions.', + }, + { + title: 'Telegram Bot', + href: '/guides/functions/examples/telegram-bot', + description: 'Build a Telegram bot with Edge Functions.', + }, + { + title: 'Slack Bot Mention Edge Function', + href: '/guides/functions/examples/slack-bot-mention', + description: 'Handle Slack mentions in a Slack bot Edge Function.', + }, + ], +} + +export const functionsExamplesOperations: ContentListingGroup = { + id: 'functions-examples-operations', + heading: 'Operations & security', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Monitoring with Sentry', + href: '/guides/functions/examples/sentry-monitoring', + description: 'Monitor Edge Functions with the Sentry Deno SDK.', + }, + { + title: 'GitHub Actions', + href: '/guides/functions/examples/github-actions', + description: 'Deploy Edge Functions with GitHub Actions.', + }, + { + title: 'Upstash Redis', + href: '/guides/functions/examples/upstash-redis', + description: 'Build an Edge Functions Counter with Upstash Redis.', + }, + { + title: 'Rate Limiting', + href: '/guides/functions/examples/rate-limiting', + description: 'Rate-limit Edge Functions with Upstash Redis.', + }, + { + title: 'Cloudflare Turnstile', + href: '/guides/functions/examples/cloudflare-turnstile', + description: 'Protect forms with Cloudflare Turnstile.', + }, + ], +} diff --git a/apps/docs/data/content-listings/getting-started.data.ts b/apps/docs/data/content-listings/getting-started.data.ts new file mode 100644 index 00000000000..33d36e08e8b --- /dev/null +++ b/apps/docs/data/content-listings/getting-started.data.ts @@ -0,0 +1,23 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const gettingStartedGetStarted: ContentListingGroup = { + id: 'getting-started-overview', + type: 'grid', + items: [ + { + title: 'Build with AI tools', + href: '/guides/ai-tools', + description: 'Develop with Supabase AI-first using plugins, MCP, and skills.', + }, + { + title: 'API Keys', + href: '/guides/getting-started/api-keys', + description: 'Learn about the different API keys in Supabase and how to use them.', + }, + { + title: 'Local Development', + href: '/guides/local-development', + description: 'Use the Supabase CLI to develop locally and collaborate between teams.', + }, + ], +} diff --git a/apps/docs/data/content-listings/index.ts b/apps/docs/data/content-listings/index.ts new file mode 100644 index 00000000000..b5ac8a84d09 --- /dev/null +++ b/apps/docs/data/content-listings/index.ts @@ -0,0 +1,40 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +import { authGetStarted, authNextSteps, authPricing } from './auth.data' +import { databaseGetStarted, databaseNextSteps } from './database.data' +import { + functionsExamplesAiMedia, + functionsExamplesMessaging, + functionsExamplesOperations, + functionsExamplesSupabase, + functionsExamplesWebhooksPayments, + functionsGetStarted, +} from './functions.data' +import { gettingStartedGetStarted } from './getting-started.data' +import { realtimeExamples, realtimeGetStarted, realtimeResources } from './realtime.data' +import { storageExamples, storageGetStarted, storageResources } from './storage.data' + +const ALL_GROUPS: readonly ContentListingGroup[] = [ + authGetStarted, + authPricing, + authNextSteps, + databaseGetStarted, + databaseNextSteps, + functionsGetStarted, + functionsExamplesSupabase, + functionsExamplesWebhooksPayments, + functionsExamplesAiMedia, + functionsExamplesMessaging, + functionsExamplesOperations, + gettingStartedGetStarted, + realtimeGetStarted, + realtimeExamples, + realtimeResources, + storageGetStarted, + storageExamples, + storageResources, +] + +export const CONTENT_LISTINGS: Readonly> = Object.fromEntries( + ALL_GROUPS.map((group) => [group.id, group]) +) diff --git a/apps/docs/data/content-listings/realtime.data.ts b/apps/docs/data/content-listings/realtime.data.ts new file mode 100644 index 00000000000..0e0e85b2270 --- /dev/null +++ b/apps/docs/data/content-listings/realtime.data.ts @@ -0,0 +1,66 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const realtimeGetStarted: ContentListingGroup = { + id: 'realtime-get-started', + heading: 'Get started', + type: 'grid', + columns: 2, + items: [ + { + title: 'Getting Started', + href: '/guides/realtime/getting_started', + description: 'Set up Realtime in your project and send your first message.', + }, + ], +} + +export const realtimeExamples: ContentListingGroup = { + id: 'realtime-examples', + heading: 'Examples', + type: 'grid', + columns: 2, + items: [ + { + title: 'Multiplayer.dev', + href: 'https://multiplayer.dev', + description: + 'Showcase application displaying cursor movements and chat messages using Broadcast.', + }, + { + title: 'Chat', + href: 'https://supabase.com/ui/docs/nextjs/realtime-chat', + description: 'Supabase UI chat component using Broadcast to send message between users.', + }, + { + title: 'Avatar Stack', + href: 'https://supabase.com/ui/docs/nextjs/realtime-avatar-stack', + description: 'Supabase UI avatar stack component using Presence to track connected users.', + }, + { + title: 'Realtime Cursor', + href: 'https://supabase.com/ui/docs/nextjs/realtime-cursor', + description: + "Supabase UI realtime cursor component using Broadcast to share users' cursors to build collaborative applications.", + }, + ], +} + +export const realtimeResources: ContentListingGroup = { + id: 'realtime-resources', + heading: 'Resources', + description: 'Find the source code and documentation in the Supabase GitHub repository:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Supabase Realtime', + href: 'https://github.com/supabase/realtime', + description: 'View the source code.', + }, + { + title: 'Realtime: Multiplayer Edition', + href: 'https://supabase.com/blog/supabase-realtime-multiplayer-general-availability', + description: 'Read more about Supabase Realtime.', + }, + ], +} diff --git a/apps/docs/data/content-listings/storage.data.ts b/apps/docs/data/content-listings/storage.data.ts new file mode 100644 index 00000000000..9180ea08d49 --- /dev/null +++ b/apps/docs/data/content-listings/storage.data.ts @@ -0,0 +1,72 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const storageGetStarted: ContentListingGroup = { + id: 'storage-get-started', + heading: 'Get started', + description: 'Choose the bucket type that fits your use case:', + type: 'grid', + items: [ + { + title: 'Files buckets', + href: '/guides/storage/quickstart', + description: + 'Store and serve images, videos, documents, and general-purpose files with direct URL access and row-level security.', + }, + { + title: 'Analytics buckets', + href: '/guides/storage/analytics/introduction', + description: + 'Store data in Apache Iceberg tables for data lakes, logs, and Supabase Pipelines. Query from Postgres via foreign tables with partitioning.', + }, + { + title: 'Vector buckets', + href: '/guides/storage/vector/introduction', + description: + 'Store embeddings and run similarity search for semantic matching, AI, and RAG. Use HNSW indexing, distance metrics, and metadata filtering.', + }, + ], +} + +export const storageExamples: ContentListingGroup = { + id: 'storage-examples', + heading: 'Examples', + description: 'Working sample projects for common Storage integration patterns:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Storage templates and examples', + href: 'https://github.com/supabase/supabase/tree/master/examples/storage', + description: + 'Sample projects for resumable uploads, signed upload URLs, and serving map tiles from private buckets.', + }, + { + title: 'Resumable Uploads with Uppy', + href: 'https://github.com/supabase/supabase/tree/master/examples/storage/resumable-upload-uppy', + icon: '/docs/img/icons/github-icon', + description: + 'Upload large files with pause-and-resume support using Uppy and the TUS protocol.', + }, + ], +} + +export const storageResources: ContentListingGroup = { + id: 'storage-resources', + heading: 'Resources', + description: 'Source code and REST API reference for the Storage service:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Supabase Storage API', + href: 'https://github.com/supabase/storage-api', + description: 'Amazon S3-compatible object storage service that stores metadata in Postgres.', + }, + { + title: 'OpenAPI Spec', + href: 'https://supabase.github.io/storage/', + description: + 'Interactive reference for Storage REST endpoints, request parameters, and response schemas.', + }, + ], +} diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index b47ebf114e8..9df8413a927 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -5,6 +5,7 @@ import AuthProviders from '~/components/AuthProviders' import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig' import ButtonCard from '~/components/ButtonCard' import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable' +import { ContentListings } from '~/components/ContentListings' import { Extensions } from '~/components/Extensions' import Image, { type ImageProps } from '~/components/Image' import { Mermaid } from '~/components/Mermaid' @@ -26,6 +27,7 @@ import { ShowUntil } from '~/features/ui/ShowUntil' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { ArrowDown, Check, X } from 'lucide-react' import Link from 'next/link' +import { type ComponentPropsWithoutRef } from 'react' import { Badge, Button } from 'ui' import { Admonition, type AdmonitionProps } from 'ui-patterns/admonition' import { GlassPanel } from 'ui-patterns/GlassPanel' @@ -74,6 +76,7 @@ const components = { CodeSampleDummy, CodeSampleWrapper, ComputeDiskLimitsTable, + ContentListings, ErrorCodes, Extensions, GlassPanel, @@ -100,17 +103,17 @@ const components = { TabPanel, InfoTooltip, a: MdxAnchor, - h2: (props: any) => ( + h2: (props: ComponentPropsWithoutRef<'h2'>) => ( {props.children} ), - h3: (props: any) => ( + h3: (props: ComponentPropsWithoutRef<'h3'>) => ( {props.children} ), - h4: (props: any) => ( + h4: (props: ComponentPropsWithoutRef<'h4'>) => ( {props.children} diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index f2a378915b0..462c0c3d969 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -17,6 +17,7 @@ import { AuthProviders } from './markdown-schema/AuthProviders' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' import { ErrorCodes } from './markdown-schema/ErrorCodes' import { Link } from './markdown-schema/Link' +import { ContentListings } from './markdown-schema/ContentListings' import { MetricsStackCards } from './markdown-schema/MetricsStackCards' import { NavData } from './markdown-schema/NavData' import { Panel } from './markdown-schema/Panel' @@ -152,6 +153,7 @@ const SCHEMA: ComponentSchema = { ...StepHike, TabPanel, MetricsStackCards, + ContentListings, NavData, SharedData, } @@ -178,7 +180,9 @@ async function transformBody(content: string, data: Record): Pr const header = headerParts.join('\n\n') - return header ? `${header}\n\n${body}` : body + let output = header ? `${header}\n\n${body}` : body + + return output } async function generateOne(sourceFile: string, frontmatter: FrontmatterFormat): Promise { diff --git a/apps/docs/internals/markdown-schema/ContentListings.ts b/apps/docs/internals/markdown-schema/ContentListings.ts new file mode 100644 index 00000000000..299b23c06ec --- /dev/null +++ b/apps/docs/internals/markdown-schema/ContentListings.ts @@ -0,0 +1,51 @@ +import { withDocsBasePath } from '~/internals/internal-links' +import type { ContentListingGroup } from '~/lib/content-listings.schema' +import { getContentListingById, isExternalContentListingHref } from '~/lib/content-listings.utils' + +import { getInternalLinkBaseUrl } from '../internal-links' + +const HEADING_MARKDOWN: Record<'h2' | 'h3' | 'h4', string> = { + h2: '##', + h3: '###', + h4: '####', +} + +export function serializeContentListingGroupToMarkdown( + group: ContentListingGroup, + linkBaseUrl: string +): string { + const lines: string[] = [] + if (group.heading) { + const level = group.headingLevel ?? 'h2' + lines.push(`${HEADING_MARKDOWN[level]} ${group.heading}`) + lines.push('') + } + + if (group.description) { + lines.push(group.description) + lines.push('') + } + + for (const item of group.items) { + const href = isExternalContentListingHref(item.href) + ? item.href + : `${linkBaseUrl}${withDocsBasePath(item.href)}` + lines.push(`- **[${item.title}](${href}):** ${item.description}`) + } + + return lines.join('\n') +} + +/** + * Markdown export handler for ``. Looks up the + * group by id in the same data registry the React component uses. + */ +export const ContentListings = ({ props }: { props: Record }): string => { + const id = typeof props.id === 'string' ? props.id : '' + if (!id) return '' + + const group = getContentListingById(id) + if (!group) return '' + + return serializeContentListingGroupToMarkdown(group, getInternalLinkBaseUrl()) +} diff --git a/apps/docs/lib/content-listings.schema.ts b/apps/docs/lib/content-listings.schema.ts new file mode 100644 index 00000000000..df48028bf00 --- /dev/null +++ b/apps/docs/lib/content-listings.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +import { + contentListingGridColumnsSchema, + contentListingGroupSchema, + contentListingGroupTypeSchema, + contentListingHeadingLevelSchema, + contentListingItemSchema, +} from './content-listings.zod.mjs' + +export { + contentListingGridColumnsSchema, + contentListingGroupSchema, + contentListingGroupTypeSchema, + contentListingHeadingLevelSchema, + contentListingItemSchema, +} + +export type ContentListingItem = z.infer +export type ContentListingGroup = z.infer diff --git a/apps/docs/lib/content-listings.test.ts b/apps/docs/lib/content-listings.test.ts new file mode 100644 index 00000000000..61a067ad726 --- /dev/null +++ b/apps/docs/lib/content-listings.test.ts @@ -0,0 +1,166 @@ +import { storageGetStarted } from '~/data/content-listings/storage.data' +import { + ContentListings as ContentListingsMarkdownHandler, + serializeContentListingGroupToMarkdown, +} from '~/internals/markdown-schema/ContentListings' +import { isExternalContentListingHref } from '~/lib/content-listings.utils' +import { describe, expect, it } from 'vitest' + +describe('isExternalContentListingHref', () => { + it('treats protocol-relative URLs as external', () => { + expect(isExternalContentListingHref('//example.com/path')).toBe(true) + }) + + it('treats absolute http(s) URLs as external', () => { + expect(isExternalContentListingHref('https://github.com/supabase/storage-api')).toBe(true) + }) + + it('treats internal guide paths as not external', () => { + expect(isExternalContentListingHref('/guides/storage/quickstart')).toBe(false) + }) +}) + +describe('serializeContentListingGroupToMarkdown', () => { + it('renders grouped items with absolute URLs and descriptions', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + description: 'Read these first.', + items: [ + { + title: 'Connect to your database', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings and pooler modes.', + }, + ], + }, + 'https://supabase.com' + ) + + expect(markdown).toContain('## Get started') + expect(markdown).toContain('Read these first.') + expect(markdown).toContain( + '**[Connect to your database](https://supabase.com/docs/guides/database/connecting-to-postgres):** Connection strings and pooler modes.' + ) + }) + + it('preserves external hrefs in markdown export', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'resources', + items: [ + { + title: 'Storage API', + href: 'https://github.com/supabase/storage-api', + description: 'View the source code.', + }, + ], + }, + 'https://supabase.com' + ) + + expect(markdown).toContain( + '**[Storage API](https://github.com/supabase/storage-api):** View the source code.' + ) + }) + + it('respects heading-level in markdown export', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + headingLevel: 'h3', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown).toContain('### Get started') + }) + + it('renders heading and description only once', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + description: 'Read these first.', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown.match(/^## Get started$/gm)).toHaveLength(1) + expect(markdown.match(/^Read these first\.$/gm)).toHaveLength(1) + expect(markdown).toBe( + '## Get started\n\nRead these first.\n\n- **[Connect](/docs/guides/database/connecting-to-postgres):** Connection strings.' + ) + }) + + it('omits heading line when heading is not set', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown).not.toMatch(/^#+\s/m) + expect(markdown).toContain('**[Connect]') + }) +}) + +describe('ContentListings markdown handler', () => { + it('serializes the group looked up by id', () => { + const markdown = ContentListingsMarkdownHandler({ props: { id: 'storage-get-started' } }) + + expect(markdown).toContain('## Get started') + expect(markdown).toContain('Files buckets') + expect(markdown).toContain('/guides/storage/quickstart') + }) + + it('returns empty string when id is missing or unknown', () => { + expect(ContentListingsMarkdownHandler({ props: {} })).toBe('') + expect(ContentListingsMarkdownHandler({ props: { id: 'nonexistent-id' } })).toBe('') + }) + + it('matches direct serializeContentListingGroupToMarkdown for the same data', () => { + const handler = ContentListingsMarkdownHandler({ props: { id: 'storage-get-started' } }) + const direct = serializeContentListingGroupToMarkdown(storageGetStarted, '') + expect(handler).toBe(direct) + }) +}) + +describe('TelemetryEvent union', () => { + it('includes docs_content_listing_clicked', () => { + const event = { + action: 'docs_content_listing_clicked' as const, + properties: { + targetPath: '/guides/storage', + linkTitle: 'Storage', + }, + } + + const _typeCheck: import('common/telemetry-constants').TelemetryEvent = event + expect(_typeCheck.action).toBe('docs_content_listing_clicked') + }) +}) diff --git a/apps/docs/lib/content-listings.utils.ts b/apps/docs/lib/content-listings.utils.ts new file mode 100644 index 00000000000..10c73b4d4f1 --- /dev/null +++ b/apps/docs/lib/content-listings.utils.ts @@ -0,0 +1,16 @@ +import { CONTENT_LISTINGS } from '~/data/content-listings' + +import type { ContentListingGroup } from './content-listings.schema' + +/** Label for telemetry — prefers heading, falls back to id. */ +export function getContentListingGroupLabel(group: ContentListingGroup): string { + return group.heading ?? group.id +} + +export function isExternalContentListingHref(href: string): boolean { + return /^https?:\/\//i.test(href) || href.startsWith('//') +} + +export function getContentListingById(id: string): ContentListingGroup | undefined { + return CONTENT_LISTINGS[id] +} diff --git a/apps/docs/lib/content-listings.zod.mjs b/apps/docs/lib/content-listings.zod.mjs new file mode 100644 index 00000000000..4480879288e --- /dev/null +++ b/apps/docs/lib/content-listings.zod.mjs @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const contentListingItemSchema = z.object({ + title: z.string().min(1), + href: z.string().min(1), + description: z.string().min(1), + icon: z.string().min(1).optional(), +}) + +export const contentListingGroupTypeSchema = z.enum(['list', 'grid']) + +export const contentListingGridColumnsSchema = z.union([z.literal(2), z.literal(3), z.literal(4)]) + +export const contentListingHeadingLevelSchema = z.enum(['h2', 'h3', 'h4']) + +export const contentListingGroupSchema = z.object({ + id: z.string().min(1), + heading: z.string().min(1).optional(), + headingLevel: contentListingHeadingLevelSchema.optional(), + description: z.string().optional(), + type: contentListingGroupTypeSchema.optional(), + columns: contentListingGridColumnsSchema.optional(), + items: z.array(contentListingItemSchema).min(1), +}) diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index fee4d647002..6034b84443f 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -892,6 +892,22 @@ export interface AskAiClickedEvent { } } +/** + * User clicked a curated orientation link from a content listings MDX component. + * + * @group Events + * @source docs + */ +export interface DocsContentListingClickedEvent { + action: 'docs_content_listing_clicked' + properties: { + targetPath: string + linkTitle: string + groupTitle?: string + listingId?: string + } +} + /** * User clicked a recommended page card shown on a docs "not found" page. * @@ -3553,6 +3569,7 @@ export type TelemetryEvent = | DocsFeedbackClickedEvent | CopyAsMarkdownClickedEvent | AskAiClickedEvent + | DocsContentListingClickedEvent | Docs404RecommendationClickedEvent | HomepageFrameworkQuickstartClickedEvent | HomepageProductCardClickedEvent