diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml
index 7d402e444b4..9f89a86c660 100644
--- a/.github/workflows/docs-tests.yml
+++ b/.github/workflows/docs-tests.yml
@@ -20,6 +20,7 @@ jobs:
with:
sparse-checkout: |
apps/docs
+ examples
packages
- name: Use Node.js
diff --git a/apps/docs/app/contributing/content.mdx b/apps/docs/app/contributing/content.mdx
index cbee66c3616..12cba239f87 100644
--- a/apps/docs/app/contributing/content.mdx
+++ b/apps/docs/app/contributing/content.mdx
@@ -157,6 +157,46 @@ Additional helpful information.
+### Code Samples
+
+You can include code samples as normal in Markdown:
+
+````mdx
+```js
+const PI = 3.14
+```
+````
+
+Of, you can use the `<$CodeSample />` component to include code samples from a source code file.
+
+If the file is within the `supabase/supabase` repo's `examples` directory:
+
+```mdx
+<$CodeSample
+path="/relative/path/from/examples/directory.js"
+{/_ Array of [start, end] line numbers to include.
+Line numbers are 1-indexed and inclusive.
+-1 indicates the final line. _/}
+lines={[[1, 3], [5, -1]]}
+{/* Optional, displays as a file name on the code block */}
+meta="display/path.js"
+/>
+```
+
+If the file is within some other GitHub repo (note that the repo must be public):
+
+```mdx
+<$CodeSample
+external={true}
+org="supabase"
+repo="cli"
+commit="1623aa9b95ec90e21c5bae5a0d50dcf272abe92f"
+path="/relative/path/from/root.js"
+lines={[[1, 3], [5, -1]]}
+meta="display/path.js"
+/>
+```
+
### Icons
The following icons are available. They can be styled with [Tailwind](https://tailwindcss.com/) classes:
diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
index 42f1e837abe..a6655eddebe 100644
--- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
+++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
@@ -154,53 +154,21 @@ export function createClient() {
+Create a `client.ts` and a `server.ts` with the following functionalities for client-side Supabase and server-side Supabase, respectively.
+
-```tsx utils/supabase/client.ts
-import { createBrowserClient } from '@supabase/ssr'
+<$CodeSample
+path="/user-management/nextjs-user-management/utils/supabase/client.ts"
+lines={[[1, -1]]}
+meta="utils/supabase/client.ts"
+/>
-export function createClient() {
- // Create a supabase client on the browser with project's credentials
- return createBrowserClient(
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
- )
-}
-```
-
-```tsx utils/supabase/server.ts
-import { createServerClient, type CookieOptions } from '@supabase/ssr'
-import { cookies } from 'next/headers'
-
-export function createClient() {
- const cookieStore = cookies()
-
- // Create a server's supabase client with newly configured cookie,
- // which could be used to maintain user's session
- return createServerClient(
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
- {
- cookies: {
- getAll() {
- return cookieStore.getAll()
- },
- setAll(cookiesToSet) {
- try {
- cookiesToSet.forEach(({ name, value, options }) =>
- cookieStore.set(name, value, options)
- )
- } catch {
- // The `setAll` method was called from a Server Component.
- // This can be ignored if you have middleware refreshing
- // user sessions.
- }
- },
- },
- }
- )
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/utils/supabase/server.ts"
+lines={[[1, -1]]}
+meta="utils/supabase/server.ts"
+/>
@@ -313,65 +281,17 @@ Create a `middleware.ts` file at the project root and another one within the `ut
-```tsx middleware.ts
-import { type NextRequest } from 'next/server'
-import { updateSession } from '@/utils/supabase/middleware'
+<$CodeSample
+path="/user-management/nextjs-user-management/middleware.ts"
+lines={[[1, -1]]}
+meta="middleware.ts"
+/>
-export async function middleware(request: NextRequest) {
- // update user's auth session
- return await updateSession(request)
-}
-
-export const config = {
- matcher: [
- /*
- * Match all request paths except for the ones starting with:
- * - _next/static (static files)
- * - _next/image (image optimization files)
- * - favicon.ico (favicon file)
- * Feel free to modify this pattern to include more paths.
- */
- '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
- ],
-}
-```
-
-```tsx utils/supabase/middleware.ts
-import { createServerClient, type CookieOptions } from '@supabase/ssr'
-import { NextResponse, type NextRequest } from 'next/server'
-
-export async function updateSession(request: NextRequest) {
- let supabaseResponse = NextResponse.next({
- request,
- })
-
- const supabase = createServerClient(
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
- {
- cookies: {
- getAll() {
- return request.cookies.getAll()
- },
- setAll(cookiesToSet) {
- cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
- supabaseResponse = NextResponse.next({
- request,
- })
- cookiesToSet.forEach(({ name, value, options }) =>
- supabaseResponse.cookies.set(name, value, options)
- )
- },
- },
- }
- )
-
- // refreshing the auth token
- await supabase.auth.getUser()
-
- return supabaseResponse
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/utils/supabase/middleware.ts"
+lines={[[1, -1]]}
+meta="utils/supabase/middleware.ts"
+/>
@@ -420,22 +340,11 @@ export default function LoginPage() {
Create a new folder named `login`, containing a `page.tsx` file with a login/signup form.
-```tsx app/login/page.tsx
-import { login, signup } from './actions'
-
-export default function LoginPage() {
- return (
-
- )
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/login/page.tsx"
+lines={[[1, -1]]}
+meta="app/login/page.tsx"
+/>
@@ -528,60 +437,17 @@ export default function ErrorPage() {
-```ts app/login/actions.ts
-'use server'
+<$CodeSample
+path="/user-management/nextjs-user-management/app/login/actions.ts"
+lines={[[1, -1]]}
+meta="app/login/actions.ts"
+/>
-import { revalidatePath } from 'next/cache'
-import { redirect } from 'next/navigation'
-
-import { createClient } from '@/utils/supabase/server'
-
-export async function login(formData: FormData) {
- const supabase = createClient()
-
- // type-casting here for convenience
- // in practice, you should validate your inputs
- const data = {
- email: formData.get('email') as string,
- password: formData.get('password') as string,
- }
-
- const { error } = await supabase.auth.signInWithPassword(data)
-
- if (error) {
- redirect('/error')
- }
-
- revalidatePath('/', 'layout')
- redirect('/account')
-}
-
-export async function signup(formData: FormData) {
- const supabase = createClient()
-
- // type-casting here for convenience
- // in practice, you should validate your inputs
- const data = {
- email: formData.get('email') as string,
- password: formData.get('password') as string,
- }
-
- const { error } = await supabase.auth.signUp(data)
-
- if (error) {
- redirect('/error')
- }
-
- revalidatePath('/', 'layout')
- redirect('/account')
-}
-```
-
-```tsx app/error/page.tsx
-export default function ErrorPage() {
- return Sorry, something went wrong
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/error/page.tsx"
+lines={[[1, -1]]}
+meta="app/error/page.tsx"
+/>
@@ -668,43 +534,11 @@ export async function GET(request) {
-```ts app/auth/confirm/route.ts
-import { type EmailOtpType } from '@supabase/supabase-js'
-import { type NextRequest, NextResponse } from 'next/server'
-
-import { createClient } from '@/utils/supabase/server'
-
-// Creating a handler to a GET request to route /auth/confirm
-export async function GET(request: NextRequest) {
- const { searchParams } = new URL(request.url)
- const token_hash = searchParams.get('token_hash')
- const type = searchParams.get('type') as EmailOtpType | null
- const next = '/account'
-
- // Create redirect link without the secret token
- const redirectTo = request.nextUrl.clone()
- redirectTo.pathname = next
- redirectTo.searchParams.delete('token_hash')
- redirectTo.searchParams.delete('type')
-
- if (token_hash && type) {
- const supabase = createClient()
-
- const { error } = await supabase.auth.verifyOtp({
- type,
- token_hash,
- })
- if (!error) {
- redirectTo.searchParams.delete('next')
- return NextResponse.redirect(redirectTo)
- }
- }
-
- // return the user to an error page with some instructions
- redirectTo.pathname = '/error'
- return NextResponse.redirect(redirectTo)
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/auth/confirm/route.ts"
+lines={[[1, -1]]}
+meta="app/auth/confirm/route.ts"
+/>
@@ -857,137 +691,11 @@ export default function AccountForm({ user }) {
-```tsx app/account/account-form.tsx
-'use client'
-import { useCallback, useEffect, useState } from 'react'
-import { createClient } from '@/utils/supabase/client'
-import { type User } from '@supabase/supabase-js'
-
-export default function AccountForm({ user }: { user: User | null }) {
- const supabase = createClient()
- const [loading, setLoading] = useState(true)
- const [fullname, setFullname] = useState(null)
- const [username, setUsername] = useState(null)
- const [website, setWebsite] = useState(null)
- const [avatar_url, setAvatarUrl] = useState(null)
-
- const getProfile = useCallback(async () => {
- try {
- setLoading(true)
-
- const { data, error, status } = await supabase
- .from('profiles')
- .select(`full_name, username, website, avatar_url`)
- .eq('id', user?.id)
- .single()
-
- if (error && status !== 406) {
- console.log(error)
- throw error
- }
-
- if (data) {
- setFullname(data.full_name)
- setUsername(data.username)
- setWebsite(data.website)
- setAvatarUrl(data.avatar_url)
- }
- } catch (error) {
- alert('Error loading user data!')
- } finally {
- setLoading(false)
- }
- }, [user, supabase])
-
- useEffect(() => {
- getProfile()
- }, [user, getProfile])
-
- async function updateProfile({
- username,
- website,
- avatar_url,
- }: {
- username: string | null
- fullname: string | null
- website: string | null
- avatar_url: string | null
- }) {
- try {
- setLoading(true)
-
- const { error } = await supabase.from('profiles').upsert({
- id: user?.id as string,
- full_name: fullname,
- username,
- website,
- avatar_url,
- updated_at: new Date().toISOString(),
- })
- if (error) throw error
- alert('Profile updated!')
- } catch (error) {
- alert('Error updating the data!')
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
-
-
-
-
-
- setFullname(e.target.value)}
- />
-
-
-
- setUsername(e.target.value)}
- />
-
-
-
- setWebsite(e.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
- )
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/account/account-form.tsx"
+lines={[[1, 4], [7, 78], [88, -1]]}
+meta="app/account/account-form.tsx"
+/>
@@ -1026,20 +734,11 @@ export default async function Account() {
-```tsx app/account/page.tsx
-import AccountForm from './account-form'
-import { createClient } from '@/utils/supabase/server'
-
-export default async function Account() {
- const supabase = createClient()
-
- const {
- data: { user },
- } = await supabase.auth.getUser()
-
- return
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/account/page.tsx"
+lines={[[1, -1]]}
+meta="app/account/page.tsx"
+/>
@@ -1086,29 +785,11 @@ export async function POST(req) {
-```ts app/auth/signout/route.ts
-import { createClient } from '@/utils/supabase/server'
-import { revalidatePath } from 'next/cache'
-import { type NextRequest, NextResponse } from 'next/server'
-
-export async function POST(req: NextRequest) {
- const supabase = createClient()
-
- // Check if a user's logged in
- const {
- data: { user },
- } = await supabase.auth.getUser()
-
- if (user) {
- await supabase.auth.signOut()
- }
-
- revalidatePath('/', 'layout')
- return NextResponse.redirect(new URL('/login', req.url), {
- status: 302,
- })
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/auth/signout/route.ts"
+lines={[[1, -1]]}
+meta="app/auth/signout/route.ts"
+/>
@@ -1237,105 +918,11 @@ export default function Avatar({ uid, url, size, onUpload }) {
-```tsx app/account/avatar.tsx
-'use client'
-import React, { useEffect, useState } from 'react'
-import { createClient } from '@/utils/supabase/client'
-import Image from 'next/image'
-
-export default function Avatar({
- uid,
- url,
- size,
- onUpload,
-}: {
- uid: string | null
- url: string | null
- size: number
- onUpload: (url: string) => void
-}) {
- const supabase = createClient()
- const [avatarUrl, setAvatarUrl] = useState(url)
- const [uploading, setUploading] = useState(false)
-
- useEffect(() => {
- async function downloadImage(path: string) {
- try {
- const { data, error } = await supabase.storage.from('avatars').download(path)
- if (error) {
- throw error
- }
-
- const url = URL.createObjectURL(data)
- setAvatarUrl(url)
- } catch (error) {
- console.log('Error downloading image: ', error)
- }
- }
-
- if (url) downloadImage(url)
- }, [url, supabase])
-
- const uploadAvatar: React.ChangeEventHandler = async (event) => {
- try {
- setUploading(true)
-
- if (!event.target.files || event.target.files.length === 0) {
- throw new Error('You must select an image to upload.')
- }
-
- const file = event.target.files[0]
- const fileExt = file.name.split('.').pop()
- const filePath = `${uid}-${Math.random()}.${fileExt}`
-
- const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
-
- if (uploadError) {
- throw uploadError
- }
-
- onUpload(filePath)
- } catch (error) {
- alert('Error uploading avatar!')
- } finally {
- setUploading(false)
- }
- }
-
- return (
-
- {avatarUrl ? (
-
- ) : (
-
- )}
-
-
-
-
-
- )
-}
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/account/avatar.tsx"
+lines={[[1, -1]]}
+meta="app/account/avatar.tsx"
+/>
@@ -1382,28 +969,11 @@ return (
-```tsx app/account/account-form.tsx
-// Import the new component
-import Avatar from './avatar'
-
-// ...
-
-return (
-
- {/* Add to the body */}
-
{
- setAvatarUrl(url)
- updateProfile({ fullname, username, website, avatar_url: url })
- }}
- />
- {/* ... */}
-
-)
-```
+<$CodeSample
+path="/user-management/nextjs-user-management/app/account/account-form.tsx"
+lines={[[5, 5], [77, 87], [137, -1]]}
+meta="app/account/account-form.tsx"
+/>
diff --git a/apps/docs/features/directives/CodeSample.client.tsx b/apps/docs/features/directives/CodeSample.client.tsx
new file mode 100644
index 00000000000..8cde6cf1ae9
--- /dev/null
+++ b/apps/docs/features/directives/CodeSample.client.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import Link from 'next/link'
+import { useState, type PropsWithChildren } from 'react'
+
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui'
+
+export function CodeSampleWrapper({
+ children,
+ /**
+ * A GitHub URL to the source code file.
+ */
+ source: _source,
+}: PropsWithChildren<{ source: string | URL | (string | URL)[] }>) {
+ const source = Array.isArray(_source) ? _source : [_source]
+
+ if (source.length === 1) {
+ return {children}
+ }
+
+ if (source.length > 1) {
+ return {children}
+ }
+
+ return <>{children}>
+}
+
+function MultipleSources({ children, sources }: PropsWithChildren<{ sources: (string | URL)[] }>) {
+ return (
+ <>
+ {children}
+
+
+
+
+
+ {sources.map((source) => (
+ window.open(source.toString(), '_blank', 'noopener noreferrer')}
+ >
+ ...{source.toString().split('/').slice(-2).join('/')}
+
+ ))}
+
+
+ >
+ )
+}
+
+function SingleSource({ children, source }: PropsWithChildren<{ source: string | URL }>) {
+ return (
+ <>
+ {children}
+
+ View source
+
+ >
+ )
+}
diff --git a/apps/docs/features/directives/CodeSample.test.ts b/apps/docs/features/directives/CodeSample.test.ts
new file mode 100644
index 00000000000..65dd9bdb26d
--- /dev/null
+++ b/apps/docs/features/directives/CodeSample.test.ts
@@ -0,0 +1,576 @@
+import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'
+
+import { fromMarkdown } from 'mdast-util-from-markdown'
+import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
+import { toMarkdown } from 'mdast-util-to-markdown'
+import { mdxjs } from 'micromark-extension-mdxjs'
+
+import { _createElidedLine, codeSampleRemark } from './CodeSample'
+
+const fetchFromGitHubMock = vi.fn((_params) => Promise.resolve('ok'))
+const transformWithMock = codeSampleRemark({
+ fetchFromGitHub: fetchFromGitHubMock,
+})
+
+let env: NodeJS.Process['env']
+
+describe('$CodeSample', () => {
+ beforeAll(() => {
+ env = process.env
+ process.env = { NODE_ENV: 'test', NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: '1234567890' }
+ })
+
+ afterAll(() => {
+ process.env = env
+ })
+
+ it('should replace code sample with source code', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample path="/_internal/fixtures/javascript.js" lines={[[1, -1]]} />
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`javascript
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should replace code sample and elide lines', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample path="/_internal/fixtures/javascript.js" lines={[[1, 2], [8, 10]]} />
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`javascript
+ const A = 'A'
+ const B = 3
+
+ // ...
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ // ...
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should handle paths without leading slash', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample path="_internal/fixtures/javascript.js" lines={[[1, -1]]} />
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`javascript
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should use correct language modifier', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample path="/_internal/fixtures/python.py" lines={[[1, -1]]} />
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`python
+ PI = 3.14159
+ E = 2.71828
+
+ def add_numbers(a, b):
+ return a + b
+
+ def concat_strings(str1, str2):
+ return str1 + str2
+
+ # Test cases
+ if __name__ == "__main__":
+ result1 = add_numbers(3, 5)
+ print(f"add_numbers(3, 5) = {result1}") # Expected output: 8
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should fetch external code samples remotely', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample
+ external={true}
+ org="supabase"
+ repo="supabase"
+ commit="68d5s42hvs7p342kl65ldk90dsafdsa"
+ path="/path/to/file.ts"
+ lines={[[1, -1]]}
+/>
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`typescript
+ ok
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(fetchFromGitHubMock).toHaveBeenCalledTimes(1)
+ expect(fetchFromGitHubMock).toHaveBeenCalledWith({
+ org: 'supabase',
+ repo: 'supabase',
+ path: '/path/to/file.ts',
+ branch: '68d5s42hvs7p342kl65ldk90dsafdsa',
+ options: { onError: expect.any(Function), fetch: expect.any(Function) },
+ })
+ expect(output).toEqual(expected)
+ })
+
+ it('should preserve meta as code block meta if given', async () => {
+ const markdown = `
+# Embed code sample
+
+<$CodeSample
+ path="/_internal/fixtures/javascript.js"
+ lines={[[1, 2], [8, 10]]}
+ meta="utils/client.ts"
+/>
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+ \`\`\`javascript utils/client.ts
+ const A = 'A'
+ const B = 3
+
+ // ...
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ // ...
+ \`\`\`
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should wrap entire CodeHike if CodeHike descendant', async () => {
+ const markdown = `
+# Embed code sample
+
+
+
+<$CodeSample
+ path="/_internal/fixtures/javascript.js"
+ lines={[[1, -1]]}
+ meta="utils/client.ts"
+/>
+
+
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+
+ \`\`\`javascript utils/client.ts
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should merge multiple CodeSampleWrappers', async () => {
+ const markdown = `
+# Embed code sample
+
+
+
+<$CodeSample
+path="/_internal/fixtures/javascript.js"
+lines={[[1, -1]]}
+meta="utils/client.ts"
+/>
+
+<$CodeSample
+path="/_internal/fixtures/python.py"
+lines={[[1, -1]]}
+meta="utils/python.py"
+/>
+
+
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+
+ \`\`\`javascript utils/client.ts
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+ \`\`\`python utils/python.py
+ PI = 3.14159
+ E = 2.71828
+
+ def add_numbers(a, b):
+ return a + b
+
+ def concat_strings(str1, str2):
+ return str1 + str2
+
+ # Test cases
+ if __name__ == "__main__":
+ result1 = add_numbers(3, 5)
+ print(f"add_numbers(3, 5) = {result1}") # Expected output: 8
+ \`\`\`
+
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+
+ it('should correctly replace multiple CodeHikes', async () => {
+ const markdown = `
+# Embed code sample
+
+
+
+<$CodeSample
+ path="/_internal/fixtures/javascript.js"
+ lines={[[1, -1]]}
+ meta="utils/client1.ts"
+/>
+
+<$CodeSample
+ path="/_internal/fixtures/javascript.js"
+ lines={[[1, -1]]}
+ meta="utils/client2.ts"
+/>
+
+
+
+Another one:
+
+
+
+<$CodeSample
+ path="/_internal/fixtures/javascript.js"
+ lines={[[1, -1]]}
+ meta="utils/client3.ts"
+/>
+
+
+
+Some more text.
+`.trim()
+
+ const mdast = fromMarkdown(markdown, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+ const transformed = await transformWithMock(mdast)
+ const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
+
+ const expected = `
+# Embed code sample
+
+
+
+ \`\`\`javascript utils/client1.ts
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+ \`\`\`javascript utils/client2.ts
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+
+
+Another one:
+
+
+
+ \`\`\`javascript utils/client3.ts
+ const A = 'A'
+ const B = 3
+
+ function add(a, b) {
+ return a + b
+ }
+
+ function max(a, b) {
+ return a > b ? a : b
+ }
+
+ function min(a, b) {
+ return a < b ? a : b
+ }
+ \`\`\`
+
+
+
+Some more text.
+`.trimStart()
+
+ expect(output).toEqual(expected)
+ })
+})
+
+describe('_createElidedLine', () => {
+ it('properly preserves indentation', () => {
+ const content = `
+def add_numbers(a, b):
+ return a + b
+
+def concat_strings(str1, str2):
+ return str1 + str2
+
+# Test cases
+if __name__ == "__main__":
+ result1 = add_numbers(3, 5)
+ print(f"add_numbers(3, 5) = {result1}") # Expected output: 8
+`.trim()
+
+ const output = _createElidedLine('python', content.split('\n'), 10, 10)
+
+ const expected = '\n // ...\n'
+ expect(output).toEqual(expected)
+ })
+
+ it('properly uses comment format in JSX and TSX', () => {
+ const content = `
+const one = 'one'
+const two = 'two'
+
+function One() {
+ return (
+
+
+
+
+ )
+}
+`.trim()
+
+ const output = _createElidedLine('tsx', content.split('\n'), 4, -1)
+
+ const expected = '\n// ...\n'
+ expect(output).toEqual(expected)
+
+ const outputJsx = _createElidedLine('tsx', content.split('\n'), 8, -1)
+ const expectedJsx = '\n {/* ... */}\n'
+ expect(outputJsx).toEqual(expectedJsx)
+ })
+})
diff --git a/apps/docs/features/directives/CodeSample.ts b/apps/docs/features/directives/CodeSample.ts
new file mode 100644
index 00000000000..1e1d0306204
--- /dev/null
+++ b/apps/docs/features/directives/CodeSample.ts
@@ -0,0 +1,473 @@
+/**
+ * The $CodeSample directive supports inclusion of code samples from a source
+ * code file, which may be internal to this repo or external from another
+ * GitHub repo.
+ *
+ * The syntax for internal references is:
+ *
+ * ```mdx
+ * <$CodeSample
+ * path="/path/to/file.ts"
+ * lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
+ * meta="utils/client.ts" // Optional, for displaying a file path on the code block
+ * />
+ * ```
+ *
+ * The syntax for external references is:
+ *
+ * ```mdx
+ * <$CodeSample
+ * external={true} // Note you must set the boolean, React pattern of omitting for true doesn't work
+ * org="supabase"
+ * repo="wrappers"
+ * commit="68d5s42hvs7p342kl65ldk90dsafdsa"
+ * path="/path/to/file.ts"
+ * lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
+ * meta="utils/client.ts" // Optional, for displaying a file path on the code block
+ * />
+ */
+
+import * as acorn from 'acorn'
+import tsPlugin from 'acorn-typescript'
+import { type BlockContent, type Code, type Root } from 'mdast'
+import type {
+ MdxJsxAttribute,
+ MdxJsxAttributeValueExpression,
+ MdxJsxExpressionAttribute,
+ MdxJsxFlowElement,
+ MdxJsxFlowElementHast,
+ MdxJsxTextElement,
+ MdxJsxTextElementHast,
+} from 'mdast-util-mdx-jsx'
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { type Parent } from 'unist'
+import { visitParents } from 'unist-util-visit-parents'
+import { z, type SafeParseError } from 'zod'
+
+import { fetchWithNextOptions } from '~/features/helpers.fetch'
+import { EXAMPLES_DIRECTORY } from '~/lib/docs'
+
+const linesSchema = z.array(z.tuple([z.coerce.number(), z.coerce.number()]))
+const linesValidator = z.string().transform((v, ctx) => {
+ try {
+ const array = JSON.parse(v)
+ return linesSchema.parse(array)
+ } catch (e) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Lines should be an array of [number, number] tuples',
+ })
+ return z.NEVER
+ }
+})
+
+type AdditionalMeta = {
+ parent: Parent
+ codeHikeAncestor: Parent | null
+ codeHikeAncestorParent: Parent | null
+}
+
+const codeSampleExternalSchema = z.object({
+ external: z.coerce.boolean().refine((v) => v === true),
+ org: z.string(),
+ repo: z.string(),
+ commit: z.string(),
+ path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
+ lines: linesValidator,
+ meta: z.string().optional(),
+})
+type ICodeSampleExternal = z.infer & AdditionalMeta
+
+const codeSampleInternalSchema = z.object({
+ external: z.coerce
+ .boolean()
+ .refine((v) => v === false)
+ .optional(),
+ path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
+ lines: linesValidator,
+ meta: z.string().optional(),
+})
+type ICodeSampleInternal = z.infer & AdditionalMeta
+
+type CodeSampleMeta = ICodeSampleExternal | ICodeSampleInternal
+
+function isExternalSource(meta: CodeSampleMeta): meta is ICodeSampleExternal {
+ return !!meta.external
+}
+
+interface Dependencies {
+ fetchFromGitHub: (params: {
+ org: string
+ repo: string
+ path: string
+ branch: string
+ options: { onError: (error: unknown) => void; fetch: (url: string) => Promise }
+ }) => Promise
+}
+
+export function codeSampleRemark(deps: Dependencies) {
+ return async function transform(tree: Root) {
+ const contentMap = await fetchSourceCodeContent(tree, deps)
+ rewriteNodes(contentMap)
+
+ return tree
+ }
+}
+
+async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
+ const codeSampleNodes = [] as MdxJsxFlowElement[]
+ const metadata = [] as CodeSampleMeta[]
+ const pendingFetches = [] as Promise[]
+
+ visitParents(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement, ancestors) => {
+ if (node.name !== '$CodeSample') return
+
+ const codeHikeAncestorIndex = ancestors.findLastIndex(
+ (ancestor) => ancestor.type === 'mdxJsxFlowElement' && ancestor.name === 'CH.Code'
+ )
+ const codeHikeAncestor = codeHikeAncestorIndex === -1 ? null : ancestors[codeHikeAncestorIndex]
+ const codeHikeAncestorParent =
+ codeHikeAncestorIndex <= 0 ? null : ancestors[codeHikeAncestorIndex - 1]
+ const parent = ancestors[ancestors.length - 1]
+
+ const isExternal = getAttributeValueExpression(getAttributeValue(node, 'external')) === 'true'
+
+ if (isExternal) {
+ const org = getAttributeValue(node, 'org')
+ const repo = getAttributeValue(node, 'repo')
+ const commit = getAttributeValue(node, 'commit')
+ const path = getAttributeValue(node, 'path')
+ const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
+ const meta = getAttributeValue(node, 'meta')
+
+ const result = codeSampleExternalSchema.safeParse({
+ external: isExternal,
+ org,
+ repo,
+ commit,
+ path,
+ lines,
+ meta,
+ })
+
+ if (!result.success) {
+ throw new Error(
+ `Invalid $CodeSample directive: ${(result as SafeParseError).error.message}`
+ )
+ }
+
+ const fetchTask = deps.fetchFromGitHub({
+ org: result.data.org,
+ repo: result.data.repo,
+ path: result.data.path,
+ branch: result.data.commit,
+ options: {
+ onError: (error: unknown) => {
+ throw Error(
+ `Failed to fetch code sample from ${org}/${repo}@${commit} at path ${path}: ${error}`
+ )
+ },
+ fetch: fetchWithNextOptions({ cache: 'force-cache' }),
+ },
+ })
+
+ codeSampleNodes.push(node)
+ metadata.push({ ...result.data, parent, codeHikeAncestor, codeHikeAncestorParent })
+ pendingFetches.push(fetchTask)
+ } else {
+ const path = getAttributeValue(node, 'path')
+ const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
+ const meta = getAttributeValue(node, 'meta')
+
+ const result = codeSampleInternalSchema.safeParse({
+ external: isExternal,
+ path,
+ lines,
+ meta,
+ })
+
+ if (!result.success) {
+ throw new Error(
+ `Invalid $CodeSample directive: ${(result as SafeParseError).error.message}`
+ )
+ }
+
+ const filePath = join(EXAMPLES_DIRECTORY, result.data.path)
+ if (!filePath.startsWith(EXAMPLES_DIRECTORY)) {
+ throw new Error(`Invalid $CodeSample settings: Path must be inside ${EXAMPLES_DIRECTORY}`)
+ }
+ const fetchTask = readFile(filePath, 'utf-8')
+
+ codeSampleNodes.push(node)
+ metadata.push({ ...result.data, parent, codeHikeAncestor, codeHikeAncestorParent })
+ pendingFetches.push(fetchTask)
+ }
+ })
+
+ const resolvedContent = await Promise.all(pendingFetches)
+
+ const nodeContentMap = new Map()
+ codeSampleNodes.forEach((node, index) => {
+ nodeContentMap.set(node, [metadata[index], resolvedContent[index]])
+ })
+
+ return nodeContentMap
+}
+
+function getAttributeValue(
+ node: MdxJsxFlowElement | MdxJsxFlowElementHast | MdxJsxTextElement | MdxJsxTextElementHast,
+ attributeName: string
+) {
+ return (
+ node.attributes.find(
+ (attr: MdxJsxAttribute | MdxJsxExpressionAttribute) =>
+ 'name' in attr && attr.name === attributeName
+ )?.value ?? undefined
+ )
+}
+
+function getAttributeValueExpression(node: MdxJsxAttributeValueExpression | string | undefined) {
+ if (typeof node === 'string' || node?.type !== 'mdxJsxAttributeValueExpression') return undefined
+ return node.value
+}
+
+function rewriteNodes(contentMap: Map) {
+ for (const [node, [meta, content]] of contentMap) {
+ const lang = matchLang(meta.path.split('.').pop())
+
+ const source = isExternalSource(meta)
+ ? `https://github.com/${meta.org}/${meta.repo}/blob/${meta.commit}${meta.path}`
+ : `https://github.com/supabase/supabase/blob/${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? 'master'}/examples${meta.path}`
+
+ const elidedContent = redactLines(content, meta.lines, lang)
+
+ const replacementContent: MdxJsxFlowElement | Code = meta.codeHikeAncestor
+ ? {
+ type: 'code',
+ lang,
+ meta: meta.meta,
+ value: elidedContent,
+ }
+ : {
+ type: 'mdxJsxFlowElement',
+ name: 'CodeSampleWrapper',
+ attributes: [
+ {
+ type: 'mdxJsxAttribute',
+ name: 'source',
+ value: source,
+ },
+ ],
+ children: [
+ {
+ type: 'code',
+ lang,
+ meta: meta.meta,
+ value: elidedContent,
+ },
+ ],
+ }
+ meta.parent.children.splice(meta.parent.children.indexOf(node), 1, replacementContent)
+
+ if (meta.codeHikeAncestor && meta.codeHikeAncestorParent) {
+ const existingWrapper = meta.codeHikeAncestorParent.children.find(
+ (child) =>
+ child.type === 'mdxJsxFlowElement' &&
+ (child as MdxJsxFlowElement).name === 'CodeSampleWrapper' &&
+ (child as MdxJsxFlowElement).children?.[0] === meta.codeHikeAncestor
+ ) as MdxJsxFlowElement | undefined
+ if (existingWrapper) {
+ const existingSource = getAttributeValue(existingWrapper, 'source')
+ if (typeof existingSource === 'string' && existingSource !== source) {
+ const newSource = createArrayAttributeValueExpression(existingSource, source)
+ existingWrapper.attributes[0].value = newSource
+ } else if (
+ typeof existingSource !== 'string' &&
+ existingSource.type === 'mdxJsxAttributeValueExpression'
+ ) {
+ const existingSourceArray =
+ // @ts-ignore
+ existingSource.data.estree.body[0]?.expression?.elements?.map(
+ (element) => element.value
+ ) ?? []
+ const newSource = createArrayAttributeValueExpression(...existingSourceArray, source)
+ existingWrapper.attributes[0].value = newSource
+ }
+ } else {
+ const codeSampleWrapper: MdxJsxFlowElement = {
+ type: 'mdxJsxFlowElement',
+ name: 'CodeSampleWrapper',
+ attributes: [
+ {
+ type: 'mdxJsxAttribute',
+ name: 'source',
+ value: source,
+ },
+ ],
+ children: [meta.codeHikeAncestor as BlockContent],
+ }
+ meta.codeHikeAncestorParent.children.splice(
+ meta.codeHikeAncestorParent.children.indexOf(meta.codeHikeAncestor),
+ 1,
+ codeSampleWrapper
+ )
+ }
+ }
+ }
+}
+
+function matchLang(lang: string) {
+ switch (lang) {
+ case 'tsx':
+ return 'tsx'
+ case 'ts':
+ return 'typescript'
+ case 'jsx':
+ return 'jsx'
+ case 'js':
+ return 'javascript'
+ case 'json':
+ return 'json'
+ case 'py':
+ return 'python'
+ case 'sh':
+ return 'bash'
+ case 'kt':
+ return 'kotlin'
+ case 'dart':
+ return 'dart'
+ case 'swift':
+ return 'swift'
+ case 'sql':
+ return 'sql'
+ default:
+ return null
+ }
+}
+
+function redactLines(
+ content: string,
+ lines: [number, number, ...unknown[]][],
+ lang: string | null
+) {
+ const contentLines = content.split('\n')
+ const preservedLines = lines.reduce((acc, [start, end], index, arr) => {
+ if (index !== 0 || start !== 1) {
+ acc.push(_createElidedLine(lang, contentLines, start, end))
+ }
+
+ // Start and end are 1-indexed and inclusive
+ acc.push(...contentLines.slice(start - 1, end === -1 ? contentLines.length : end))
+
+ if (index === arr.length - 1 && end !== -1 && end !== contentLines.length) {
+ acc.push(_createElidedLine(lang, contentLines, start, end))
+ }
+
+ return acc
+ }, [] as string[])
+
+ return preservedLines.join('\n').trim()
+}
+
+export function _createElidedLine(
+ lang: string | null,
+ lines: string[],
+ start: number,
+ end: number
+) {
+ const indentation = lines[start - 1].match(/^\s*/)?.[0] ?? ''
+
+ switch (lang) {
+ case 'sql':
+ return `\n${indentation}-- ...\n`
+ case 'jsx':
+ case 'tsx':
+ // @ts-ignore
+ const acornTree = acorn.Parser.extend(tsPlugin()).parse(lines.join('\n'), {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ locations: true,
+ })
+ const isWithinJsx = isContainedInJsx(acornTree, start)
+ if (isWithinJsx) {
+ return `\n${indentation}{/* ... */}\n`
+ } else {
+ return `\n${indentation}// ...\n`
+ }
+ default:
+ return `\n${indentation}// ...\n`
+ }
+}
+
+function isContainedInJsx(tree: acorn.Node, line: number) {
+ const acornNodeContainsLine = (node: acorn.Node, line) =>
+ node.loc?.start.line <= line && node.loc?.end.line >= line
+ if (!acornNodeContainsLine(tree, line)) {
+ return false
+ }
+
+ let candidateNarrowestContainingNode = tree
+
+ function getNarrowestContainingNode(node: acorn.Node, line: number) {
+ for (const key of Object.keys(node)) {
+ const value = node[key]
+ if (!value || typeof value !== 'object') {
+ continue
+ }
+
+ if (!Array.isArray(value)) {
+ if (acornNodeContainsLine(value, line)) {
+ candidateNarrowestContainingNode = value
+ getNarrowestContainingNode(value, line)
+ }
+ } else {
+ for (const child of value) {
+ if (!acornNodeContainsLine(child, line)) {
+ continue
+ } else {
+ if (
+ child.loc?.start?.line > candidateNarrowestContainingNode.loc?.start?.line ||
+ child.loc.end.line < candidateNarrowestContainingNode.loc?.end?.line ||
+ child.loc.start.column > candidateNarrowestContainingNode.loc?.start.column ||
+ child.loc.end.column < candidateNarrowestContainingNode.loc?.end.column
+ ) {
+ candidateNarrowestContainingNode = child
+ getNarrowestContainingNode(child, line)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ getNarrowestContainingNode(tree, line)
+ return candidateNarrowestContainingNode.type.startsWith('JSX')
+}
+
+function createArrayAttributeValueExpression(...arrayElements: string[]) {
+ const expression: MdxJsxAttributeValueExpression = {
+ type: 'mdxJsxAttributeValueExpression',
+ value: '[' + arrayElements.map((element) => `'${element}'`).join(', ') + ']',
+ data: {
+ estree: {
+ type: 'Program',
+ sourceType: 'module',
+ body: [
+ {
+ type: 'ExpressionStatement',
+ expression: {
+ type: 'ArrayExpression',
+ elements: arrayElements.map((element) => ({
+ type: 'Literal',
+ value: element,
+ raw: element,
+ })),
+ },
+ },
+ ],
+ },
+ },
+ }
+ return expression
+}
diff --git a/apps/docs/features/directives/README.md b/apps/docs/features/directives/README.md
new file mode 100644
index 00000000000..e033a4e09e1
--- /dev/null
+++ b/apps/docs/features/directives/README.md
@@ -0,0 +1,22 @@
+# Directives
+
+Directives are a custom feature of the Supabase docs content system, which allows you to extend MDX to provide custom functionality.
+
+## Why not a React component?
+
+MDX supports React components, and that is the preferred way to add new features. If your use case is supported by a React component alone, use that instead.
+
+Custom directives are used to implement features that need low-level parse or compile-time control over the MDX AST.
+
+## Syntax
+
+We reserve a special syntax for directives, which start with a `$` sign. For example:
+
+```mdx
+<$CodeSample />
+```
+
+This syntax was chosen because it is both:
+
+- Sufficiently standard to be supported by MDX parsers without needing to build a custom extension.
+- Sufficiently uncommon to avoid collisions with other React components used in docs.
diff --git a/apps/docs/features/directives/utils.ts b/apps/docs/features/directives/utils.ts
new file mode 100644
index 00000000000..d232b0619d0
--- /dev/null
+++ b/apps/docs/features/directives/utils.ts
@@ -0,0 +1,28 @@
+import { type Root } from 'mdast'
+import { fromMarkdown } from 'mdast-util-from-markdown'
+import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
+import { toMarkdown } from 'mdast-util-to-markdown'
+import { mdxjs } from 'micromark-extension-mdxjs'
+
+import { getGitHubFileContents } from '~/lib/octokit'
+import { codeSampleRemark } from './CodeSample'
+
+type Transformer = (ast: Root) => Root | Promise
+
+export async function preprocessMdx(mdx: string, transformers: Transformer[]) {
+ let mdast = fromMarkdown(mdx, {
+ mdastExtensions: [mdxFromMarkdown()],
+ extensions: [mdxjs()],
+ })
+
+ for (const transform of transformers) {
+ mdast = await transform(mdast)
+ }
+
+ const output = toMarkdown(mdast, { extensions: [mdxToMarkdown()] })
+ return output
+}
+
+export function preprocessMdxWithDefaults(mdx: string) {
+ return preprocessMdx(mdx, [codeSampleRemark({ fetchFromGitHub: getGitHubFileContents })])
+}
diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx
index 7bdb4a49675..e3f189e2718 100644
--- a/apps/docs/features/docs/MdxBase.shared.tsx
+++ b/apps/docs/features/docs/MdxBase.shared.tsx
@@ -34,6 +34,7 @@ import { RealtimeLimitsEstimator } from '~/components/RealtimeLimitsEstimator'
import { RegionsList } from '~/components/RegionsList'
import { SharedData } from '~/components/SharedData'
import StepHikeCompact from '~/components/StepHikeCompact'
+import { CodeSampleWrapper } from '~/features/directives/CodeSample.client'
import { Accordion, AccordionItem } from '~/features/ui/Accordion'
import * as CH from '~/features/ui/CodeHike'
import { Tabs, TabPanel } from '~/features/ui/Tabs'
@@ -50,6 +51,7 @@ const components = {
Button,
ButtonCard,
CH,
+ CodeSampleWrapper,
CostWarning,
CreateClientSnippet,
DatabaseSetup,
diff --git a/apps/docs/features/docs/MdxBase.tsx b/apps/docs/features/docs/MdxBase.tsx
index 9cb1e104ced..1bda6f1f0b1 100644
--- a/apps/docs/features/docs/MdxBase.tsx
+++ b/apps/docs/features/docs/MdxBase.tsx
@@ -7,6 +7,7 @@ import remarkGfm from 'remark-gfm'
import rehypeKatex from 'rehype-katex'
import remarkMath from 'remark-math'
+import { preprocessMdxWithDefaults } from '~/features/directives/utils'
import { components } from '~/features/docs/MdxBase.shared'
const codeHikeOptions: CodeHikeConfig = {
@@ -29,7 +30,18 @@ const mdxOptions: SerializeOptions = {
},
}
-const MDXRemoteBase = ({ options = {}, ...props }: ComponentProps) => {
+const MDXRemoteBase = async ({
+ source,
+ options = {},
+ customPreprocess,
+ ...props
+}: ComponentProps & {
+ source: string
+ customPreprocess?: (mdx: string) => string | Promise
+}) => {
+ const preprocess = customPreprocess ?? preprocessMdxWithDefaults
+ const preprocessedSource = await preprocess(source)
+
const { mdxOptions: { remarkPlugins, rehypePlugins, ...otherMdxOptions } = {}, ...otherOptions } =
options
const {
@@ -51,7 +63,14 @@ const MDXRemoteBase = ({ options = {}, ...props }: ComponentProps
+ return (
+
+ )
}
export { MDXRemoteBase }
diff --git a/apps/docs/features/docs/Reference.mdx.tsx b/apps/docs/features/docs/Reference.mdx.tsx
index 1024e5771ad..eac8747683a 100644
--- a/apps/docs/features/docs/Reference.mdx.tsx
+++ b/apps/docs/features/docs/Reference.mdx.tsx
@@ -44,7 +44,7 @@ interface MDXRemoteRefsProps {
function MDXRemoteRefs({ source }: MDXRemoteRefsProps) {
const refComponents = { ...components, RefSubLayout, CliGlobalFlagsHandler }
- return
+ return x} />
}
export { getRefMarkdown, MDXRemoteRefs }
diff --git a/apps/docs/features/docs/Reference.ui.tsx b/apps/docs/features/docs/Reference.ui.tsx
index 0a6eae3427a..a27fc1a4013 100644
--- a/apps/docs/features/docs/Reference.ui.tsx
+++ b/apps/docs/features/docs/Reference.ui.tsx
@@ -230,7 +230,7 @@ function ParamOrTypeDetails({ paramOrType }: { paramOrType: object }) {
{description && (
-
+
)}
{subContent && subContent.length > 0 && }
@@ -253,7 +253,10 @@ export function ReturnTypeDetails({ returnType }: { returnType: MethodTypes['ret
{getTypeName(returnType)}
{returnType.comment?.shortText && (
-
+
)}
{subContent && subContent.length > 0 && }
diff --git a/apps/docs/features/helpers.fetch.ts b/apps/docs/features/helpers.fetch.ts
index 6a38fb63933..cb194482203 100644
--- a/apps/docs/features/helpers.fetch.ts
+++ b/apps/docs/features/helpers.fetch.ts
@@ -8,10 +8,16 @@
import { ONE_DAY_IN_SECONDS } from './helpers.time'
-function fetchWithNextOptions(options: NextFetchRequestConfig) {
- return (info: RequestInfo) => fetch(info, { next: options })
+function fetchWithNextOptions({
+ next,
+ cache,
+}: {
+ next?: NextFetchRequestConfig
+ cache?: RequestInit['cache']
+}) {
+ return (info: RequestInfo) => fetch(info, { next, cache })
}
-const fetchRevalidatePerDay = fetchWithNextOptions({ revalidate: ONE_DAY_IN_SECONDS })
+const fetchRevalidatePerDay = fetchWithNextOptions({ next: { revalidate: ONE_DAY_IN_SECONDS } })
export { fetchWithNextOptions, fetchRevalidatePerDay }
diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts
index d6d84ee7630..1b833b7ed82 100644
--- a/apps/docs/lib/docs.ts
+++ b/apps/docs/lib/docs.ts
@@ -4,8 +4,7 @@ import { serialize } from 'next-mdx-remote/serialize'
import type { SerializeOptions } from 'next-mdx-remote/dist/types'
import { existsSync } from 'node:fs'
import { readdir, readFile } from 'node:fs/promises'
-import { dirname, join, extname, sep, basename } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { join, extname, sep, basename } from 'node:path'
import remarkGfm from 'remark-gfm'
import rehypeKatex from 'rehype-katex'
import remarkMath from 'remark-math'
@@ -16,6 +15,7 @@ import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
// with outputFileTracingIncludes (not auto-traced) will not be found at
// runtime.
const DOCS_DIRECTORY = process.cwd()
+export const EXAMPLES_DIRECTORY = join(DOCS_DIRECTORY, '..', '..', 'examples')
export const GUIDES_DIRECTORY = join(DOCS_DIRECTORY, 'content/guides')
export const REF_DOCS_DIRECTORY = join(DOCS_DIRECTORY, 'docs/ref')
export const SPEC_DIRECTORY = join(DOCS_DIRECTORY, 'spec')
diff --git a/apps/docs/lib/octokit.ts b/apps/docs/lib/octokit.ts
new file mode 100644
index 00000000000..c1ccf9073cb
--- /dev/null
+++ b/apps/docs/lib/octokit.ts
@@ -0,0 +1,81 @@
+import 'server-only'
+
+import { createAppAuth } from '@octokit/auth-app'
+import { Octokit } from '@octokit/core'
+import crypto from 'node:crypto'
+
+import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
+
+let octokitInstance: Octokit
+
+function octokit() {
+ if (!octokitInstance) {
+ const privateKeyPkcs8 = crypto
+ .createPrivateKey(process.env.DOCS_GITHUB_APP_PRIVATE_KEY)
+ .export({
+ type: 'pkcs8',
+ format: 'pem',
+ })
+
+ octokitInstance = new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: process.env.DOCS_GITHUB_APP_ID,
+ installationId: process.env.DOCS_GITHUB_APP_INSTALLATION_ID,
+ privateKey: privateKeyPkcs8,
+ },
+ })
+ }
+
+ return octokitInstance
+}
+
+export async function getGitHubFileContents({
+ org,
+ repo,
+ path,
+ branch,
+ options: { onError },
+}: {
+ org: string
+ repo: string
+ path: string
+ branch: string
+ options: {
+ onError: (err?: unknown) => void
+ /**
+ *
+ * A custom fetch implementation to control Next.js caching.
+ * By default, uses a "once-per-day" revalidation strategy.
+ * This default may change later as we move to on-demand revalidation.
+ */
+ fetch?: (info: RequestInfo, init?: RequestInit) => Promise
+ }
+}) {
+ if (path.startsWith('/')) {
+ path = path.slice(1)
+ }
+
+ try {
+ const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', {
+ owner: org,
+ repo: repo,
+ path: path,
+ ref: branch,
+ options: {
+ fetch: fetchRevalidatePerDay,
+ },
+ })
+ if (response.status !== 200 || !response.data) {
+ throw Error(`Could not find contents of ${path} in ${org}/${repo}`)
+ }
+ if (!('type' in response.data) || response.data.type !== 'file') {
+ throw Error(`${path} in ${org}/${repo} is not a file`)
+ }
+ const content = Buffer.from(response.data.content, 'base64').toString('utf-8')
+ return content
+ } catch (err) {
+ console.error('Error fetching GitHub file: %o', err)
+ onError?.(err)
+ }
+}
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 682e61ab5f2..b6043830bc1 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -40,6 +40,8 @@
"@supabase/supabase-js": "^2.44.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.13.4",
+ "acorn": "^8.11.3",
+ "acorn-typescript": "^1.4.13",
"common": "*",
"common-tags": "^1.8.2",
"config": "*",
@@ -79,6 +81,7 @@
"remark-emoji": "^3.1.2",
"remark-gfm": "^3.0.1",
"remark-math": "^6.0.0",
+ "server-only": "^0.0.1",
"shared-data": "*",
"toml": "^3.0.0",
"ui": "*",
@@ -97,7 +100,6 @@
"@types/node": "^20.11.16",
"@types/react": "^18.2.24",
"@types/unist": "^2.0.6",
- "acorn": "^8.11.3",
"api-types": "*",
"cheerio": "^1.0.0-rc.12",
"config": "*",
diff --git a/examples/_internal/README.md b/examples/_internal/README.md
new file mode 100644
index 00000000000..aebea5b36c5
--- /dev/null
+++ b/examples/_internal/README.md
@@ -0,0 +1,3 @@
+# Internal fixtures for examples
+
+This directory contains some fixtures for internal testing purposes.
diff --git a/examples/_internal/fixtures/javascript.js b/examples/_internal/fixtures/javascript.js
new file mode 100644
index 00000000000..58de0afd3a5
--- /dev/null
+++ b/examples/_internal/fixtures/javascript.js
@@ -0,0 +1,14 @@
+const A = 'A'
+const B = 3
+
+function add(a, b) {
+ return a + b
+}
+
+function max(a, b) {
+ return a > b ? a : b
+}
+
+function min(a, b) {
+ return a < b ? a : b
+}
diff --git a/examples/_internal/fixtures/python.py b/examples/_internal/fixtures/python.py
new file mode 100644
index 00000000000..7944d2e2f6e
--- /dev/null
+++ b/examples/_internal/fixtures/python.py
@@ -0,0 +1,13 @@
+PI = 3.14159
+E = 2.71828
+
+def add_numbers(a, b):
+ return a + b
+
+def concat_strings(str1, str2):
+ return str1 + str2
+
+# Test cases
+if __name__ == "__main__":
+ result1 = add_numbers(3, 5)
+ print(f"add_numbers(3, 5) = {result1}") # Expected output: 8
diff --git a/package-lock.json b/package-lock.json
index ada73a851a5..d426c6f89ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -864,6 +864,8 @@
"@supabase/supabase-js": "^2.44.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.13.4",
+ "acorn": "^8.11.3",
+ "acorn-typescript": "^1.4.13",
"common": "*",
"common-tags": "^1.8.2",
"config": "*",
@@ -903,6 +905,7 @@
"remark-emoji": "^3.1.2",
"remark-gfm": "^3.0.1",
"remark-math": "^6.0.0",
+ "server-only": "^0.0.1",
"shared-data": "*",
"toml": "^3.0.0",
"ui": "*",
@@ -921,7 +924,6 @@
"@types/node": "^20.11.16",
"@types/react": "^18.2.24",
"@types/unist": "^2.0.6",
- "acorn": "^8.11.3",
"api-types": "*",
"cheerio": "^1.0.0-rc.12",
"config": "*",
@@ -16634,6 +16636,14 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/acorn-typescript": {
+ "version": "1.4.13",
+ "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
+ "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
+ "peerDependencies": {
+ "acorn": ">=8.9.0"
+ }
+ },
"node_modules/acorn-walk": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
@@ -37524,6 +37534,11 @@
"node": ">=10"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="
+ },
"node_modules/set-blocking": {
"version": "2.0.0",
"license": "ISC"
diff --git a/turbo.json b/turbo.json
index f1f79fd0664..b691faf1169 100644
--- a/turbo.json
+++ b/turbo.json
@@ -15,6 +15,9 @@
"dependsOn": ["^build"],
"env": [
"ANALYZE",
+ "DOCS_GITHUB_APP_ID",
+ "DOCS_GITHUB_APP_INSTALLATION_ID",
+ "DOCS_GITHUB_APP_PRIVATE_KEY",
"DOCS_REVALIDATION_KEYS",
"DOCS_REVALIDATION_OVERRIDE_KEYS",
"NEXT_PUBLIC_*",
@@ -31,6 +34,9 @@
"AUTH_JWT_SECRET",
"DEFAULT_ORGANIZATION_NAME",
"DEFAULT_PROJECT_NAME",
+ "DOCS_GITHUB_APP_ID",
+ "DOCS_GITHUB_APP_INSTALLATION_ID",
+ "DOCS_GITHUB_APP_PRIVATE_KEY",
"LOGFLARE_API_KEY",
"LOGFLARE_URL",
"NEXT_PUBLIC_*",