Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/ui-library/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ next-env.d.ts
.contentlayer

# Auto-generated on every Vercel build by `pnpm build:llms` -> scripts/build-llms-txt.ts
public/llms.txt
public/llms.txt

# Generated page markdown, including the slug allowlist imported by middleware.
# Regenerated by `pnpm build:markdown` during predev and build.
public/markdown/
7 changes: 7 additions & 0 deletions apps/ui-library/app/(app)/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,17 @@ export async function generateMetadata(props: DocPageProps): Promise<Metadata> {
return {}
}

const markdownPath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? '/library'}/docs/${doc.slugAsParams}.md`

const metadata: Metadata = {
...mainMetadata,
title: doc.title,
description: doc.description,
alternates: {
types: {
'text/markdown': `https://supabase.com${markdownPath}`,
},
},
openGraph: {
...mainMetadata.openGraph,
title: doc.title,
Expand Down
43 changes: 43 additions & 0 deletions apps/ui-library/app/api/docs-md/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { promises as fs } from 'node:fs'
import path from 'node: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/markdown/docs')
const filePath = path.join(baseDir, `${slug.join('/')}.md`)

if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
return markdownNotFound(slug)
}

try {
const content = await fs.readFile(filePath, 'utf-8')
return new NextResponse(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
Vary: 'Accept',
},
})
} catch {
return markdownNotFound(slug)
}
}

function markdownNotFound(slug: string[]) {
const pagePath = slug.join('/')
const markdown = `# 404 - Page Not Found

The page \`/library/docs/${pagePath}.md\` does not exist.

See also: [Supabase Library](https://supabase.com/library/llms.txt)
`
return new NextResponse(markdown, {
status: 404,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'no-store',
},
})
}
44 changes: 44 additions & 0 deletions apps/ui-library/lib/install-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
export type ShadcnFramework = 'react' | 'vue'

export function getShadcnFramework(name: string): ShadcnFramework {
return name.includes('vue') || name.includes('nuxtjs') ? 'vue' : 'react'
}

export function getRegistryBaseUrl(env = process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV): string {
if (env === 'production') {
// Special alias for production, added in https://github.com/shadcn-ui/ui/pull/8161
return '@supabase'
}
if (env === 'preview') {
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
}
return 'http://localhost:3004'
}

export function getRegistryComponentPath(
name: string,
env = process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV
): string {
if (env === 'production') {
return `/${name}`
}
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/r/${name}.json`
}

export function getInstallCommands(
name: string,
options?: { framework?: ShadcnFramework; production?: boolean }
): Record<PackageManager, string> {
const framework = options?.framework ?? getShadcnFramework(name)
const env = options?.production ? 'production' : process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV
const specifier = `${getRegistryBaseUrl(env)}${getRegistryComponentPath(name, env)}`
const cli = framework === 'vue' ? 'shadcn-vue@latest' : 'shadcn@latest'

return {
npm: `npx ${cli} add ${specifier}`,
pnpm: `pnpm dlx ${cli} add ${specifier}`,
yarn: `yarn dlx ${cli} add ${specifier}`,
bun: `bunx --bun ${cli} add ${specifier}`,
}
}
50 changes: 50 additions & 0 deletions apps/ui-library/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { NextRequest } from 'next/server'

import { middleware } from './middleware'

const DOCS_URL = 'https://supabase.com/library/docs/nextjs/client'

function request(url: string, headers: Record<string, string>) {
return new NextRequest(new Request(url, { headers }))
}

describe('middleware markdown negotiation', () => {
it('rewrites to the markdown route when Accept prefers markdown', () => {
const response = middleware(request(DOCS_URL, { accept: 'text/markdown' }))

assert.equal(
response.headers.get('x-middleware-rewrite'),
'https://supabase.com/library/api/docs-md/nextjs/client'
)
})

it('406s when Accept rejects both html and markdown', () => {
const response = middleware(request(DOCS_URL, { accept: 'application/json' }))

assert.equal(response.status, 406)
})

it('passes Server Action requests through untouched', () => {
// Server Actions POST to the page URL with `Accept: text/x-component`.
const response = middleware(
request(DOCS_URL, {
accept: 'text/x-component',
'next-action': '7f1e0c0d5a1b2c3d4e5f60718293a4b5c6d7e8f900',
})
)

assert.equal(response.status, 200)
assert.equal(response.headers.get('x-middleware-rewrite'), null)
})

it('serves html to browsers', () => {
const response = middleware(
request(DOCS_URL, { accept: 'text/html,application/xhtml+xml,*/*;q=0.8' })
)

assert.equal(response.status, 200)
assert.equal(response.headers.get('x-middleware-rewrite'), null)
})
})
50 changes: 50 additions & 0 deletions apps/ui-library/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { negotiateMarkdown } from 'common/markdown-negotiation'
import { NextResponse, type NextRequest } from 'next/server'

import MARKDOWN_SLUGS from '@/public/markdown/manifest.json'

const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/library'
const DOCS_PATH = `${BASE_PATH}/docs`
const MARKDOWN_SLUG_SET = new Set(MARKDOWN_SLUGS)

export function middleware(request: NextRequest) {
const url = new URL(request.url)
const { pathname } = url

if (!pathname.startsWith(`${DOCS_PATH}/`)) {
return NextResponse.next()
}

// Server Actions POST to the page URL with `Accept: text/x-component`, which
// matches none of the types this route negotiates and would 406. Let Next.js
// handle them.
if (request.headers.get('next-action')) {
return NextResponse.next()
}

const isMdSuffix = pathname.endsWith('.md')
const slug = pathname.replace(`${DOCS_PATH}/`, '').replace(/\.md$/, '')
const decision = negotiateMarkdown(
{ acceptHeader: request.headers.get('accept') ?? '' },
{ hasMarkdownVariant: MARKDOWN_SLUG_SET.has(slug), isMarkdownSuffix: isMdSuffix }
)

if (decision === 'not-acceptable') {
return new NextResponse('Not Acceptable', {
status: 406,
headers: { 'Cache-Control': 'no-store', Vary: 'Accept' },
})
}

if (decision === 'markdown') {
const rewriteUrl = new URL(url)
rewriteUrl.pathname = `${BASE_PATH}/api/docs-md/${slug}`
return NextResponse.rewrite(rewriteUrl)
}

return NextResponse.next()
}

export const config = {
matcher: ['/docs/:path*'],
}
3 changes: 3 additions & 0 deletions apps/ui-library/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const nextConfig = {
},
},
},
outputFileTracingIncludes: {
'/api/docs-md/**/*': ['./public/markdown/docs/**/*'],
},
async redirects() {
return [
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
Expand Down
15 changes: 14 additions & 1 deletion apps/ui-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --port 3004",
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && pnpm run build:app",
"predev": "pnpm run build:markdown",
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:markdown && pnpm run build:llms && pnpm run build:app",
"build:registry": "rimraf -G public/r/* && tsx ./scripts/build-registry.mts && shadcn build public/r/registry.json && tsx scripts/clean-registry.ts",
"build:markdown": "tsx ./scripts/build-markdown.ts",
"build:llms": "tsx ./scripts/build-llms-txt.ts",
"test:markdown": "tsx --test scripts/library-mdx-to-markdown.test.ts",
"test:middleware": "pnpm run build:markdown && tsx --test middleware.test.ts",
"build:app": "next build --turbopack",
"start": "next start",
"lint": "eslint .",
Expand Down Expand Up @@ -78,12 +82,21 @@
"@tanstack/react-start": "catalog:",
"@types/common-tags": "^1.8.4",
"@types/lodash": "^4.17.16",
"@types/mdast": "^3.0.15",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript/native": "catalog:",
"config": "workspace:^",
"gray-matter": "^4.0.3",
"lodash": "catalog:",
"mdast-util-from-markdown": "^1.3.1",
"mdast-util-gfm": "^2.0.2",
"mdast-util-mdx": "^2.0.1",
"mdast-util-mdx-jsx": "^2.1.4",
"mdast-util-to-markdown": "^1.5.0",
"mdast-util-toc": "^6.1.1",
"micromark-extension-gfm": "^2.0.3",
"micromark-extension-mdxjs": "^1.0.1",
"postcss": "catalog:",
"react-dropzone": "^14.3.8",
"react-router": "^7.13.2",
Expand Down
4 changes: 2 additions & 2 deletions apps/ui-library/scripts/build-llms-txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@ let content = `# Supabase Library
Last updated: ${new Date().toISOString()}

## Overview
Library of components for your project. The components integrate with Supabase and are shadcn compatible.
Library of components for your project. The components integrate with Supabase and are shadcn compatible. Each docs page is also available as markdown for agents (append .md to the URL).

## Docs
`

// Add documentation links
for (const doc of docs) {
const url = `${BASE_URL}/${doc.path}`
const url = `${BASE_URL}/${doc.path}.md`
content += `- [${doc.title}](${url})`
if (doc.description) {
content += `\n - ${doc.description}`
Expand Down
65 changes: 65 additions & 0 deletions apps/ui-library/scripts/build-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import fs from 'node:fs/promises'
import path from 'node:path'

import { transformLibraryMdx } from './library-mdx-to-markdown'

const CONTENT_DIR = path.join(process.cwd(), 'content', 'docs')
const OUTPUT_DIR = path.join(process.cwd(), 'public', 'markdown', 'docs')
const MANIFEST_PATH = path.join(process.cwd(), 'public', 'markdown', 'manifest.json')

async function collectMdxFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files: string[] = []

for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await collectMdxFiles(fullPath)))
} else if (entry.name.endsWith('.mdx')) {
files.push(fullPath)
}
}

return files.sort((a, b) => a.localeCompare(b))
}

async function generate() {
const sources = await collectMdxFiles(CONTENT_DIR)
const slugs: string[] = []

// Wipe first so pages that were renamed or deleted don't leave stale markdown
// behind — public/markdown is served directly, and the files outlive the manifest.
await fs.rm(OUTPUT_DIR, { recursive: true, force: true })
await fs.mkdir(OUTPUT_DIR, { recursive: true })

for (const sourceFile of sources) {
const relativePath = path.relative(CONTENT_DIR, sourceFile)
const slug = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/')
const outPath = path.join(OUTPUT_DIR, `${slug}.md`)
const raw = await fs.readFile(sourceFile, 'utf8')

let output: string
try {
output = transformLibraryMdx(raw)
} catch (err) {
throw new Error(
`Failed to process ${sourceFile}: ${err instanceof Error ? err.message : err}`,
{ cause: err }
)
}

await fs.mkdir(path.dirname(outPath), { recursive: true })
await fs.writeFile(outPath, output)
slugs.push(slug)
}

await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true })
await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(slugs, null, 2)}\n`)

console.log(`Generated ${slugs.length} markdown files under public/markdown/docs/`)
}

generate().catch((error) => {
console.error(error)
process.exit(1)
})
Loading
Loading