Files
supabase/apps/docs/internals/generate-guides-markdown.ts
Danny White 3b06c6c7cc fix(docs): unify docs card hover and retire IconPanel (#48379)
## 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 -->
2026-07-31 06:34:05 +10:00

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)
})