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 ? ( - Avatar - ) : ( -
- )} -
- - -
-
- ) -} -``` +<$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_*",