Files
supabase/apps/docs/features/docs/Reference.utils.ts
T
Pamela Chia 5a7c0d6d84 fix(docs): resolve legacy sdk reference urls (#51064)
I made the crawler renderer resolve legacy JavaScript and Dart reference
slugs to their current sections, and updated authored guide and SDK spec
links to use them. Exact slugs still win, ambiguous bare slugs still
return 404, and `file-buckets-listv2` remains a section slug in
canonical links. I kept the www redirect work in a separate draft PR
because the apps deploy independently.

## To test

- [x] On the Docs preview, request `reference/javascript/order` and
`reference/dart/get-user` with a bot user agent. Expect the intended
heading and canonical URL.
- [x] Request `reference/javascript/file-buckets-listv2` with bot and
browser user agents. Expect it to open the list v2 section.
- [x] Request `reference/swift/get-user` and the Kotlin reference root
with a bot user agent. Expect the intended heading.
- [x] Open the Storage quickstart guide and follow its upload reference
link. Expect the current JavaScript upload section.

## Linear

refs GROWTH-1293


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Reference pages now resolve legacy aliases and ambiguous slugs more
accurately, with canonical links that preserve explicit SDK versions.
* SDK version paths are recognized only when the full path segment
matches the version format, improving reference-page routing.

* **Documentation**
* Updated API reference links across authentication, storage, security,
and SDK guides to point to current pages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-30 17:11:53 -07:00

316 lines
9.6 KiB
TypeScript

import { clientSdkIds, REFERENCES, selfHostingServices } from '~/content/navigation.references'
import { getFlattenedSections } from '~/features/docs/Reference.generated.singleton'
import { generateOpenGraphImageMeta } from '~/features/seo/openGraph'
import { BASE_PATH } from '~/lib/constants'
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { mdxjs } from 'micromark-extension-mdxjs'
import type { Metadata, ResolvingMetadata } from 'next'
import { redirect } from 'next/navigation'
import { visit } from 'unist-util-visit'
const { metadataTitle } = getCustomContent(['metadata:title'])
export interface AbbrevApiReferenceSection {
id: string
type: string
title?: string
slug?: string
items?: Array<AbbrevApiReferenceSection>
excludes?: Array<string>
meta?: {
shared?: boolean
}
}
export function parseReferencePath(slug: Array<string>) {
const isClientSdkReference = clientSdkIds.includes(slug[0])
const isCliReference = slug[0] === 'cli'
const isApiReference = slug[0] === 'api'
const isSelfHostingReference = slug[0].startsWith('self-hosting-')
if (isClientSdkReference) {
let sdkId: string
let maybeVersion: string | null
let maybeCrawlers: string | null
let path: string[]
;[sdkId, maybeVersion, maybeCrawlers, ...path] = slug
if (!/^v\d+$/.test(maybeVersion)) {
maybeVersion = null
path = [maybeCrawlers, ...path]
maybeCrawlers = maybeVersion
}
if (maybeCrawlers !== 'crawlers') {
if (typeof maybeCrawlers === 'string') {
path = [maybeCrawlers, ...path]
}
maybeCrawlers = null
}
return {
__type: 'clientSdk' as const,
sdkId,
maybeVersion,
maybeCrawlers,
path,
}
} else if (isCliReference) {
return {
__type: 'cli' as const,
path: slug.slice(1),
}
} else if (isApiReference) {
return {
__type: 'api' as const,
path: slug.slice(1),
}
} else if (isSelfHostingReference) {
return {
__type: 'self-hosting' as const,
service: slug[0].replace('self-hosting-', ''),
servicePath: slug[0],
path: slug.slice(1),
}
} else {
return {
__type: 'UNIMPLEMENTED' as const,
}
}
}
async function generateStaticParamsForSdkVersion(sdkId: string, version: string) {
const flattenedSections = await getFlattenedSections(sdkId, version)
return (flattenedSections || [])
.filter((section) => section.type !== 'category' && !!section.slug)
.map((section) => ({
slug: [
sdkId,
version === REFERENCES[sdkId].versions[0] ? null : version,
'crawlers',
section.slug,
].filter(Boolean),
}))
}
// Spike (DOCS-1268): one static page per Management API endpoint, in addition
// to the existing bare `/reference/api` monolith. Deliberately does not reuse
// generateStaticParamsForSdkVersion's output shape — that function bakes in a
// 'crawlers' path segment for a separate crawler-only mechanism unrelated to
// these human-facing per-operation URLs.
async function generateStaticParamsForApi() {
const flattenedSections = await getFlattenedSections('api', 'latest')
return (flattenedSections || [])
.filter((section) => section.type !== 'category' && !!section.slug)
.map((section) => ({
slug: ['api', section.slug],
}))
}
export async function generateReferenceStaticParams() {
const sdkPages = clientSdkIds
.flatMap((sdkId) =>
REFERENCES[sdkId].versions.map((version) => ({
sdkId,
version,
}))
)
.map(({ sdkId, version }) => ({
slug: [sdkId, version === REFERENCES[sdkId].versions[0] ? null : version].filter(Boolean),
}))
const cliPages = [
{
slug: ['cli'],
},
]
const apiPages = [
{
slug: ['api'],
},
...(await generateStaticParamsForApi()),
]
const selfHostingPages = selfHostingServices.map((service) => ({
slug: [REFERENCES[service].libPath],
}))
return [...sdkPages, ...cliPages, ...apiPages, ...selfHostingPages]
}
export async function generateReferenceMetadata(
props: { params: Promise<{ slug: Array<string> }> },
resolvingParent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await props.params
const { alternates: parentAlternates, openGraph: parentOg } = await resolvingParent
const parsedPath = parseReferencePath(slug)
const isClientSdkReference = parsedPath.__type === 'clientSdk'
const isCliReference = parsedPath.__type === 'cli'
const isApiReference = parsedPath.__type === 'api'
const isSelfHostingReference = parsedPath.__type === 'self-hosting'
if (isClientSdkReference) {
const { sdkId, maybeVersion, path } = parsedPath
const version = maybeVersion ?? REFERENCES[sdkId].versions[0]
const flattenedSections = await getFlattenedSections(sdkId, version)
const displayName = REFERENCES[sdkId].name
const sectionTitle =
slug.length > 0
? flattenedSections?.find((section) => section.slug === slug[0])?.title
: undefined
const url = [BASE_PATH, 'reference', sdkId, path[0]].filter(Boolean).join('/')
const images = generateOpenGraphImageMeta({
type: 'API Reference',
title: `${displayName}${sectionTitle ? `: ${sectionTitle}` : ''}`,
})
return {
title: `${displayName} API Reference | ${metadataTitle || 'Supabase'}`,
description: `API reference for the ${displayName} Supabase SDK`,
...(slug.length > 0
? {
alternates: {
canonical: url,
},
}
: {}),
openGraph: {
...parentOg,
url,
images,
},
}
} else if (isCliReference) {
return {
title: 'CLI Reference | Supabase Docs',
description: 'CLI reference for the Supabase CLI',
}
} else if (isApiReference) {
const { path } = parsedPath
const operationSlug = path[0]
const flattenedSections = operationSlug
? await getFlattenedSections('api', 'latest')
: undefined
const sectionTitle = flattenedSections?.find((section) => section.slug === operationSlug)?.title
const url = [BASE_PATH, 'reference', 'api', operationSlug].filter(Boolean).join('/')
const images = generateOpenGraphImageMeta({
type: 'API Reference',
title: `Management API${sectionTitle ? `: ${sectionTitle}` : ''}`,
})
return {
title: `${sectionTitle ? `${sectionTitle} | ` : ''}Management API Reference | Supabase Docs`,
description: `Management API reference for the Supabase API${sectionTitle ? `: ${sectionTitle}` : ''}`,
...(operationSlug
? {
alternates: {
canonical: url,
},
}
: {}),
openGraph: {
...parentOg,
url,
images,
},
}
} else if (isSelfHostingReference) {
return {
title: 'Self-Hosting | Supabase Docs',
}
} else {
return {}
}
}
export async function redirectNonexistentReferenceSection(
sdkId: string,
version: string,
path: Array<string>,
isLatestVersion: boolean
) {
const initialSelectedSection = path[0]
const validSlugs = await generateStaticParamsForSdkVersion(sdkId, version)
if (
initialSelectedSection &&
!validSlugs.some((params) => params.slug[0] === initialSelectedSection)
) {
redirect(`/reference/${sdkId}` + (!isLatestVersion ? '/' + version : ''))
}
}
export function normalizeMarkdown(markdownUnescaped: string): string {
/**
* Need to first escape the braces so that the MDX parser doesn't choke on
* them. Unlike the MDX parser, the regular Markdown parser handles braces
* gracefully, so we use it to find the positions of the code blocks, then
* escape all other braces before the final conversion with the MDX parser.
*/
const markdownTree = fromMarkdown(markdownUnescaped)
const codeBlocks = [] as Array<{
type: string
start: number
end: number
}>
visit(markdownTree, ['code', 'inlineCode'], (node) => {
codeBlocks.push({
type: node.type,
start: node.position?.start?.offset || 0,
end: node.position?.end?.offset || 0,
})
})
// Sort code blocks by start offset in descending order
codeBlocks.sort((a, b) => b.start - a.start)
let markdown = markdownUnescaped
let lastIndex = markdown.length
// Iterate through the sorted code blocks
for (const block of codeBlocks) {
// Escape braces in the text between the current code block and the last processed position
const textBetween = markdown.slice(block.end, lastIndex)
const escapedTextBetween = textBetween.replace(/(?<!\\)([{}])/g, '\\$1')
// Replace the original text with the escaped version
markdown = markdown.slice(0, block.end) + escapedTextBetween + markdown.slice(lastIndex)
// Update the last processed position
lastIndex = block.start
}
// Escape braces in the remaining text before the first code block
if (lastIndex > 0) {
const remainingText = markdown.slice(0, lastIndex)
const escapedRemainingText = remainingText.replace(/(?<!\\)([{}])/g, '\\$1')
markdown = escapedRemainingText + markdown.slice(lastIndex)
}
const mdxTree = fromMarkdown(markdown, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
visit(mdxTree, 'text', (node) => {
node.value = node.value.replace(/\n/g, ' ')
})
const content = toMarkdown(mdxTree, {
extensions: [mdxToMarkdown()],
})
return content
}
export { SUPPORTS_NEW_REFERENCE_PROCESS } from '~/features/docs/Reference.constants'