diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 59e71b63a89..89db757aa41 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -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///. Regenerated by # `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index 946afb6d46c..302cd6e4d09 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -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>['lints'] = [] - let lintsList: Awaited>['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 ( - - - Available checks - - {fetchError ? ( - - We fetch remediation guides straight from the supabase/splinter repository - during the build. GitHub timed out just now, so we’re showing the overview only. -
-
- You can check back in a few minutes or browse the - {` `} - - latest Markdown on GitHub (opens in a new tab) - - . -
- ) : ( - - {lints.map((lint) => ( - -
- -
-
- ))} -
- )} -
- ) + return } -/** - * 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 } diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index c8236dcddff..bf70b4650a2 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -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 { - 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(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 }) => { if (!isFeatureEnabled('docs:fdw')) { @@ -347,191 +17,18 @@ const WrappersDocs = async (props: { params: Promise }) => { } 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 ( - - - - - {dashboardIntegrationURL && ( - -

You can enable the {meta.title} wrapper right from the Supabase dashboard.

- - -
- )} - - - - -
-
- ) + return } -/** - * 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 } diff --git a/apps/docs/components/DatabaseAdvisorsIndex.tsx b/apps/docs/components/DatabaseAdvisorsIndex.tsx new file mode 100644 index 00000000000..9e5cda92899 --- /dev/null +++ b/apps/docs/components/DatabaseAdvisorsIndex.tsx @@ -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 ( + + {lints.map((lint) => ( + +
+ +
+
+ ))} +
+ ) +} diff --git a/apps/docs/components/WrapperDashboardIntegration.tsx b/apps/docs/components/WrapperDashboardIntegration.tsx new file mode 100644 index 00000000000..8ec6fcd727d --- /dev/null +++ b/apps/docs/components/WrapperDashboardIntegration.tsx @@ -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 ( + +

You can enable the {title} wrapper right from the Supabase dashboard.

+ + +
+ ) +} diff --git a/apps/docs/content/guides/database/database-advisors.mdx b/apps/docs/content/guides/database/database-advisors.mdx new file mode 100644 index 00000000000..25dfbac3db4 --- /dev/null +++ b/apps/docs/content/guides/database/database-advisors.mdx @@ -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 + + diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 70aba0cb37b..6c17ae6b5e5 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -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'>) => ( diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index e018300827d..bb87dd02876 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -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) { diff --git a/apps/docs/internals/markdown-schema/DatabaseAdvisorsIndex.ts b/apps/docs/internals/markdown-schema/DatabaseAdvisorsIndex.ts new file mode 100644 index 00000000000..96e40c23a66 --- /dev/null +++ b/apps/docs/internals/markdown-schema/DatabaseAdvisorsIndex.ts @@ -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') +} diff --git a/apps/docs/internals/markdown-schema/WrapperDashboardIntegration.ts b/apps/docs/internals/markdown-schema/WrapperDashboardIntegration.ts new file mode 100644 index 00000000000..508b1c64a98 --- /dev/null +++ b/apps/docs/internals/markdown-schema/WrapperDashboardIntegration.ts @@ -0,0 +1,9 @@ +export const WrapperDashboardIntegration = ({ + props, +}: { + props: Record +}): 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).` +} diff --git a/apps/docs/scripts/federated-content/fetch-federated-content.ts b/apps/docs/scripts/federated-content/fetch-federated-content.ts index b105012765a..79ef9cb8fbc 100644 --- a/apps/docs/scripts/federated-content/fetch-federated-content.ts +++ b/apps/docs/scripts/federated-content/fetch-federated-content.ts @@ -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 { + const pattern = new RegExp(source.latestTag!.pattern) + + const { + repository: { + refs: { + nodes, + pageInfo: { hasNextPage, endCursor }, + }, + }, + } = await octokit().graphql(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/
` 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 = `\n\n${content}` + } const frontmatter: Record = { 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 { +async function fetchSource(baseSource: FederatedContentSource): Promise { + 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 { 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=` (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 { + 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( diff --git a/apps/docs/scripts/federated-content/sources/wrappers.ts b/apps/docs/scripts/federated-content/sources/wrappers.ts new file mode 100644 index 00000000000..c6f0e35a031 --- /dev/null +++ b/apps/docs/scripts/federated-content/sources/wrappers.ts @@ -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 diff --git a/apps/docs/scripts/federated-content/types.ts b/apps/docs/scripts/federated-content/types.ts index 34470d5f56d..15a763cf6c1 100644 --- a/apps/docs/scripts/federated-content/types.ts +++ b/apps/docs/scripts/federated-content/types.ts @@ -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/////...`. + */ + assetsDir?: string /** * Unmapped links fall back to `${externalSite}/${relativePath}${hash}`. Set * this when `externalSite` is a source-controlled host (e.g. a GitHub blob