feat(www): markdown copy/ask affordances (#48475)

This commit is contained in:
Pamela Chia authored and GitHub committed 2026-08-21 08:51:11 +08:00
1 parent e605178a63
commit 10c425ad0b
14 files changed
+253 -100

No files matched your search

+27 -35
View File
@@ -2,12 +2,11 @@
import { Feedback } from '~/components/Feedback'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { isFeatureEnabled } from 'common'
import { askAiUrls, isFeatureEnabled, useCopyMarkdownFromUrl } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, Copy, Sparkles } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { cn } from 'ui'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc'
@@ -22,38 +21,22 @@ interface TOCHeader {
}
function AiTools({ className }: { className?: string }) {
const [copied, setCopied] = useState(false)
const path = usePathname()
const sendTelemetryEvent = useSendTelemetryEvent()
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const urls = askAiUrls(`https://supabase.com/docs${path}`)
function handleAgentSetupClick() {
sendTelemetryEvent({ action: 'agent_setup_clicked' })
}
async function copyMarkdown() {
const mdUrl = `/docs/${path}.md`
try {
const res = await fetch(mdUrl)
let text: string
if (res.ok) {
text = await res.text()
} else {
// Default to HTML content within the article when no .md file is available.
text = document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? ''
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Failed to copy markdown', error)
}
sendTelemetryEvent({
action: 'copy_as_markdown_clicked',
async function handleCopy() {
const ok = await copyMarkdown(`/docs${path}.md`, {
fallback: () => document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? '',
})
if (ok) {
sendTelemetryEvent({ action: 'copy_as_markdown_clicked', properties: { pageType: 'guide' } })
}
}
return (
@@ -75,38 +58,47 @@ function AiTools({ className }: { className?: string }) {
</Link>
<button
tabIndex={0}
onClick={copyMarkdown}
onClick={handleCopy}
className="flex cursor-pointer items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground text-left transition-colors"
>
{copied ? (
<Check size={14} strokeWidth={1.5} className="text-brand" />
<Check size={14} strokeWidth={1.5} className="text-brand" aria-hidden />
) : (
<Copy size={14} strokeWidth={1.5} />
<Copy size={14} strokeWidth={1.5} aria-hidden />
)}
{copied ? 'Copied!' : 'Copy as Markdown'}
</button>
<span className="sr-only" role="status">
{copied ? 'Copied to clipboard' : ''}
</span>
<a
href={`https://chatgpt.com/?hint=search&q=Read from https://supabase.com/docs${path} so I can ask questions about its contents`}
href={urls.chatgpt}
target="_blank"
onClick={() =>
sendTelemetryEvent({ action: 'ask_ai_clicked', properties: { agent: 'chatgpt' } })
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'chatgpt', pageType: 'guide' },
})
}
rel="noreferrer noopener"
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors"
>
<Chatgpt size={14} />
<Chatgpt size={14} aria-hidden />
Ask ChatGPT
</a>
<a
href={`https://claude.ai/new?q=Read from https://supabase.com/docs${path} so I can ask questions about its contents`}
href={urls.claude}
target="_blank"
onClick={() =>
sendTelemetryEvent({ action: 'ask_ai_clicked', properties: { agent: 'claude' } })
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'claude', pageType: 'guide' },
})
}
rel="noreferrer noopener"
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors"
>
<Claude size={14} />
<Claude size={14} aria-hidden />
Ask Claude
</a>
</div>
+7
View File
@@ -1,6 +1,7 @@
import { ArrowDownIcon } from '@heroicons/react/outline'
import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import { MarkdownActions } from '~/components/MarkdownActions'
import PricingAddons from '~/components/Pricing/PricingAddons'
import PricingComputeSection from '~/components/Pricing/PricingComputeSection'
import PricingDiskSection from '~/components/Pricing/PricingDiskSection'
@@ -27,6 +28,12 @@ export default function PricingContent() {
<p className="p text-lg leading-5">
Start building for free, collaborate with your team, then scale to millions of users
</p>
<MarkdownActions
orientation="horizontal"
className="justify-center pt-4"
pagePath="/pricing"
pageType="pricing"
/>
</div>
</div>
</div>
@@ -16,6 +16,7 @@ import LW13Summary from '@/components/LaunchWeek/13/Releases/LWSummary'
import LW14Summary from '@/components/LaunchWeek/14/Releases/LWSummary'
import LW15Summary from '@/components/LaunchWeek/15/LWSummary'
import LWXSummary from '@/components/LaunchWeek/X/LWXSummary'
import { MarkdownActions } from '@/components/MarkdownActions'
import { getBlogThumbnailImage } from '@/lib/blog-images'
import { compileBlogMdx } from '@/lib/mdx/compileBlogMdx'
import mdxComponents from '@/lib/mdx/mdxComponents'
@@ -225,7 +226,8 @@ const BlogPostRenderer = async ({
{isLaunchWeek14 && <LW14Summary />}
{isLaunchWeek15 && <LW15Summary />}
<div className="block lg:hidden py-8">
<div className="lg:hidden py-8 flex flex-col gap-6">
<MarkdownActions pagePath={`/blog/${blogMetaData.slug}`} pageType="blog" />
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
@@ -291,7 +293,8 @@ const BlogPostRenderer = async ({
On this page
</p>
<div className="overflow-y-auto min-h-0 flex-1">{toc}</div>
<div className="shrink-0">
<div className="shrink-0 flex flex-col gap-6">
<MarkdownActions pagePath={`/blog/${blogMetaData.slug}`} pageType="blog" />
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
</div>
@@ -1,6 +1,5 @@
'use client'
import { useCopyMarkdownFromUrl } from 'common'
import Link from 'next/link'
import {
cn,
@@ -13,8 +12,6 @@ import {
TooltipTrigger,
} from 'ui'
import { SITE_ORIGIN } from '@/lib/constants'
const ShareArticleActions = ({
title,
slug,
@@ -28,13 +25,8 @@ const ShareArticleActions = ({
basePath?: string
className?: string
}) => {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const permalink = encodeURIComponent(`${basePath}${slug}`)
const encodedTitle = encodeURIComponent(title)
const mdPath = `/blog/${slug}.md`
const mdAbs = `${SITE_ORIGIN}${mdPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
return (
<TooltipProvider>
@@ -1,13 +1,11 @@
'use client'
import { useCopyMarkdownFromUrl } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import { cn } from 'ui'
import { ChangeTypeBadge, ProductBadges } from '@/components/Changelog/ChangelogTimelineList'
import { MarkdownActions } from '@/components/MarkdownActions'
import type { ChangelogEntryFrontmatter } from '@/lib/changelog-repo'
import { SITE_ORIGIN } from '@/lib/constants'
type Props = {
slug: string
@@ -16,10 +14,6 @@ type Props = {
}
export function ChangelogDetailSidebar({ slug, frontmatter, className }: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const mdPath = `/changelog/${slug}.md`
const mdAbs = `${SITE_ORIGIN}${mdPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
const affectedProducts = frontmatter.affected_products ?? []
return (
@@ -117,37 +111,7 @@ export function ChangelogDetailSidebar({ slug, frontmatter, className }: Props)
View discussion on GitHub
</a>
)}
<button
tabIndex={0}
type="button"
onClick={() => void copyMarkdown(mdPath)}
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-left text-xs transition-colors"
>
{copied ? (
<Check size={14} strokeWidth={1.5} className="text-brand" />
) : (
<Copy size={14} strokeWidth={1.5} />
)}
{copied ? 'Copied as markdown' : 'Copy page as markdown'}
</button>
<a
href={`https://chatgpt.com/?hint=search&q=${encodeURIComponent(`Read from ${mdAbs} so I can ask questions about its contents`)}`}
target="_blank"
rel="noreferrer noopener"
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
>
<Chatgpt size={14} />
Ask ChatGPT
</a>
<a
href={`https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}`}
target="_blank"
rel="noreferrer noopener"
className="text-foreground-lighter hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
>
<Claude size={14} />
Ask Claude
</a>
<MarkdownActions pagePath={`/changelog/${slug}`} pageType="changelog" />
</nav>
</section>
</div>
@@ -1,6 +1,7 @@
'use client'
import { useCopyMarkdownFromUrl } from 'common'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { askAiUrls, useCopyMarkdownFromUrl } from 'common'
import { Chatgpt, Claude } from 'icons'
import { Check, ChevronDown, Copy } from 'lucide-react'
import {
@@ -21,8 +22,19 @@ type Props = {
export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changelog.md' }: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const mdAbs = `${SITE_ORIGIN}${markdownPath}`
const aiPrompt = `Read from ${mdAbs} so I can ask questions about its contents`
const sendTelemetryEvent = useSendTelemetryEvent()
const pagePath = markdownPath.replace(/\.md$/, '')
const urls = askAiUrls(`${SITE_ORIGIN}${pagePath}`)
async function handleCopy() {
const ok = await copyMarkdown(markdownPath)
if (ok) {
sendTelemetryEvent({
action: 'copy_as_markdown_clicked',
properties: { pageType: 'changelog' },
})
}
}
return (
<div className={cn('flex items-center', className)}>
@@ -36,10 +48,13 @@ export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changel
<Copy className="h-4 w-4" strokeWidth={2} aria-hidden />
)
}
onClick={() => void copyMarkdown(markdownPath)}
onClick={handleCopy}
>
{copied ? 'Copied as Markdown' : 'Copy as Markdown'}
{copied ? 'Copied!' : 'Copy as Markdown'}
</Button>
<span className="sr-only" role="status">
{copied ? 'Copied to clipboard' : ''}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -53,21 +68,33 @@ export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changel
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem asChild className="gap-2">
<a
href={`https://chatgpt.com/?hint=search&q=${encodeURIComponent(aiPrompt)}`}
href={urls.chatgpt}
target="_blank"
rel="noreferrer noopener"
onClick={() =>
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'chatgpt', pageType: 'changelog' },
})
}
>
<Chatgpt className="h-4 w-4 shrink-0" />
<Chatgpt className="h-4 w-4 shrink-0" aria-hidden />
Ask ChatGPT
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2">
<a
href={`https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}`}
href={urls.claude}
target="_blank"
rel="noreferrer noopener"
onClick={() =>
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'claude', pageType: 'changelog' },
})
}
>
<Claude className="h-4 w-4 shrink-0" />
<Claude className="h-4 w-4 shrink-0" aria-hidden />
Ask Claude
</a>
</DropdownMenuItem>
+96
View File
@@ -0,0 +1,96 @@
'use client'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { askAiUrls, useCopyMarkdownFromUrl } from 'common'
import type { MarkdownAffordancePageType } from 'common/telemetry-constants'
import { Chatgpt, Claude } from 'icons'
import { Check, Copy } from 'lucide-react'
import { cn } from 'ui'
import { SITE_ORIGIN } from '@/lib/constants'
type Props = {
pagePath: string
pageType: MarkdownAffordancePageType
orientation?: 'vertical' | 'horizontal'
className?: string
}
const itemClass =
'flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors'
export function MarkdownActions({
pagePath,
pageType,
orientation = 'vertical',
className,
}: Props) {
const { copied, copyMarkdown } = useCopyMarkdownFromUrl()
const sendTelemetryEvent = useSendTelemetryEvent()
const mdPath = pagePath === '/' ? '/homepage.md' : `${pagePath}.md`
const urls = askAiUrls(`${SITE_ORIGIN}${pagePath === '/' ? '' : pagePath}`)
async function handleCopy() {
const ok = await copyMarkdown(mdPath)
if (ok) {
sendTelemetryEvent({ action: 'copy_as_markdown_clicked', properties: { pageType } })
}
}
return (
<div
className={cn(
'flex',
orientation === 'vertical' ? 'flex-col gap-2' : 'flex-row flex-wrap items-center gap-4',
className
)}
>
<button
tabIndex={0}
type="button"
onClick={handleCopy}
className={cn(itemClass, 'cursor-pointer text-left')}
>
{copied ? (
<Check size={14} strokeWidth={1.5} className="text-brand" aria-hidden />
) : (
<Copy size={14} strokeWidth={1.5} aria-hidden />
)}
{copied ? 'Copied!' : 'Copy as Markdown'}
</button>
<span className="sr-only" role="status">
{copied ? 'Copied to clipboard' : ''}
</span>
<a
href={urls.chatgpt}
target="_blank"
rel="noreferrer noopener"
onClick={() =>
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'chatgpt', pageType },
})
}
className={itemClass}
>
<Chatgpt size={14} aria-hidden />
Ask ChatGPT
</a>
<a
href={urls.claude}
target="_blank"
rel="noreferrer noopener"
onClick={() =>
sendTelemetryEvent({
action: 'ask_ai_clicked',
properties: { agent: 'claude', pageType },
})
}
className={itemClass}
>
<Claude size={14} aria-hidden />
Ask Claude
</a>
</div>
)
}
+7
View File
@@ -16,6 +16,7 @@ import Link from 'next/link'
import { Button } from 'ui'
import SectionContainer from '@/components/Layouts/SectionContainer'
import { MarkdownActions } from '@/components/MarkdownActions'
// table of contents extractor
const toc = require('markdown-toc')
@@ -209,6 +210,12 @@ function CaseStudyPage(props: any) {
)
})}
<MarkdownActions
pagePath={`/customers/${slug}`}
pageType="customers"
className="not-prose"
/>
<div>
<p>Ready to get started?</p>
<div>
+6
View File
@@ -24,6 +24,7 @@ import { Image } from 'ui-patterns/Image'
import ShareArticleActions from '@/components/Blog/ShareArticleActions'
import DefaultLayout from '@/components/Layouts/Default'
import SectionContainer from '@/components/Layouts/SectionContainer'
import { MarkdownActions } from '@/components/MarkdownActions'
import authors from '@/lib/authors.json'
import { breadcrumbs } from '@/lib/breadcrumbs'
import { capitalize, isNotNullOrUndefined } from '@/lib/helpers'
@@ -324,6 +325,11 @@ const EventPage = ({ event }: InferGetStaticPropsType<typeof getStaticProps>) =>
</Link>
</Button>
</div>
<MarkdownActions
pagePath={`/events/${event.slug}`}
pageType="events"
className="mb-4"
/>
<div className="flex flex-col text-sm">
<span>Share on</span>
<ShareArticleActions title={meta.title} slug={meta.url} basePath="" />
@@ -3,14 +3,14 @@
import { useCallback, useState } from 'react'
export type CopyMarkdownFromUrlOptions = {
/** When the markdown URL is missing or not OK, use this HTML string instead (e.g. rendered article). */
fallbackHtml?: () => string
/** When the markdown URL is missing or not OK, use this string instead (e.g. rendered article HTML). */
fallback?: () => string
}
const COPIED_FEEDBACK_MS = 2000
/**
* Fetches markdown from `mdUrl`, falls back to optional HTML when the response is not OK,
* Fetches markdown from `mdUrl`, falls back to the optional fallback string when the response is not OK,
* then writes the result to the clipboard.
*/
export async function copyMarkdownFromUrl(
@@ -23,7 +23,7 @@ export async function copyMarkdownFromUrl(
if (res.ok) {
text = await res.text()
} else {
text = options?.fallbackHtml?.() ?? ''
text = options?.fallback?.() ?? ''
if (!text) return false
}
await navigator.clipboard.writeText(text)
+1
View File
@@ -8,6 +8,7 @@ export * from './feature-flags'
export * from './gotrue'
export * from './helpers'
export * from './hooks'
export * from './markdown-affordance'
export * from './MetaFavicons/pages-router'
export * from './Providers'
export * from './first-referrer-cookie'
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { askAiPrompt, askAiUrls } from './markdown-affordance'
describe('askAiUrls', () => {
it('embeds the encoded prompt for both agents', () => {
const { chatgpt, claude } = askAiUrls('https://supabase.com/blog/some-post')
const prompt = encodeURIComponent(
'Read from https://supabase.com/blog/some-post so I can ask questions about its contents'
)
expect(chatgpt).toBe(`https://chatgpt.com/?hint=search&q=${prompt}`)
expect(claude).toBe(`https://claude.ai/new?q=${prompt}`)
})
it('encodes spaces and slashes so the prompt survives as a single query param', () => {
const { chatgpt } = askAiUrls('https://supabase.com/changelog')
expect(chatgpt).not.toContain(' ')
expect(chatgpt.split('q=')[1]).not.toContain('/')
})
})
describe('askAiPrompt', () => {
it('references the page URL verbatim', () => {
expect(askAiPrompt('https://supabase.com/pricing')).toBe(
'Read from https://supabase.com/pricing so I can ask questions about its contents'
)
})
})
+11
View File
@@ -0,0 +1,11 @@
export function askAiPrompt(pageUrl: string) {
return `Read from ${pageUrl} so I can ask questions about its contents`
}
export function askAiUrls(pageUrl: string) {
const prompt = encodeURIComponent(askAiPrompt(pageUrl))
return {
chatgpt: `https://chatgpt.com/?hint=search&q=${prompt}`,
claude: `https://claude.ai/new?q=${prompt}`,
}
}
+22 -3
View File
@@ -920,14 +920,29 @@ export interface DocsFeedbackClickedEvent {
}
}
export type MarkdownAffordancePageType =
| 'blog'
| 'customers'
| 'events'
| 'pricing'
| 'changelog'
| 'guide'
/**
* User clicked 'Copy as Markdown' option on a page.
* User clicked 'Copy as Markdown' on a page and the markdown was copied successfully.
* Fires on success only; failed fetch/clipboard writes are not counted.
*
* @group Events
* @source docs
* @source www, docs
*/
export interface CopyAsMarkdownClickedEvent {
action: 'copy_as_markdown_clicked'
properties: {
/**
* Page class the affordance sits on.
*/
pageType: MarkdownAffordancePageType
}
}
/**
@@ -944,12 +959,16 @@ export interface AgentSetupClickedEvent {
* User clicked "Ask..." to open a new window to consult an agent about the current page.
*
* @group Events
* @source docs
* @source www, docs
*/
export interface AskAiClickedEvent {
action: 'ask_ai_clicked'
properties: {
agent: 'chatgpt' | 'claude'
/**
* Page class the affordance sits on.
*/
pageType: MarkdownAffordancePageType
}
}