diff --git a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx index d40563b3f4a..278ad08fb54 100644 --- a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx +++ b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx @@ -7,7 +7,7 @@ subtitle: 'Scopes let you specify the level of access your integration needs' -Scopes are only available for OAuth apps. Check out [**our guide**](/docs/guides/integrations/build-a-supabase-oauth-integration) to learn how to build an OAuth app integration. +Scopes are only available for OAuth apps. Check out [our guide](/docs/guides/integrations/build-a-supabase-oauth-integration) to learn how to build an OAuth app integration. diff --git a/apps/docs/content/guides/platform/multi-factor-authentication.mdx b/apps/docs/content/guides/platform/multi-factor-authentication.mdx index b1ef06cc409..d460dcb6232 100644 --- a/apps/docs/content/guides/platform/multi-factor-authentication.mdx +++ b/apps/docs/content/guides/platform/multi-factor-authentication.mdx @@ -6,7 +6,7 @@ subtitle: 'Enable multi-factor authentication (MFA) to keep your account secure. -This guide is for adding MFA to your Supabase user account. If you want to enable MFA for users in your Supabase project, refer to [**this guide**](/docs/guides/auth/auth-mfa) instead. +This guide is for adding MFA to your Supabase user account. If you want to enable MFA for users in your Supabase project, refer to [this guide](/docs/guides/auth/auth-mfa) instead. diff --git a/apps/docs/features/docs/MdxAnchor.tsx b/apps/docs/features/docs/MdxAnchor.tsx index 30b2b54db42..c792fab63ec 100644 --- a/apps/docs/features/docs/MdxAnchor.tsx +++ b/apps/docs/features/docs/MdxAnchor.tsx @@ -23,17 +23,50 @@ const flattenChildrenToText = (node: unknown): string => { return '' } -export function MdxAnchor({ href, children, ...rest }: ComponentPropsWithoutRef<'a'>) { +/** Same resting/hover contract as Studio InlineLink (no brand/green link color). */ +const mdxAnchorClassName = + 'underline transition underline-offset-2 decoration-inherit hover:decoration-foreground text-inherit hover:text-foreground' + +const relForTarget = (target?: string, rel?: string) => { + if (target !== '_blank') return rel + const tokens = new Set((rel ?? '').split(/\s+/).filter(Boolean)) + tokens.add('noopener') + tokens.add('noreferrer') + return [...tokens].join(' ') +} + +/** + * Docs MDX `` mapper. Same resting/hover contract as Studio InlineLink: + * inherit text + decoration, then foreground on hover. + */ +export function MdxAnchor({ + href, + children, + className, + target, + rel, + ...rest +}: ComponentPropsWithoutRef<'a'>) { + const linkClassName = cn(mdxAnchorClassName, className) + const resolvedRel = relForTarget(target, rel) + if (!isExternalHref(href)) { return ( - + {children} ) } const label = flattenChildrenToText(children).trim() return ( - + {children} { expect(within(screen.getByRole('alert')).queryByText(`${name}:`)).not.toBeInTheDocument() }) - it('renders the title as a paragraph with its own margin', () => { + it('renders the title via AlertTitle (paragraph, not a heading)', () => { render() const note = screen.getByRole('alert', { name: 'Note' }) @@ -127,8 +127,8 @@ describe('Admonition', () => { expect(note.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument() expect(title.tagName).toBe('P') - expect(title).toHaveAttribute('data-admonition-title') - expect(title).toHaveClass('mb-0.5') + expect(title).toHaveAttribute('data-slot', 'alert-title') + expect(title).toHaveClass('!mt-0', 'mb-0.5', 'font-medium') }) it('wraps a string description in a paragraph', () => { @@ -137,4 +137,19 @@ describe('Admonition', () => { const note = screen.getByRole('alert', { name: 'Note' }) expect(within(note).getByText('Body copy.').tagName).toBe('P') }) + + it('wraps MDX children in AlertDescription', () => { + render( + +

Children body copy.

+
+ ) + + const note = screen.getByRole('alert', { name: 'Note' }) + const paragraph = within(note).getByText('Children body copy.') + + expect(paragraph.tagName).toBe('P') + expect(paragraph.parentElement).toHaveClass('text-sm') + expect(paragraph.parentElement).toHaveAttribute('data-slot', 'alert-description') + }) }) diff --git a/packages/ui-patterns/src/Admonition/Admonition.tsx b/packages/ui-patterns/src/Admonition/Admonition.tsx index 530f65658b5..7333aac1d74 100644 --- a/packages/ui-patterns/src/Admonition/Admonition.tsx +++ b/packages/ui-patterns/src/Admonition/Admonition.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { Alert, AlertDescription, cn } from 'ui' +import { Alert, AlertDescription, AlertTitle, cn } from 'ui' import { TYPE_LABEL, TYPE_TO_VARIANT } from './Admonition.constants' import type { AdmonitionLayout, AdmonitionProps, AdmonitionType } from './Admonition.types' @@ -7,6 +7,9 @@ import { AdmonitionTypeIcon } from './AdmonitionIcons' export type { AdmonitionLayout, AdmonitionProps, AdmonitionType } +const admonitionBodyClassName = + '[&_p]:!mt-0 [&_p]:!mb-1.5 [&_p:last-child]:!mb-0 [&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5' + export const Admonition = forwardRef< React.ComponentRef, Omit< @@ -66,28 +69,31 @@ export const Admonition = forwardRef< ] )} > -
so these MDX body resets don't override its mb-0.5 - // ([&_p]:!mb-1.5 beats !mb-0.5 on specificity: class+element vs class). - '[&_p:not([data-admonition-title])]:!mt-0 [&_p:not([data-admonition-title])]:!mb-1.5 [&_p:not([data-admonition-title]):last-child]:!mb-0', - '[&_ul]:!my-1.5 [&_ol]:!my-1.5 [&_li]:!my-0.5', - childProps?.description?.className - )} - > +
{title && ( -

{title} -

+ + )} + {description && ( + + {description} + + )} + {children && ( + + {children} + )} - {description && {description}} - {children}
{actions && (
{ expect(result).toStrictEqual('function') }) + + it('flattens nested element children including icons without props.children', () => { + const value = [ + 'See ', + { + props: { + children: [ + 'pg_prewarm', + // lucide icons (and similar) are objects without useful text children + { $$typeof: Symbol.for('react.element'), type: 'svg', props: undefined }, + ], + }, + }, + ] + + const result = getAnchor(value) + + expect(result).toStrictEqual('see-pgprewarm') + }) }) describe('when value is a string', () => { diff --git a/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts b/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts index 244210c945a..082e2a1c7f6 100644 --- a/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts +++ b/packages/ui/src/components/CustomHTMLElements/CustomHTMLElements.utils.ts @@ -1,3 +1,20 @@ +/** + * Flatten React node trees to plain text for heading anchor ids. + * MDX headings often mix strings with inline nodes (`code`, links, icons); + * older code assumed every non-string had `props.children` and crashed when + * that was missing (e.g. lucide icons inside docs MDX links). + */ +const flattenNodeText = (node: unknown): string => { + if (node == null || typeof node === 'boolean') return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(flattenNodeText).join('') + if (typeof node === 'object' && node !== null && 'props' in node) { + const props = (node as { props?: { children?: unknown } }).props + return flattenNodeText(props?.children) + } + return '' +} + // Check if heading has custom anchor first, before forming the anchor based on the title export const getAnchor = (text: any, { id }: { id?: string } = {}): string | undefined => { if (id) { @@ -15,27 +32,22 @@ export const getAnchor = (text: any, { id }: { id?: string } = {}): string | und const formattedText = text .map((x) => { - if (typeof x !== 'string') { - return x.props.children + if (typeof x === 'string') { + return x.trim() } - - return x.trim() + return flattenNodeText(x) }) .map((x) => { if (typeof x !== 'string') { return x } - return slugify(x) }) return formattedText.join('-').toLowerCase() } else { - const anchor = text.props.children - if (typeof anchor === 'string') { - return slugify(anchor) - } - return anchor + const flattened = flattenNodeText(text).trim() + return flattened ? slugify(flattened) : undefined } } else if (typeof text === 'string') { if (hasCustomAnchor(text)) { diff --git a/packages/ui/src/components/shadcn/ui/alert.tsx b/packages/ui/src/components/shadcn/ui/alert.tsx index 56e00a1b8fc..e1590d9857e 100644 --- a/packages/ui/src/components/shadcn/ui/alert.tsx +++ b/packages/ui/src/components/shadcn/ui/alert.tsx @@ -31,40 +31,54 @@ const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => ( -
+
)) Alert.displayName = 'Alert' -const AlertTitle = React.forwardRef>( - ({ className, ...props }, ref) =>
-) -AlertTitle.displayName = 'AlertTitle' - -const AlertDescription = React.forwardRef< +const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes ->(({ className, children, ...props }, ref) => { - // Automatically wrap primitive text nodes (string/number) in

tags for semantic HTML - const content = - typeof children === 'string' || typeof children === 'number' ?

{children}

: children +>(({ className, ...props }, ref) => ( +

+)) +AlertTitle.displayName = 'AlertTitle' - return ( -

- {content} -
- ) -}) +const AlertDescription = React.forwardRef>( + ({ className, children, ...props }, ref) => { + // Automatically wrap primitive text nodes (string/number) in

tags for semantic HTML + const content = + typeof children === 'string' || typeof children === 'number' ?

{children}

: children + + return ( +
+ {content} +
+ ) + } +) AlertDescription.displayName = 'AlertDescription' export { Alert, AlertDescription, AlertTitle }