diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 55ab4a6d45e..19731c8f0d6 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -30,6 +30,8 @@ public/sitemap.xml # llms.txt (generated) public/llms.txt public/llms/ +# Generated guide markdown files +public/docs/ # Copied examples folder /examples/ diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index 2d9f7cb3684..4413ab84577 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -22,6 +22,19 @@ For a complete run-down on how all of our tools work together, see the main DEVE 4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end 5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs) +## AI friendly documentation + +This project generates Markdown files for each page under `/docs/guides/..` path. + +To test locally, within the `apps/docs` directory: + +1. Run `pnpm build:guides-markdown` +2. Run `pnpm dev` + +This creates Markdown files for all routes under the `public/docs/guides` directory, ignored by Git. + +For production this setup runs as a `prebuild` task to allow Vercel to bundle these files with middleware and functions. + ## Contributing For repo organization and style guide, see the [contributing guide](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md). diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts new file mode 100644 index 00000000000..f09a7854f25 --- /dev/null +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -0,0 +1,22 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { NextResponse } from 'next/server' + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params + const baseDir = path.join(process.cwd(), 'public/docs/guides') + const filePath = path.join(baseDir, `${slug.join('/')}.md`) + + if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) { + return new NextResponse('Not found', { status: 404 }) + } + + try { + const content = await fs.readFile(filePath, 'utf-8') + return new NextResponse(content, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }) + } catch { + return new NextResponse('Not found', { status: 404 }) + } +} diff --git a/apps/docs/app/guides/local-development/cli/config/page.tsx b/apps/docs/app/guides/local-development/cli/config/page.tsx index 9ce99b1da78..341d800b6f8 100644 --- a/apps/docs/app/guides/local-development/cli/config/page.tsx +++ b/apps/docs/app/guides/local-development/cli/config/page.tsx @@ -1,7 +1,7 @@ import ReactMarkdown from 'react-markdown' import { CodeBlock } from 'ui' import { Heading } from 'ui/src/components/CustomHTMLElements' -import { type TOCHeader } from '~/components/GuidesTableOfContents' +import { type TOCHeader } from '~/components/GuidesSidebar' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import type { Parameter } from '~/lib/refGenerator/refTypes' diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx new file mode 100644 index 00000000000..ee4e8c49b4b --- /dev/null +++ b/apps/docs/components/GuidesSidebar.tsx @@ -0,0 +1,134 @@ +'use client' + +import { Check, Copy, ExternalLink } from 'lucide-react' +import { usePathname } from 'next/navigation' +import { useState } from 'react' +import { isFeatureEnabled } from 'common' +import { cn } from 'ui' +import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' +import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' +import { Feedback } from '~/components/Feedback' +import { useTocAnchors } from '../features/docs/GuidesMdx.state' + +interface TOCHeader { + id?: string + text: string + link: string + level: number +} + +function AiTools({ className }: { className?: string }) { + const [copied, setCopied] = useState(false) + let url = '' + + // Safe check for server side rendering. + try { + const urlParts = new URL(`${window.location}`) + url = urlParts.origin + urlParts.pathname + } catch (error) {} + + async function copyMarkdown() { + const mdUrl = `${url}.md` + + try { + const res = await fetch(mdUrl) + const text = await res.text() + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy markdown', error) + } + } + + return ( +
+

+ AI Tools +

+
+ + + + Ask ChatGPT + + + + Ask Claude + +
+
+ ) +} + +const GuidesSidebar = ({ + className, + video, + hideToc, +}: { + className?: string + video?: string + hideToc?: boolean +}) => { + const pathname = usePathname() + const { toc } = useTocAnchors() + const showFeedback = isFeatureEnabled('feedback:docs') + const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` + + return ( +
+
+ {video && ( +
+ +
+ )} + {showFeedback && ( +
+ +
+ )} +
+ +
+ {!hideToc && toc.length !== 0 && ( + +

+ On this page +

+ + + +
+ )} +
+
+ ) +} + +export default GuidesSidebar +export { GuidesSidebar } +export type { TOCHeader } diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx deleted file mode 100644 index ebb1fd4158f..00000000000 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import { usePathname } from 'next/navigation' -import { isFeatureEnabled } from 'common' -import { cn } from 'ui' -import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' -import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' -import { Feedback } from '~/components/Feedback' -import { useTocAnchors } from '../features/docs/GuidesMdx.state' - -interface TOCHeader { - id?: string - text: string - link: string - level: number -} - -const GuidesTableOfContents = ({ className, video }: { className?: string; video?: string }) => { - const pathname = usePathname() - const { toc } = useTocAnchors() - - const showFeedback = isFeatureEnabled('feedback:docs') - - const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` - - return ( -
-
- {video && ( -
- -
- )} - {showFeedback && ( -
- -
- )} - {toc.length !== 0 && ( - -

- On this page -

- - - -
- )} -
-
- ) -} - -export default GuidesTableOfContents -export type { TOCHeader } diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index 76fbe6d397c..97758032965 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown' import { cn } from 'ui' import Breadcrumbs from '~/components/Breadcrumbs' -import GuidesTableOfContents from '~/components/GuidesTableOfContents' +import GuidesSidebar from '~/components/GuidesSidebar' import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client' import { MDXRemoteBase } from '~/features/docs/MdxBase' import type { WithRequired } from '~/features/helpers.types' @@ -71,7 +71,7 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT 'relative', 'transition-all ease-out', 'duration-100', - hideToc ? 'col-span-12' : 'col-span-12 md:col-span-9' + 'col-span-12 md:col-span-9' )} > @@ -114,24 +114,23 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT - {!hideToc && ( - - )} + ) diff --git a/apps/docs/features/ui/guide/Guide.tsx b/apps/docs/features/ui/guide/Guide.tsx index e0179dd7ab3..9fa9b8c31c3 100644 --- a/apps/docs/features/ui/guide/Guide.tsx +++ b/apps/docs/features/ui/guide/Guide.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, type ReactNode } from 'react' import { cn } from 'ui' -import GuidesTableOfContents from '~/components/GuidesTableOfContents' +import GuidesTableOfContents from '~/components/GuidesSidebar' import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client' import { type GuideFrontmatter } from '~/lib/docs' diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts new file mode 100644 index 00000000000..c07cb27bcb2 --- /dev/null +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -0,0 +1,165 @@ +import fs from 'fs' +import path from 'path' +import { globby } from 'globby' +import matter from 'gray-matter' + +const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials') + +/** + * Reads <$Partial path="..." /> tags and replaces them with the file contents. + * Recurses to handle nested partials. + */ +async function inlinePartials(content: string): Promise { + const partialRegex = /<\$Partial\s+path="([^"]+)"[^/]*\/>/g + const matches = [...content.matchAll(partialRegex)] + for (const [fullMatch, partialPath] of matches) { + try { + const raw = await fs.promises.readFile(path.join(PARTIALS_DIR, partialPath), 'utf8') + const { content: partialBody } = matter(raw) + const inlined = await inlinePartials(partialBody) + content = content.replace(fullMatch, inlined) + } catch { + content = content.replace(fullMatch, '') + } + } + return content +} + +/** Remove the minimum common leading whitespace from all non-empty lines. */ +function dedentBlock(text: string): string { + const lines = text.split('\n') + const nonEmpty = lines.filter((l) => /\S/.test(l)) + if (!nonEmpty.length) return text + const minIndent = Math.min(...nonEmpty.map((l) => (l.match(/^([ \t]*)/) ?? ['', ''])[1].length)) + if (!minIndent) return text + return lines.map((l) => l.slice(minIndent)).join('\n') +} + +/** + * Converts StepHikeCompact components to markdown ordered lists. + * Each step becomes a numbered item: the title (from Details) is bolded on the item + * line, and the full step body (Details + Code) is dedented and appended below. + * Remaining JSX tags inside the body are later stripped by stripJsxTags. + */ +function convertStepHike(content: string): string { + return content.replace(/([\s\S]*?)<\/StepHikeCompact>/g, (_, body) => { + const items: string[] = [] + const stepRe = /]*>([\s\S]*?)<\/StepHikeCompact\.Step>/g + let stepNum = 1 + let m: RegExpExecArray | null + while ((m = stepRe.exec(body)) !== null) { + const stepBody = m[1] + const titleMatch = stepBody.match(/]+title="([^"]*)"/) + const title = titleMatch ? titleMatch[1] : '' + // Dedent the entire step body so nested JSX indentation is removed. + // Remaining component tags (Details, Code, Admonition…) are stripped later. + const inner = dedentBlock(stepBody).trim() + const item = title ? `${stepNum}. **${title}**\n\n${inner}` : `${stepNum}. ${inner}` + items.push(item) + stepNum++ + } + return items.join('\n\n') + }) +} + +/** + * Strips JSX component tags (capitalized names, dot-notation, or $-prefixed) + * while keeping their inner content. Also strips wrapper div and a elements. + * Removes MDX JSX comment blocks. Strips unnecessary leading indentation from + * non-code-block lines. + */ +function stripJsxTags(content: string): string { + // Remove MDX/JSX comments {/* ... */} + content = content.replace(/\{\/\*[\s\S]*?\*\/\}/g, '') + + // Remove self-closing JSX components: or <$Directive ... /> + content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*\/>/gs, '') + + // Remove opening JSX component tags (possibly multi-line): + content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*>/gs, '') + + // Remove closing JSX component tags: + content = content.replace(/<\/[\$A-Z][\w.]*>/g, '') + + // Remove wrapper div and a elements used structurally in MDX (carry JSX props + // like className which are not valid HTML; inner content such as img is preserved) + content = content.replace(/]*)?\s*>/g, '') + content = content.replace(/<\/div>/g, '') + content = content.replace(/]*)?\s*>/g, '') + content = content.replace(/<\/a>/g, '') + + // Split on fenced code blocks to handle prose and code separately. + // For prose (even segments): strip leading whitespace (removes JSX nesting indent). + // For code blocks (odd segments): dedent the body to remove JSX nesting indent while + // preserving relative code structure, then normalize the closing fence. + const segments = content.split(/(```[\s\S]*?```)/g) + content = segments + .map((seg, i) => { + if (i % 2 === 0) return seg.replace(/^[ \t]+/gm, '') + return seg.replace( + /^(```[^\n]*\n)([\s\S]*?)(\n[ \t]*```)$/, + (_, open, body) => open + dedentBlock(body) + '\n```' + ) + }) + .join('') + + // Collapse lines that are only whitespace to empty lines, then deduplicate blank lines + content = content.replace(/^[^\S\n]+$/gm, '') + content = content.replace(/\n{3,}/g, '\n\n').trim() + + return content +} + +async function generate() { + const files = await globby(['content/guides/**/!(_)*.mdx']) + let warnings = 0 + + await Promise.all( + files.map(async (filePath) => { + const outPath = filePath + .replace(/^content\/guides\//, 'public/docs/guides/') + .replace(/\.mdx$/, '.md') + + let output: string + try { + const raw = await fs.promises.readFile(filePath, 'utf8') + const { content: rawContent, data } = matter(raw) + + const withPartials = await inlinePartials(rawContent) + const withSteps = convertStepHike(withPartials) + const processed = stripJsxTags(withSteps) + + const header = [ + data.title ? `# ${data.title}` : '', + data.subtitle || data.description ? `\n${data.subtitle ?? data.description}` : '', + ] + .filter(Boolean) + .join('\n') + + output = header ? `${header}\n\n${processed}` : processed + } catch (err) { + warnings++ + console.warn( + `[warn] Failed to process ${filePath}: ${err instanceof Error ? err.message : err}` + ) + // Fall back to raw file content so the route still serves something + try { + output = await fs.promises.readFile(filePath, 'utf8') + } catch { + output = `` + } + } + + // content/guides/ai/vector-columns.mdx → public/docs/guides/ai/vector-columns.md + // Placing under public/docs/ ensures the file is served at /docs/guides/... + // matching the exact URL of the rendered page. + await fs.promises.mkdir(path.dirname(outPath), { recursive: true }) + await fs.promises.writeFile(outPath, output) + }) + ) + + const summary = warnings ? ` (${warnings} with warnings)` : '' + console.log(`Generated ${files.length} markdown files under public/docs/guides/${summary}`) +} + +generate() diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index ea55aafa52e..27868037dee 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -6,8 +6,19 @@ import { BASE_PATH } from '~/lib/constants' const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` +const GUIDES_PATH = `${BASE_PATH ?? ''}/guides` + export function middleware(request: NextRequest) { const url = new URL(request.url) + + // Serve pre-generated .md files before the [[...slug]] page route can intercept them + if (url.pathname.startsWith(GUIDES_PATH + '/') && url.pathname.endsWith('.md')) { + const slug = url.pathname.slice(GUIDES_PATH.length + 1, -'.md'.length) + const rewriteUrl = new URL(url) + rewriteUrl.pathname = `${BASE_PATH ?? ''}/api/guides-md/${slug}` + return NextResponse.rewrite(rewriteUrl) + } + if (!url.pathname.startsWith(REFERENCE_PATH)) { return NextResponse.next() } @@ -56,5 +67,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: '/reference/:path*', + matcher: ['/reference/:path*', '/guides/:path*'], } diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index d1419a3e104..53e8066039e 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -60,6 +60,7 @@ const nextConfig = { ], outputFileTracingIncludes: { '/api/crawlers': ['./features/docs/generated/**/*', './docs/ref/**/*'], + '/api/guides-md/**/*': ['./public/docs/guides/**/*'], '/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*', './examples/**/*'], '/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'], }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 98acb87d8d3..1f562c8413e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,6 +7,7 @@ "build": "next build", "build:analyze": "ANALYZE=true next build", "build:llms": "tsx --conditions=react-server ./scripts/llms.ts", + "build:guides-markdown": "tsx ./internals/generate-guides-markdown.ts", "build:sitemap": "tsx ./internals/generate-sitemap.ts", "clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__", "codegen:examples": "shx cp -r ../../examples ./examples", @@ -25,7 +26,7 @@ "lint": "eslint .", "lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml", "postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh", - "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", + "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:guides-markdown", "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", "preembeddings": "pnpm run codegen:references", "preinstall": "npx only-allow pnpm",