From c2fe11850dcb11c82c187074b48031c3da3012bc Mon Sep 17 00:00:00 2001
From: Charis <26616127+charislam@users.noreply.github.com>
Date: Thu, 26 Sep 2024 15:54:57 -0400
Subject: [PATCH] feat: embed code samples from github (#29519)
Add the ability to embed code samples from GitHub into tutorials, so we can have a single source of truth for the source code.
Introduces the <$CodeSample /> syntax, which is a special syntax and not a real React component (see the directives/README.md for why on earth I did it this way -- in this specific case, CodeHike adjusts the MDX syntax tree before it gets compiled, and we need to adjust it ourselves before CodeHike sees it, so we need to get down to the level of manipulating the AST in order to make this work with CodeHike).
Adjusted one of the example tutorials to use this new feature as a test.
---
.github/workflows/docs-tests.yml | 1 +
apps/docs/app/contributing/content.mdx | 40 ++
.../getting-started/tutorials/with-nextjs.mdx | 564 ++---------------
.../features/directives/CodeSample.client.tsx | 67 ++
.../features/directives/CodeSample.test.ts | 576 ++++++++++++++++++
apps/docs/features/directives/CodeSample.ts | 473 ++++++++++++++
apps/docs/features/directives/README.md | 22 +
apps/docs/features/directives/utils.ts | 28 +
apps/docs/features/docs/MdxBase.shared.tsx | 2 +
apps/docs/features/docs/MdxBase.tsx | 23 +-
apps/docs/features/docs/Reference.mdx.tsx | 2 +-
apps/docs/features/docs/Reference.ui.tsx | 7 +-
apps/docs/features/helpers.fetch.ts | 12 +-
apps/docs/lib/docs.ts | 4 +-
apps/docs/lib/octokit.ts | 81 +++
apps/docs/package.json | 4 +-
examples/_internal/README.md | 3 +
examples/_internal/fixtures/javascript.js | 14 +
examples/_internal/fixtures/python.py | 13 +
package-lock.json | 17 +-
turbo.json | 6 +
21 files changed, 1450 insertions(+), 509 deletions(-)
create mode 100644 apps/docs/features/directives/CodeSample.client.tsx
create mode 100644 apps/docs/features/directives/CodeSample.test.ts
create mode 100644 apps/docs/features/directives/CodeSample.ts
create mode 100644 apps/docs/features/directives/README.md
create mode 100644 apps/docs/features/directives/utils.ts
create mode 100644 apps/docs/lib/octokit.ts
create mode 100644 examples/_internal/README.md
create mode 100644 examples/_internal/fixtures/javascript.js
create mode 100644 examples/_internal/fixtures/python.py
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_*",