mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## What kind of change does this PR introduce?
Bug fix / docs UI polish.
## What is the current behavior?
- Many docs `GlassPanel`s use `background={false}`, so hover only tweaks
the border and reads as having no hover state
- Compact icon+label grids still use `IconPanel`, which has a broken
`-z-10` hover fill and overlaps with the newer `IconLink` pattern
- Description card grids jump to 3-up too early on medium widths
## What is the new behavior?
**GlassPanel**
- Removes the `background` prop; cards always use the filled surface
with stronger border hover
- Tightens icon→description gap (`gap-6` → `gap-3`)
- Decorative icons/logos use empty `alt` so screen readers don’t hear
the title twice
**Icon tiles**
- Retires `IconPanel` from docs and deletes it from `ui-patterns`
- Uses `IconLink` / `IconLinkList` for compact navigation tiles (auth
providers, social login, etc.)
- Adds `IconLinkButton` for SMS provider pickers (same chrome, opens a
dialog)
- Adds focus styles, list labelling, and dialog-trigger ARIA where
needed
**Layout / content**
- Migrate-to-Supabase description cards on resources use `GlassPanel`
(not slim icon tiles)
- Grid spans use `md:… xl:…` so cards stay 2-up until ~1280px
- Fixes migrate links to `/guides/platform/migrating-to-supabase/…` and
SSR quickstarts to `creating-a-client` with framework query params
- Moves the Extensions list `key` onto the outer `Link`
| Before | After |
| --- | --- |
| <img width="1185" height="1323" alt="Resources Supabase Docs"
src="https://github.com/user-attachments/assets/1677bf65-d3a3-4202-8c70-e758f7c3bcce"
/> | <img width="1185" height="1323" alt="Resources Supabase Docs"
src="https://github.com/user-attachments/assets/51760f0f-62b6-4010-9841-de26039f37b4"
/> |
## Additional context
Homepage compact sections already use `IconLinkList` from #48317; this
PR finishes that pattern for remaining docs `IconPanel` callsites and
cleans up GlassPanel hover.
`www/customers` only drops the removed `background` prop; those cards
already use the filled surface via `logo`.
## Test plan
- [ ] `/guides/getting-started`: GlassPanels show filled surface and
clearer border hover
- [ ] `/guides/resources`: migrate cards are GlassPanels with working
`/platform/…` links; 2-up until xl
- [ ] `/guides/auth/social-login` and auth providers partial: IconLink
tiles hover/focus correctly
- [ ] `/guides/auth/phone-login`: SMS provider buttons open dialogs;
keyboard focus works
- [ ] Docs homepage: migrate / self-host IconLinkLists unchanged in
behaviour
- [ ] `/guides/auth/server-side`: Next.js / SvelteKit cards resolve on
docs preview
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Improved Layouts**
* Made “GlassPanel” card grids more responsive and consistent; refined
card and success badge spacing for a cleaner presentation.
* **Updated Documentation**
* Refreshed multiple guide and resource pages (including quickstarts and
migration content) with standardized card layouts and updated link
destinations.
* **Component Updates**
* Standardized “GlassPanel” styling (background toggle removed) and
simplified icon-based panels; added an `IconLinkButton` for action
tiles; updated authentication provider grids to use the shared tile UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { parsePartialVariables, substitutePartialVars } from '~/lib/partials.utils'
|
|
import matter from 'gray-matter'
|
|
import type { Content, Parent, Root } from 'mdast'
|
|
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'
|
|
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
|
|
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
|
|
import { toMarkdown } from 'mdast-util-to-markdown'
|
|
import { gfm } from 'micromark-extension-gfm'
|
|
import { mdxjs } from 'micromark-extension-mdxjs'
|
|
import { parse as parseToml } from 'smol-toml'
|
|
import { mcpConfigPanelMarkdown as McpConfigPanel } from 'ui-patterns/McpUrlBuilder/McpConfigPanel.md'
|
|
|
|
import { addBaseUrlPrefix, getInternalLinkBaseUrl, withDocsBasePath } from './internal-links'
|
|
import { AccordionItem } from './markdown-schema/Accordion'
|
|
import { Admonition } from './markdown-schema/Admonition'
|
|
import { AiSkillsIndex } from './markdown-schema/AiSkillsIndex'
|
|
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'
|
|
import { Link } from './markdown-schema/Link'
|
|
import { McpCiConfigBlock } from './markdown-schema/McpCiConfigBlock'
|
|
import { MetricsStackCards } from './markdown-schema/MetricsStackCards'
|
|
import { NavData } from './markdown-schema/NavData'
|
|
import { Panel } from './markdown-schema/Panel'
|
|
import { Price } from './markdown-schema/Price'
|
|
import { PromptPanel } from './markdown-schema/PromptPanel'
|
|
import { RealtimeLimitsEstimator } from './markdown-schema/RealtimeLimitsEstimator'
|
|
import { RegionsList, SmartRegionsList } from './markdown-schema/RegionsList'
|
|
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,
|
|
type MarkdownSource,
|
|
} from './markdown-sources'
|
|
|
|
const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials')
|
|
// The set of guide slugs that have a generated markdown (.md) variant. middleware.ts
|
|
// imports this to decide whether a bot request for /guides/<slug> can be served
|
|
// markdown. It lives under the gitignored public/markdown/ output dir and is
|
|
// regenerated by build:guides-markdown, which turbo runs ahead of build/typecheck/lint.
|
|
const MANIFEST_PATH = path.join(process.cwd(), 'public', 'markdown', 'manifest.json')
|
|
const TROUBLESHOOTING_INDEX_PATH = path.join(
|
|
process.cwd(),
|
|
'public',
|
|
'markdown',
|
|
'guides',
|
|
'troubleshooting.md'
|
|
)
|
|
|
|
type JsxNode = MdxJsxFlowElement | MdxJsxTextElement
|
|
type Props = Record<string, unknown>
|
|
|
|
/**
|
|
* A handler converts a single MDX component into a markdown string. It receives
|
|
* the component's props, the already-serialized markdown of its children, and
|
|
* the raw AST node (escape hatch for handlers that need to inspect structure).
|
|
*
|
|
* Any component not in the schema is treated as `({ children }) => children`,
|
|
* i.e. the wrapper is dropped and its children are kept as-is.
|
|
*/
|
|
type ComponentHandler = (ctx: { props: Props; children: string; node: JsxNode }) => string
|
|
type ComponentSchema = Record<string, ComponentHandler>
|
|
|
|
const PARSE_OPTIONS = {
|
|
extensions: [mdxjs(), gfm()],
|
|
mdastExtensions: [mdxFromMarkdown(), gfmFromMarkdown()],
|
|
}
|
|
const SERIALIZE_OPTIONS = {
|
|
extensions: [mdxToMarkdown(), gfmToMarkdown()],
|
|
bullet: '-' as const,
|
|
listItemIndent: 'one' as const,
|
|
}
|
|
|
|
const parseMdx = (source: string): Root => fromMarkdown(source, PARSE_OPTIONS)
|
|
const serializeMdx = (tree: Parent): string => toMarkdown(tree as Root, SERIALIZE_OPTIONS)
|
|
|
|
const defaultHandler: ComponentHandler = ({ children }) => children
|
|
|
|
const isJsx = (n: Content): n is JsxNode =>
|
|
n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement'
|
|
|
|
function propsFrom(node: JsxNode): Props {
|
|
const props: Props = {}
|
|
for (const attr of node.attributes) {
|
|
if (attr.type !== 'mdxJsxAttribute') continue
|
|
if (attr.value == null) props[attr.name] = true
|
|
else if (typeof attr.value === 'string') props[attr.name] = attr.value
|
|
else props[attr.name] = attr.value.value
|
|
}
|
|
return props
|
|
}
|
|
|
|
function resolvePartialPath(partialPath: string): string {
|
|
if (!partialPath.endsWith('.md') && !partialPath.endsWith('.mdx')) {
|
|
throw new Error('Invalid $Partial path: path must end with .mdx or .md')
|
|
}
|
|
const resolved = path.join(PARTIALS_DIR, partialPath)
|
|
if (!resolved.startsWith(PARTIALS_DIR)) {
|
|
throw new Error(`Invalid $Partial path: path must be inside ${PARTIALS_DIR}`)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
/**
|
|
* Replaces every `<$Partial path="..." />` in the tree with the parsed AST of
|
|
* the referenced file. Recurses so partials may include other partials.
|
|
*/
|
|
async function inlinePartials(parent: Parent): Promise<void> {
|
|
const next: Content[] = []
|
|
for (const child of parent.children as Content[]) {
|
|
if (isJsx(child) && child.name === '$Partial') {
|
|
const props = propsFrom(child)
|
|
const partialPath = String(props.path ?? '')
|
|
const variables = parsePartialVariables(props.variables)
|
|
const resolvedPath = resolvePartialPath(partialPath)
|
|
try {
|
|
const raw = await fs.readFile(resolvedPath, 'utf8')
|
|
const content = substitutePartialVars(matter(raw).content, variables)
|
|
const subtree = parseMdx(content)
|
|
await inlinePartials(subtree)
|
|
next.push(...(subtree.children as Content[]))
|
|
} catch {
|
|
// missing or broken partials are silently dropped
|
|
}
|
|
continue
|
|
}
|
|
if ('children' in child) await inlinePartials(child as Parent)
|
|
next.push(child)
|
|
}
|
|
parent.children = next as Parent['children']
|
|
}
|
|
|
|
/**
|
|
* Walks the tree bottom-up. For each JSX element, runs its schema handler (or
|
|
* the default) and replaces the node with an `html` node holding the result.
|
|
* The `html` type passes through `mdast-util-to-markdown` verbatim, so whatever
|
|
* markdown the handler returns lands in the final output unchanged.
|
|
*/
|
|
function applySchema(parent: Parent, schema: ComponentSchema): void {
|
|
for (const child of parent.children as Content[]) {
|
|
if ('children' in child) applySchema(child as Parent, schema)
|
|
}
|
|
const next: Content[] = []
|
|
for (const child of parent.children as Content[]) {
|
|
if (
|
|
child.type === 'mdxFlowExpression' ||
|
|
child.type === 'mdxTextExpression' ||
|
|
child.type === 'mdxjsEsm'
|
|
) {
|
|
continue
|
|
}
|
|
if (isJsx(child)) {
|
|
const handler = schema[child.name ?? ''] ?? defaultHandler
|
|
const children = serializeMdx({
|
|
type: 'root',
|
|
children: child.children as Root['children'],
|
|
}).trim()
|
|
const value = handler({ props: propsFrom(child), children, node: child })
|
|
next.push({ type: 'html', value } as Content)
|
|
continue
|
|
}
|
|
next.push(child)
|
|
}
|
|
parent.children = next as Parent['children']
|
|
}
|
|
|
|
/**
|
|
* Per-component overrides. Each handler receives `{ props, children, node }`
|
|
* and returns the markdown string that should replace the JSX element. Any
|
|
* component not listed is unwrapped (children are kept, wrapper is dropped).
|
|
*/
|
|
const SCHEMA: ComponentSchema = {
|
|
AccordionItem,
|
|
Admonition,
|
|
AiSkillsIndex,
|
|
IconCheck,
|
|
IconX,
|
|
Image,
|
|
AuthProviders,
|
|
ComputeDiskLimitsTable,
|
|
CustomContent,
|
|
DatabaseAdvisorsIndex,
|
|
ErrorCodes,
|
|
Link,
|
|
McpCiConfigBlock,
|
|
McpConfigPanel,
|
|
Price,
|
|
...PromptPanel,
|
|
GlassPanel: Panel,
|
|
RealtimeLimitsEstimator,
|
|
RegionsList,
|
|
SmartRegionsList,
|
|
...StepHike,
|
|
TabPanel,
|
|
MetricsStackCards,
|
|
ContentListings,
|
|
NavData,
|
|
SharedData,
|
|
TerraformProviderSchema,
|
|
WrapperDashboardIntegration,
|
|
}
|
|
|
|
function parseFrontmatter(raw: string, frontmatter: FrontmatterFormat) {
|
|
if (frontmatter === 'toml') {
|
|
return matter(raw, { language: 'toml', engines: { toml: parseToml } })
|
|
}
|
|
return matter(raw)
|
|
}
|
|
|
|
async function transformBody(content: string, data: Record<string, unknown>): Promise<string> {
|
|
const tree = parseMdx(content)
|
|
await inlinePartials(tree)
|
|
addBaseUrlPrefix(tree)
|
|
applySchema(tree, SCHEMA)
|
|
const body = serializeMdx(tree)
|
|
|
|
const headerParts: string[] = []
|
|
if (data.title) headerParts.push(`# ${String(data.title)}`)
|
|
if (data.subtitle) headerParts.push(String(data.subtitle))
|
|
if (data.description && String(data.description) !== String(data.subtitle))
|
|
headerParts.push(String(data.description))
|
|
|
|
const header = headerParts.join('\n\n')
|
|
|
|
let output = header ? `${header}\n\n${body}` : body
|
|
|
|
return output
|
|
}
|
|
|
|
async function parseSingleSource(
|
|
sourceFile: string,
|
|
frontmatter: FrontmatterFormat
|
|
): Promise<string> {
|
|
const raw = await fs.readFile(sourceFile, 'utf8')
|
|
const { content, data } = parseFrontmatter(raw, frontmatter)
|
|
return transformBody(content, data)
|
|
}
|
|
|
|
async function renderManifest(sources: MarkdownSource[], extraSlugs: string[] = []): Promise<void> {
|
|
const slugs = Array.from(new Set([...sources.map((s) => s.slug), ...extraSlugs]))
|
|
const content = `${JSON.stringify(slugs, null, 2)}\n`
|
|
|
|
await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true })
|
|
await fs.writeFile(MANIFEST_PATH, content)
|
|
}
|
|
|
|
async function renderTroubleshootingIndex(troubleshooting: MarkdownSource[]): Promise<void> {
|
|
const entries = await Promise.all(
|
|
troubleshooting.map(async ({ sourceFile, slug }) => {
|
|
const raw = await fs.readFile(sourceFile, 'utf8')
|
|
const { data } = parseFrontmatter(raw, 'toml')
|
|
const url = `${getInternalLinkBaseUrl()}${withDocsBasePath(`/guides/${slug}`)}`
|
|
return `- [${data.title}](${url})`
|
|
})
|
|
)
|
|
const content = `# Troubleshooting guides\n\n${entries.join('\n')}\n`
|
|
|
|
await fs.mkdir(path.dirname(TROUBLESHOOTING_INDEX_PATH), { recursive: true })
|
|
await fs.writeFile(TROUBLESHOOTING_INDEX_PATH, content)
|
|
}
|
|
|
|
async function generate() {
|
|
const { guides, troubleshooting } = await collectMarkdownSources()
|
|
const sources = [...guides, ...troubleshooting]
|
|
|
|
await Promise.all(
|
|
sources.map(async ({ sourceFile, outPath, frontmatter }) => {
|
|
let output: string
|
|
try {
|
|
output = await parseSingleSource(sourceFile, frontmatter)
|
|
} 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)
|
|
})
|
|
)
|
|
|
|
await renderManifest(sources, ['troubleshooting'])
|
|
await renderTroubleshootingIndex(troubleshooting)
|
|
|
|
console.log(
|
|
`Generated ${sources.length} markdown files under public/markdown/guides/, troubleshooting guides index at public/markdown/guides/troubleshooting.md and updated public/markdown/manifest.json`
|
|
)
|
|
}
|
|
|
|
generate().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|