chore: refactor database advisors and database wrapper federated content (#48199)

This commit is contained in:
Jeremias Menichelli authored and GitHub committed 2026-07-24 12:26:33 +02:00
1 parent cea246d195
commit 075caf314e
13 files changed
+488 -697

No files matched your search

+2
View File
@@ -48,6 +48,8 @@ public/docs/
/content/guides/deployment/terraform/tutorial.mdx
/content/guides/deployment/ci/
/content/guides/ai/python/
/content/guides/database/extensions/wrappers/*
!/content/guides/database/extensions/wrappers/overview.mdx
# Downloaded TypeDoc dumps under spec/reference/<lib>/<ver>/. Regenerated by
# `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the
@@ -1,185 +1,13 @@
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import { linkTransform, UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { getGitHubFileContents, octokit, OCTOKIT_RETRY_OPTIONS } from '~/lib/octokit'
import { SerializeOptions } from '~/types/next-mdx-remote-serialize'
import { capitalize } from 'lodash-es'
import rehypeSlug from 'rehype-slug'
import { Heading } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'splinter'
const branch = 'main'
const docsDir = 'docs'
const meta = {
title: 'Performance and Security Advisors',
subtitle: 'Check your database for performance and security issues',
}
const generateMetadata = genGuideMeta(() => ({
pathname: '/guides/database/database-advisors',
meta,
}))
const editLink = newEditLink('supabase/splinter/tree/main/docs')
const markdownIntro = `
You can use the Database Performance and Security Advisors to check your database for issues such as missing indexes and improperly set-up RLS policies.
## Using the Advisors
In the dashboard, navigate to [Security Advisor](https://supabase.com/dashboard/project/_/database/security-advisor) and [Performance Advisor](https://supabase.com/dashboard/project/_/database/performance-advisor) under Database. The advisors run automatically. You can also manually rerun them after you've resolved issues.
`.trim()
const getBasename = (path: string) => path.split('/').at(-1)!.replace(/\.md$/, '')
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
import { genGuideMeta, getGuidesMarkdown } from '~/features/docs/GuidesMdx.utils'
const DatabaseAdvisorDocs = async () => {
let lints: Awaited<ReturnType<typeof getLints>>['lints'] = []
let lintsList: Awaited<ReturnType<typeof getLints>>['lintsList'] = []
let fetchError: Error | null = null
const data = await getGuidesMarkdown(['database', 'database-advisors'])
try {
const data = await getLints()
lints = data.lints
lintsList = data.lintsList
} catch (error) {
fetchError = error instanceof Error ? error : new Error('Unknown error fetching advisor docs')
console.error('[database-advisors] Failed to fetch advisor docs from GitHub', fetchError)
}
const options = {
mdxOptions: {
remarkPlugins: [remarkMkDocsAdmonition, remarkPyMdownTabs, [removeTitle, meta.title]],
rehypePlugins: [[linkTransform, urlTransform(lintsList)], rehypeSlug],
},
} as SerializeOptions
return (
<GuideTemplate meta={meta} editLink={editLink} pathname="/guides/database/database-advisors">
<MDXRemoteBase source={markdownIntro} />
<Heading tag="h2">Available checks</Heading>
{fetchError ? (
<Admonition type="note" title="Couldn’t load the full Advisor library">
We fetch remediation guides straight from the <code>supabase/splinter</code> repository
during the build. GitHub timed out just now, so we’re showing the overview only.
<br />
<br />
You can check back in a few minutes or browse the
{` `}
<a
className="underline decoration-dashed underline-offset-2"
href="https://github.com/supabase/splinter/tree/main/docs"
target="_blank"
rel="noreferrer"
>
latest Markdown on GitHub (opens in a new tab)
</a>
.
</Admonition>
) : (
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:m-0!" queryGroup="lint">
{lints.map((lint) => (
<TabPanel
key={lint.path}
id={lint.path}
label={capitalize(getBasename(lint.path).replace(/_/g, ' '))}
>
<section id={getBasename(lint.path)}>
<MDXRemoteBase source={lint.content} options={options} />
</section>
</TabPanel>
))}
</Tabs>
)}
</GuideTemplate>
)
return <GuideTemplate {...data!} />
}
/**
* The GitHub repo uses relative links, which don't lead to the right locations
* in docs.
*
* @param url The original link, as written in the Markdown file
* @returns The rewritten link
*/
const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = (lints) => (url) => {
try {
const placeholderHostname = 'placeholder'
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
// Don't modify a url with a FQDN or a url that's only a hash
if (hostname !== placeholderHostname || pathname === '/') {
return url
}
const relativePath = getBasename(pathname)
const section = lints.find(({ path }) => path === relativePath)
if (section) {
const url = new URL(window.location.href)
url.searchParams.set('lint', relativePath)
return url.toString()
}
// If we don't have this page in our docs, link to GitHub repo
return `https://github.com/${org}/${repo}/blob/${branch}${pathname}${hash}`
} catch (err) {
console.error('Error transforming markdown URL', err)
return url
}
}
/**
* Fetch lint remediation Markdown from external repo
*/
const getLints = async () => {
const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', {
owner: org,
repo: repo,
path: docsDir,
ref: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
request: OCTOKIT_RETRY_OPTIONS,
})
if (response.status >= 400) {
throw new Error(
`Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}`
)
}
if (!Array.isArray(response.data)) {
throw Error(
'Reading a directory, not a file. Should not reach this, solely to appease Typescript.'
)
}
const lintsList = response.data.filter(({ path }) => /docs\/\d+.+\.md$/.test(path))
const lints = await Promise.all(
lintsList.map(async ({ path }) => {
const content = await getGitHubFileContents({ org, repo, path, branch })
return {
path: getBasename(path),
content,
}
})
)
return { lints, lintsList }
}
const generateMetadata = genGuideMeta(() => getGuidesMarkdown(['database', 'database-advisors']))
export default DatabaseAdvisorDocs
export { generateMetadata }
@@ -1,345 +1,15 @@
import { readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
import {
genGuideMeta,
genGuidesStaticParams,
removeRedundantH1,
getGuidesMarkdown,
} from '~/features/docs/GuidesMdx.utils'
import { newEditLink } from '~/features/helpers.edit-link'
import { Guide, GuideArticle, GuideFooter, GuideHeader, GuideMdxContent } from '~/features/ui/guide'
// End of third-party imports
import { getEmptyArray } from '~/features/helpers.fn'
import { IS_DEV } from '~/lib/constants'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { getGitHubFileContents, octokit } from '~/lib/octokit'
import type { SerializeOptions } from '~/types/next-mdx-remote-serialize'
import { isFeatureEnabled } from 'common'
import matter from 'gray-matter'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import rehypeSlug from 'rehype-slug'
import emoji from 'remark-emoji'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'wrappers'
const docsDir = 'docs/catalog'
const externalSite = 'https://supabase.github.io/wrappers'
type DocsTagsQueryResponse = {
repository: {
refs: {
nodes: { name: string }[] | null
pageInfo: { hasNextPage: boolean; endCursor: string | null }
}
}
}
const docsTagsQuery = `
query DocsTagsQuery($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/tags/",
orderBy: { field: TAG_COMMIT_DATE, direction: DESC },
first: 5,
after: $after
) {
nodes { name }
pageInfo { hasNextPage endCursor }
}
}
}
`
async function getLatestDocsTag(after: string | null = null): Promise<string | null> {
try {
/**
* We use GraphQL as it's the only way to use `orderBy` on Github API.
*/
const {
repository: {
refs: {
nodes,
pageInfo: { hasNextPage, endCursor },
},
},
} = await octokit().graphql<DocsTagsQueryResponse>(docsTagsQuery, {
owner: org,
name: repo,
after,
})
return (
nodes?.find(({ name }) => /^docs_v\d+\.\d+\.\d+/.test(name))?.name ??
(hasNextPage && endCursor ? await getLatestDocsTag(endCursor) : null)
)
} catch (error) {
console.error(`Error fetching docs tags for wrappers federated pages: ${error}`)
return null
}
}
// Each external docs page is mapped to a local page
const pageMap = [
{
slug: 'airtable',
meta: {
title: 'Airtable',
dashboardIntegrationPath: 'airtable_wrapper',
},
remoteFile: 'airtable.md',
},
{
slug: 'auth0',
meta: {
title: 'Auth0',
dashboardIntegrationPath: 'auth0_wrapper',
},
remoteFile: 'auth0.md',
},
{
slug: 'bigquery',
meta: {
title: 'BigQuery',
dashboardIntegrationPath: 'bigquery_wrapper',
},
remoteFile: 'bigquery.md',
},
{
slug: 'cal',
meta: {
title: 'Cal.com',
dashboardIntegrationPath: 'cal_wrapper',
},
remoteFile: 'cal.md',
},
{
slug: 'calendly',
meta: {
title: 'Calendly',
dashboardIntegrationPath: 'calendly_wrapper',
},
remoteFile: 'calendly.md',
},
{
slug: 'clerk',
meta: {
title: 'Clerk',
dashboardIntegrationPath: 'clerk_wrapper',
},
remoteFile: 'clerk.md',
},
{
slug: 'clickhouse',
meta: {
title: 'ClickHouse',
dashboardIntegrationPath: 'clickhouse_wrapper',
},
remoteFile: 'clickhouse.md',
},
{
slug: 'cloudflare-d1',
meta: {
title: 'Cloudflare D1',
dashboardIntegrationPath: 'cfd1_wrapper',
},
remoteFile: 'cfd1.md',
},
{
slug: 'cognito',
meta: {
title: 'AWS Cognito',
dashboardIntegrationPath: 'cognito_wrapper',
},
remoteFile: 'cognito.md',
},
{
slug: 'duckdb',
meta: {
title: 'DuckDB',
dashboardIntegrationPath: undefined,
},
remoteFile: 'duckdb.md',
},
{
slug: 'dynamodb',
meta: {
title: 'AWS DynamoDB',
dashboardIntegrationPath: undefined,
},
remoteFile: 'dynamodb.md',
},
{
slug: 'firebase',
meta: {
title: 'Firebase',
dashboardIntegrationPath: 'firebase_wrapper',
},
remoteFile: 'firebase.md',
},
{
slug: 'gravatar',
meta: {
title: 'Gravatar',
dashboardIntegrationPath: undefined,
},
remoteFile: 'gravatar.md',
},
{
slug: 'hubspot',
meta: {
title: 'HubSpot',
dashboardIntegrationPath: 'hubspot_wrapper',
},
remoteFile: 'hubspot.md',
},
{
slug: 'iceberg',
meta: {
title: 'Iceberg',
dashboardIntegrationPath: 'iceberg_wrapper',
},
remoteFile: 'iceberg.md',
},
{
slug: 'infura',
meta: {
title: 'Infura',
dashboardIntegrationPath: undefined,
},
remoteFile: 'infura.md',
},
{
slug: 'logflare',
meta: {
title: 'Logflare',
dashboardIntegrationPath: 'logflare_wrapper',
},
remoteFile: 'logflare.md',
},
{
slug: 'mongodb',
meta: {
title: 'MongoDB',
dashboardIntegrationPath: undefined,
},
remoteFile: 'mongodb.md',
},
{
slug: 'mssql',
meta: {
title: 'MSSQL',
dashboardIntegrationPath: 'mssql_wrapper',
},
remoteFile: 'mssql.md',
},
{
slug: 'mysql',
meta: {
title: 'MySQL',
dashboardIntegrationPath: undefined,
},
remoteFile: 'mysql.md',
},
{
slug: 'notion',
meta: {
title: 'Notion',
dashboardIntegrationPath: 'notion_wrapper',
},
remoteFile: 'notion.md',
},
{
slug: 'openapi',
meta: {
title: 'OpenAPI',
dashboardIntegrationPath: undefined,
},
remoteFile: 'openapi.md',
},
{
slug: 'orb',
meta: {
title: 'Orb',
dashboardIntegrationPath: 'orb_wrapper',
},
remoteFile: 'orb.md',
},
{
slug: 'paddle',
meta: {
title: 'Paddle',
dashboardIntegrationPath: 'paddle_wrapper',
},
remoteFile: 'paddle.md',
},
{
slug: 'redis',
meta: {
title: 'Redis',
dashboardIntegrationPath: 'redis_wrapper',
},
remoteFile: 'redis.md',
},
{
slug: 's3',
meta: {
title: 'AWS S3',
dashboardIntegrationPath: 's3_wrapper',
},
remoteFile: 's3.md',
},
{
slug: 's3_vectors',
meta: {
title: 'AWS S3 Vectors',
dashboardIntegrationPath: 's3_vectors_wrapper',
},
remoteFile: 's3vectors.md',
},
{
slug: 'shopify',
meta: {
title: 'Shopify',
dashboardIntegrationPath: undefined,
},
remoteFile: 'shopify.md',
},
{
slug: 'slack',
meta: {
title: 'Slack',
dashboardIntegrationPath: undefined,
},
remoteFile: 'slack.md',
},
{
slug: 'snowflake',
meta: {
title: 'Snowflake',
dashboardIntegrationPath: 'snowflake_wrapper',
},
remoteFile: 'snowflake.md',
},
{
slug: 'stripe',
meta: {
title: 'Stripe',
dashboardIntegrationPath: 'stripe_wrapper',
},
remoteFile: 'stripe.md',
},
]
interface Params {
slug?: string[]
}
type Params = { slug?: string[] }
const WrappersDocs = async (props: { params: Promise<Params> }) => {
if (!isFeatureEnabled('docs:fdw')) {
@@ -347,191 +17,18 @@ const WrappersDocs = async (props: { params: Promise<Params> }) => {
}
const params = await props.params
const { isExternal, meta, assetsBaseUrl, ...data } = await getContent(params)
const slug = ['database', 'extensions', 'wrappers', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
// Create a combined URL transformer that handles both regular URLs and asset URLs
const combinedUrlTransformer: UrlTransformFunction = (url, node) => {
// First try assets URL transformation (starts with ../assets/)
const transformedUrl = assetUrlTransform(url, assetsBaseUrl)
// If URL wasn't changed proceed with regular URL transformation
if (transformedUrl === url) {
return urlTransform(url, node)
}
return transformedUrl
}
const options = isExternal
? ({
mdxOptions: {
remarkPlugins: [
remarkMkDocsAdmonition,
emoji,
remarkPyMdownTabs,
[removeTitle, meta.title],
],
rehypePlugins: [[linkTransform, combinedUrlTransformer], rehypeSlug],
},
} as SerializeOptions)
: undefined
const dashboardIntegrationURL = getDashboardIntegrationURL(meta.dashboardIntegrationPath)
return (
<Guide meta={meta}>
<GuideArticle>
<GuideHeader />
{dashboardIntegrationURL && (
<Admonition type="tip" className="mb-4">
<p>You can enable the {meta.title} wrapper right from the Supabase dashboard.</p>
<Button asChild>
<Link href={dashboardIntegrationURL} className="no-underline">
Open wrapper in dashboard
</Link>
</Button>
</Admonition>
)}
<GuideMdxContent content={data.content} mdxOptions={options} />
<GuideFooter editLink={data.editLink} />
</GuideArticle>
</Guide>
)
return <GuideTemplate {...data!} />
}
/**
* Fetch markdown from external repo
*/
const getContent = async (params: Params) => {
const federatedPage = pageMap.find(
({ slug }) => params && slug && params.slug && slug === params.slug.at(0)
)
let isExternal: boolean
let meta: any
let content: string
let editLink: string
let assetsBaseUrl: string = ''
if (!federatedPage) {
isExternal = false
editLink = `supabase/supabase/apps/docs/content/guides/database/extensions/wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}.mdx`
const rawContent = await readFile(
join(
GUIDES_DIRECTORY,
'database',
'extensions',
`wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}.mdx`
),
'utf-8'
)
;({ data: meta, content } = matter(rawContent))
if (!isValidGuideFrontmatter(meta)) {
throw Error(`Expected valid frontmatter, got ${JSON.stringify(meta, null, 2)}`)
}
} else {
isExternal = true
let remoteFile: string
;({ remoteFile, meta } = federatedPage)
const tag = await getLatestDocsTag()
if (!tag) {
throw new Error('No latest docs tag found for federated wrappers pages')
}
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
let rawContent = await getGitHubFileContents({
org,
repo,
path: `${docsDir}/${remoteFile}`,
branch: tag,
})
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
const { content: contentWithoutFrontmatter } = matter(rawContent)
content = removeRedundantH1(contentWithoutFrontmatter)
}
return {
pathname:
`/guides/database/extensions/wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}` satisfies `/${string}`,
isExternal,
editLink: newEditLink(editLink),
meta,
content,
assetsBaseUrl,
}
}
const getDashboardIntegrationURL = (wrapperPath?: string) => {
return wrapperPath
? `https://supabase.com/dashboard/project/_/integrations/${wrapperPath}/overview`
: null
}
const assetUrlTransform = (url: string, baseUrl: string): string => {
const assetPattern = /(\.\.\/)+assets\//
if (assetPattern.test(url)) {
return url.replace(assetPattern, baseUrl)
}
return url
}
const urlTransform: UrlTransformFunction = (url) => {
try {
const externalSiteUrl = new URL(externalSite)
const placeholderHostname = 'placeholder'
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
// Don't modify a url with a FQDN or a url that's only a hash
if (hostname !== placeholderHostname || pathname === '/') {
return url
}
const relativePage = (
pathname.endsWith('.md')
? pathname.replace(/\.md$/, '')
: relative(externalSiteUrl.pathname, pathname)
).replace(/^\//, '')
const page = pageMap.find(({ remoteFile }) => `${relativePage}.md` === remoteFile)
// If we have a mapping for this page, use the mapped path
if (page) {
return page.slug + hash
}
// If we don't have this page in our docs, link to original docs
return `${externalSite}/${relativePage}${hash}`
} catch (err) {
console.error('Error transforming markdown URL', err)
return url
}
}
const generateStaticParams = async () => {
if (IS_DEV) {
return []
}
const mdxPaths = await genGuidesStaticParams('database/extensions/wrappers')()
const federatedPaths = pageMap.map(({ slug }) => ({
slug: [slug],
}))
return [...mdxPaths, ...federatedPaths]
}
const generateMetadata = genGuideMeta(getContent)
const generateStaticParams = !IS_DEV
? genGuidesStaticParams('database/extensions/wrappers')
: getEmptyArray
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
getGuidesMarkdown(['database', 'extensions', 'wrappers', ...(params.slug ?? [])])
)
export default WrappersDocs
export { generateMetadata, generateStaticParams }
@@ -0,0 +1,34 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import { GENERATED_DIRECTORY } from '~/lib/docs'
import { capitalize } from 'lodash-es'
interface Lint {
path: string
content: string
}
export async function DatabaseAdvisorsIndex() {
let lints: Lint[] = []
try {
const raw = await readFile(join(GENERATED_DIRECTORY, 'database-advisors.json'), 'utf-8')
lints = JSON.parse(raw)
} catch (error) {
throw error('[database-advisors] Failed to read generated advisor docs', error)
}
return (
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:m-0!" queryGroup="lint">
{lints.map((lint) => (
<TabPanel key={lint.path} id={lint.path} label={capitalize(lint.path.replace(/_/g, ' '))}>
<section id={lint.path}>
<MDXRemoteBase source={lint.content} />
</section>
</TabPanel>
))}
</Tabs>
)
}
@@ -0,0 +1,20 @@
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
export function WrapperDashboardIntegration({ title, path }: { title: string; path: string }) {
return (
<Admonition type="tip" className="mb-4">
<p>You can enable the {title} wrapper right from the Supabase dashboard.</p>
<Button asChild>
<Link
href={`https://supabase.com/dashboard/project/_/integrations/${path}/overview`}
className="no-underline"
>
Open wrapper in dashboard
</Link>
</Button>
</Admonition>
)
}
@@ -0,0 +1,14 @@
---
title: Performance and Security Advisors
subtitle: Check your database for performance and security issues
---
You can use the Database Performance and Security Advisors to check your database for issues such as missing indexes and improperly set-up RLS policies.
## Using the advisors
In the dashboard, navigate to [Security Advisor](/dashboard/project/_/database/security-advisor) and [Performance Advisor](dashboard/project/_/database/performance-advisor) under Database. The advisors run automatically. You can also manually rerun them after you've resolved issues.
## Available checks
<DatabaseAdvisorsIndex />
@@ -7,6 +7,7 @@ import ButtonCard from '~/components/ButtonCard'
import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable'
import { ContentListings } from '~/components/ContentListings'
import { CustomContent } from '~/components/CustomContent'
import { DatabaseAdvisorsIndex } from '~/components/DatabaseAdvisorsIndex'
import { Extensions } from '~/components/Extensions'
import Image, { type ImageProps } from '~/components/Image'
import { McpCiConfigBlock } from '~/components/McpCiConfigBlock'
@@ -20,6 +21,7 @@ import { RegionsList, SmartRegionsList } from '~/components/RegionsList'
import { SharedData } from '~/components/SharedData'
import StepHikeCompact from '~/components/StepHikeCompact'
import { TerraformProviderSchema } from '~/components/TerraformProviderSchema'
import { WrapperDashboardIntegration } from '~/components/WrapperDashboardIntegration'
import { CodeSampleDummy, CodeSampleWrapper } from '~/features/directives/CodeSample.client'
import { NamedCodeBlock } from '~/features/directives/CodeTabs.components'
import { MdxAnchor } from '~/features/docs/MdxAnchor'
@@ -82,6 +84,7 @@ const components = {
ComputeDiskLimitsTable,
CustomContent,
ContentListings,
DatabaseAdvisorsIndex,
ErrorCodes,
Extensions,
GlassPanel,
@@ -113,6 +116,7 @@ const components = {
Tabs,
TabPanel,
TerraformProviderSchema,
WrapperDashboardIntegration,
InfoTooltip,
a: MdxAnchor,
h2: (props: ComponentPropsWithoutRef<'h2'>) => (
@@ -20,6 +20,7 @@ import { AuthProviders } from './markdown-schema/AuthProviders'
import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable'
import { ContentListings } from './markdown-schema/ContentListings'
import { CustomContent } from './markdown-schema/CustomContent'
import { DatabaseAdvisorsIndex } from './markdown-schema/DatabaseAdvisorsIndex'
import { ErrorCodes } from './markdown-schema/ErrorCodes'
import { IconCheck, IconX } from './markdown-schema/Icons'
import { Image } from './markdown-schema/Image'
@@ -36,6 +37,7 @@ import { SharedData } from './markdown-schema/SharedData'
import { StepHike } from './markdown-schema/StepHike'
import { TabPanel } from './markdown-schema/TabPanel'
import { TerraformProviderSchema } from './markdown-schema/TerraformProviderSchema'
import { WrapperDashboardIntegration } from './markdown-schema/WrapperDashboardIntegration'
import {
collectMarkdownSources,
type FrontmatterFormat,
@@ -173,6 +175,7 @@ const SCHEMA: ComponentSchema = {
AuthProviders,
ComputeDiskLimitsTable,
CustomContent,
DatabaseAdvisorsIndex,
ErrorCodes,
Link,
McpCiConfigBlock,
@@ -191,6 +194,7 @@ const SCHEMA: ComponentSchema = {
NavData,
SharedData,
TerraformProviderSchema,
WrapperDashboardIntegration,
}
function parseFrontmatter(raw: string, frontmatter: FrontmatterFormat) {
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
const ADVISORS_PATH = path.join(process.cwd(), 'features/docs/generated/database-advisors.json')
interface Lint {
path: string
content: string
}
export const DatabaseAdvisorsIndex = (): string => {
const lints: Lint[] = JSON.parse(readFileSync(ADVISORS_PATH, 'utf-8'))
return lints
.map(
(lint) => `### ${lint.path}
${lint.content}`
)
.join('\n\n')
}
@@ -0,0 +1,9 @@
export const WrapperDashboardIntegration = ({
props,
}: {
props: Record<string, unknown>
}): string => {
const title = props.title ? String(props.title) : 'this'
const path = String(props.path ?? '')
return `> You can enable the ${title} wrapper right from the [Supabase dashboard](https://supabase.com/dashboard/project/_/integrations/${path}/overview).`
}
@@ -16,6 +16,7 @@ import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { gfm } from 'micromark-extension-gfm'
import { mdxjs } from 'micromark-extension-mdxjs'
import emoji from 'remark-emoji'
import { visit } from 'unist-util-visit'
import type { FederatedContentSource, FederatedPage } from './types'
@@ -34,6 +35,9 @@ const STRINGIFY_OPTIONS = {
listItemIndent: 'one' as const,
fences: true,
}
// remark-emoji's attacher is typed with a unified `this: Processor` context
// it never actually uses; cast it to the plain transformer factory it is.
const emojiTransform = (emoji as unknown as () => (tree: any) => void)()
/**
* Discovers every `FederatedContentSource` under `./sources`.
@@ -56,11 +60,76 @@ function remotePath(source: FederatedContentSource, page: FederatedPage): string
return page.useRoot ? page.remoteFile : `${source.docsDir}/${page.remoteFile}`
}
type LatestTagQueryResponse = {
repository: {
refs: {
nodes: { name: string }[] | null
pageInfo: { hasNextPage: boolean; endCursor: string | null }
}
}
}
const LATEST_TAG_QUERY = `
query LatestTagQuery($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/tags/",
orderBy: { field: TAG_COMMIT_DATE, direction: DESC },
first: 20,
after: $after
) {
nodes { name }
pageInfo { hasNextPage endCursor }
}
}
}
`
/**
* Resolves a source's `latestTag.pattern` to the newest matching tag name.
* GraphQL is required here since the REST API can't order tags by date.
*/
async function resolveLatestTag(
source: FederatedContentSource,
after: string | null = null
): Promise<string> {
const pattern = new RegExp(source.latestTag!.pattern)
const {
repository: {
refs: {
nodes,
pageInfo: { hasNextPage, endCursor },
},
},
} = await octokit().graphql<LatestTagQueryResponse>(LATEST_TAG_QUERY, {
owner: source.org,
name: source.repo,
after,
})
const tag = nodes?.find(({ name }) => pattern.test(name))?.name
if (tag) return tag
if (hasNextPage && endCursor) return resolveLatestTag(source, endCursor)
throw new Error(
`No tag matching ${source.latestTag!.pattern} found for ${source.org}/${source.repo}`
)
}
/**
* Rewrites a link URL: pages mapped in `source.pageMap` go to their local
* `/guides/<section>` route, everything else falls back to `externalSite`.
*/
function transformUrl(source: FederatedContentSource, url: string): string {
const assetPattern = /(\.\.\/)+assets\//
if (source.assetsDir && assetPattern.test(url)) {
return url.replace(
assetPattern,
`https://raw.githubusercontent.com/${source.org}/${source.repo}/${source.branch}/${source.assetsDir}/`
)
}
try {
const placeholderHostname = 'placeholder'
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
@@ -104,9 +173,12 @@ async function fetchPage(source: FederatedContentSource, page: FederatedPage): P
branch: source.branch,
})
const tree = fromMarkdown(raw, PARSE_OPTIONS)
// Strip the source file's own frontmatter, if any; we build our own from
// `page.meta` below rather than merging it in.
const tree = fromMarkdown(matter(raw).content, PARSE_OPTIONS)
remarkMkDocsAdmonition()(tree)
remarkPyMdownTabs()(tree)
emojiTransform(tree)
if (page.dropLeadingHeading) {
const [firstNode] = tree.children
if (firstNode?.type === 'heading' && firstNode.depth === 1) tree.children.splice(0, 1)
@@ -116,7 +188,10 @@ async function fetchPage(source: FederatedContentSource, page: FederatedPage): P
visit(tree, ['link', 'image', 'definition'], (node: any) => {
node.url = transformUrl(source, node.url)
})
const content = toMarkdown(tree, STRINGIFY_OPTIONS).trim()
let content = toMarkdown(tree, STRINGIFY_OPTIONS).trim()
if (page.meta.dashboardIntegrationPath) {
content = `<WrapperDashboardIntegration title="${page.meta.title}" path="${page.meta.dashboardIntegrationPath}" />\n\n${content}`
}
const frontmatter: Record<string, string> = {
title: page.meta.title,
@@ -146,7 +221,11 @@ async function fetchRawFile(
await writeFile(join(GENERATED_DIRECTORY, rawFile.outFile), content)
}
async function fetchSource(source: FederatedContentSource): Promise<void> {
async function fetchSource(baseSource: FederatedContentSource): Promise<void> {
const source = baseSource.latestTag
? { ...baseSource, branch: await resolveLatestTag(baseSource) }
: baseSource
await mkdir(join(GUIDES_DIRECTORY, source.section), { recursive: true })
await Promise.all([
@@ -214,10 +293,99 @@ async function fetchAiSkills(): Promise<void> {
await writeFile(join(GENERATED_DIRECTORY, 'ai-skills.json'), JSON.stringify(skills, null, 2))
}
const SPLINTER_REPO = {
org: 'supabase',
repo: 'splinter',
branch: 'main',
docsDir: 'docs',
}
/**
* Rewrites a splinter lint doc's link: cross-references to other lints
* become `?lint=<path>` (matching `Tabs`'s `queryGroup` tab-switching), and
* everything else falls back to viewing the file on GitHub.
*/
function splinterUrlTransform(lintPaths: string[]) {
return (url: string): string => {
try {
const placeholderHostname = 'placeholder'
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
if (hostname !== placeholderHostname || pathname === '/') {
return url
}
const basename = pathname.split('/').at(-1)!.replace(/\.md$/, '')
if (lintPaths.includes(basename)) {
return `?lint=${basename}${hash}`
}
return `https://github.com/${SPLINTER_REPO.org}/${SPLINTER_REPO.repo}/blob/${SPLINTER_REPO.branch}${pathname}${hash}`
} catch (err) {
console.error('Error transforming markdown URL', err)
return url
}
}
}
/**
* Lists the numbered lint docs in the splinter repo, transforms each the
* same way as a guide page, and writes them to
* `features/docs/generated/database-advisors.json` for the
* `DatabaseAdvisorsIndex` component to read.
*/
async function fetchDatabaseAdvisors(): Promise<void> {
const { data: contents } = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', {
owner: SPLINTER_REPO.org,
repo: SPLINTER_REPO.repo,
path: SPLINTER_REPO.docsDir,
ref: SPLINTER_REPO.branch,
request: OCTOKIT_RETRY_OPTIONS,
})
if (!Array.isArray(contents)) {
throw new Error('Expected directory listing from GitHub splinter repo')
}
const lintFiles = contents.filter(({ path }) => /docs\/\d+.+\.md$/.test(path))
const lintPaths = lintFiles.map(({ path }) => path.split('/').at(-1)!.replace(/\.md$/, ''))
const urlTransform = splinterUrlTransform(lintPaths)
const lints = await Promise.all(
lintFiles.map(async ({ path }) => {
const raw = await getGitHubFileContents({
org: SPLINTER_REPO.org,
repo: SPLINTER_REPO.repo,
path,
branch: SPLINTER_REPO.branch,
})
const tree = fromMarkdown(matter(raw).content, PARSE_OPTIONS)
remarkMkDocsAdmonition()(tree)
remarkPyMdownTabs()(tree)
visit(tree, ['link', 'image', 'definition'], (node: any) => {
node.url = urlTransform(node.url)
})
return {
path: path.split('/').at(-1)!.replace(/\.md$/, ''),
content: toMarkdown(tree, STRINGIFY_OPTIONS).trim(),
}
})
)
await mkdir(GENERATED_DIRECTORY, { recursive: true })
await writeFile(
join(GENERATED_DIRECTORY, 'database-advisors.json'),
JSON.stringify(lints, null, 2)
)
}
async function fetchFederatedContent() {
const sources = await loadSources()
await Promise.all([...sources.map(fetchSource), fetchAiSkills()])
await Promise.all([...sources.map(fetchSource), fetchAiSkills(), fetchDatabaseAdvisors()])
const pageCount = sources.reduce((sum, source) => sum + source.pageMap.length, 0)
console.log(
@@ -0,0 +1,173 @@
import type { FederatedContentSource } from '../types'
// We fetch these docs at build time from an external repo
const wrappers: FederatedContentSource = {
section: 'database/extensions/wrappers',
org: 'supabase',
repo: 'wrappers',
branch: 'main',
// The wrappers repo tags its docs releases separately from code.
latestTag: { pattern: '^docs_v\\d+\\.\\d+\\.\\d+' },
docsDir: 'docs/catalog',
assetsDir: 'docs/assets',
externalSite: 'https://supabase.github.io/wrappers',
pageMap: [
{
slug: 'airtable',
meta: { title: 'Airtable', dashboardIntegrationPath: 'airtable_wrapper' },
remoteFile: 'airtable.md',
},
{
slug: 'auth0',
meta: { title: 'Auth0', dashboardIntegrationPath: 'auth0_wrapper' },
remoteFile: 'auth0.md',
},
{
slug: 'bigquery',
meta: { title: 'BigQuery', dashboardIntegrationPath: 'bigquery_wrapper' },
remoteFile: 'bigquery.md',
},
{
slug: 'cal',
meta: { title: 'Cal.com', dashboardIntegrationPath: 'cal_wrapper' },
remoteFile: 'cal.md',
},
{
slug: 'calendly',
meta: { title: 'Calendly', dashboardIntegrationPath: 'calendly_wrapper' },
remoteFile: 'calendly.md',
},
{
slug: 'clerk',
meta: { title: 'Clerk', dashboardIntegrationPath: 'clerk_wrapper' },
remoteFile: 'clerk.md',
},
{
slug: 'clickhouse',
meta: { title: 'ClickHouse', dashboardIntegrationPath: 'clickhouse_wrapper' },
remoteFile: 'clickhouse.md',
},
{
slug: 'cloudflare-d1',
meta: { title: 'Cloudflare D1', dashboardIntegrationPath: 'cfd1_wrapper' },
remoteFile: 'cfd1.md',
},
{
slug: 'cognito',
meta: { title: 'AWS Cognito', dashboardIntegrationPath: 'cognito_wrapper' },
remoteFile: 'cognito.md',
},
{
slug: 'duckdb',
meta: { title: 'DuckDB' },
remoteFile: 'duckdb.md',
},
{
slug: 'dynamodb',
meta: { title: 'AWS DynamoDB' },
remoteFile: 'dynamodb.md',
},
{
slug: 'firebase',
meta: { title: 'Firebase', dashboardIntegrationPath: 'firebase_wrapper' },
remoteFile: 'firebase.md',
},
{
slug: 'gravatar',
meta: { title: 'Gravatar' },
remoteFile: 'gravatar.md',
},
{
slug: 'hubspot',
meta: { title: 'HubSpot', dashboardIntegrationPath: 'hubspot_wrapper' },
remoteFile: 'hubspot.md',
},
{
slug: 'iceberg',
meta: { title: 'Iceberg', dashboardIntegrationPath: 'iceberg_wrapper' },
remoteFile: 'iceberg.md',
},
{
slug: 'infura',
meta: { title: 'Infura' },
remoteFile: 'infura.md',
},
{
slug: 'logflare',
meta: { title: 'Logflare', dashboardIntegrationPath: 'logflare_wrapper' },
remoteFile: 'logflare.md',
},
{
slug: 'mongodb',
meta: { title: 'MongoDB' },
remoteFile: 'mongodb.md',
},
{
slug: 'mssql',
meta: { title: 'MSSQL', dashboardIntegrationPath: 'mssql_wrapper' },
remoteFile: 'mssql.md',
},
{
slug: 'mysql',
meta: { title: 'MySQL' },
remoteFile: 'mysql.md',
},
{
slug: 'notion',
meta: { title: 'Notion', dashboardIntegrationPath: 'notion_wrapper' },
remoteFile: 'notion.md',
},
{
slug: 'openapi',
meta: { title: 'OpenAPI' },
remoteFile: 'openapi.md',
},
{
slug: 'orb',
meta: { title: 'Orb', dashboardIntegrationPath: 'orb_wrapper' },
remoteFile: 'orb.md',
},
{
slug: 'paddle',
meta: { title: 'Paddle', dashboardIntegrationPath: 'paddle_wrapper' },
remoteFile: 'paddle.md',
},
{
slug: 'redis',
meta: { title: 'Redis', dashboardIntegrationPath: 'redis_wrapper' },
remoteFile: 'redis.md',
},
{
slug: 's3',
meta: { title: 'AWS S3', dashboardIntegrationPath: 's3_wrapper' },
remoteFile: 's3.md',
},
{
slug: 's3_vectors',
meta: { title: 'AWS S3 Vectors', dashboardIntegrationPath: 's3_vectors_wrapper' },
remoteFile: 's3vectors.md',
},
{
slug: 'shopify',
meta: { title: 'Shopify' },
remoteFile: 'shopify.md',
},
{
slug: 'slack',
meta: { title: 'Slack' },
remoteFile: 'slack.md',
},
{
slug: 'snowflake',
meta: { title: 'Snowflake', dashboardIntegrationPath: 'snowflake_wrapper' },
remoteFile: 'snowflake.md',
},
{
slug: 'stripe',
meta: { title: 'Stripe', dashboardIntegrationPath: 'stripe_wrapper' },
remoteFile: 'stripe.md',
},
],
}
export default wrappers
@@ -9,6 +9,8 @@ export interface FederatedPage {
subtitle?: string
description?: string
tocVideo?: string
/** Path segment for the "Enable in dashboard" integration link, if any. */
dashboardIntegrationPath?: string
}
/** Path of the file in the remote repo, relative to `docsDir`. */
remoteFile: string
@@ -39,6 +41,21 @@ export interface FederatedContentSource {
docsDir: string
/** Public site the remote docs are also published to, used to resolve unmapped links. */
externalSite: string
/**
* Resolve `branch` to the latest matching release tag at fetch time,
* instead of using a fixed branch name. Useful for repos that tag docs
* releases separately from code (e.g. `docs_v1.2.3`).
*/
latestTag?: {
/** Regex (as a string) tested against each tag name, newest first. */
pattern: string
}
/**
* Directory (relative to the repo root) holding image assets referenced by
* pages via `../assets/...`-style relative links. When set, such links are
* rewritten to `https://raw.githubusercontent.com/<org>/<repo>/<branch>/<assetsDir>/...`.
*/
assetsDir?: string
/**
* Unmapped links fall back to `${externalSite}/${relativePath}${hash}`. Set
* this when `externalSite` is a source-controlled host (e.g. a GitHub blob