From 10c425ad0b9f7a83418ac66a4e7029001ffa95b7 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 21 Aug 2026 08:51:11 +0800 Subject: [PATCH] feat(www): markdown copy/ask affordances (#48475) --- apps/docs/components/GuidesSidebar.tsx | 62 ++++++------ apps/www/app/pricing/PricingContent.tsx | 7 ++ apps/www/components/Blog/BlogPostRenderer.tsx | 7 +- .../components/Blog/ShareArticleActions.tsx | 8 -- .../Changelog/ChangelogDetailSidebar.tsx | 42 +------- .../Changelog/ChangelogLlmMarkdownButton.tsx | 45 +++++++-- apps/www/components/MarkdownActions.tsx | 96 +++++++++++++++++++ apps/www/pages/customers/[slug].tsx | 7 ++ apps/www/pages/events/[slug].tsx | 6 ++ .../common/hooks/useCopyMarkdownFromUrl.ts | 8 +- packages/common/index.tsx | 1 + packages/common/markdown-affordance.test.ts | 28 ++++++ packages/common/markdown-affordance.ts | 11 +++ packages/common/telemetry-constants.ts | 25 ++++- 14 files changed, 253 insertions(+), 100 deletions(-) create mode 100644 apps/www/components/MarkdownActions.tsx create mode 100644 packages/common/markdown-affordance.test.ts create mode 100644 packages/common/markdown-affordance.ts diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx index a247ca6e912..4f9a8893140 100644 --- a/apps/docs/components/GuidesSidebar.tsx +++ b/apps/docs/components/GuidesSidebar.tsx @@ -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 }) { + + {copied ? 'Copied to clipboard' : ''} + - 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" > - + Ask ChatGPT - 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" > - + Ask Claude diff --git a/apps/www/app/pricing/PricingContent.tsx b/apps/www/app/pricing/PricingContent.tsx index 32a7d4f6504..ed8cead6dd4 100644 --- a/apps/www/app/pricing/PricingContent.tsx +++ b/apps/www/app/pricing/PricingContent.tsx @@ -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() {

Start building for free, collaborate with your team, then scale to millions of users

+ diff --git a/apps/www/components/Blog/BlogPostRenderer.tsx b/apps/www/components/Blog/BlogPostRenderer.tsx index b01b9d63d73..0e838af8213 100644 --- a/apps/www/components/Blog/BlogPostRenderer.tsx +++ b/apps/www/components/Blog/BlogPostRenderer.tsx @@ -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 && } {isLaunchWeek15 && } -
+
+
@@ -291,7 +293,8 @@ const BlogPostRenderer = async ({ On this page

{toc}
-
+
+
diff --git a/apps/www/components/Blog/ShareArticleActions.tsx b/apps/www/components/Blog/ShareArticleActions.tsx index 8360f2abfad..28509509faf 100644 --- a/apps/www/components/Blog/ShareArticleActions.tsx +++ b/apps/www/components/Blog/ShareArticleActions.tsx @@ -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 ( diff --git a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx index 17f25044d7a..1addb673cd0 100644 --- a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx +++ b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx @@ -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 )} - - - - Ask ChatGPT - - - - Ask Claude - +
diff --git a/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx b/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx index 2e85c3e4512..bdb99914951 100644 --- a/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx +++ b/apps/www/components/Changelog/ChangelogLlmMarkdownButton.tsx @@ -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 (
@@ -36,10 +48,13 @@ export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changel ) } - onClick={() => void copyMarkdown(markdownPath)} + onClick={handleCopy} > - {copied ? 'Copied as Markdown' : 'Copy as Markdown'} + {copied ? 'Copied!' : 'Copy as Markdown'} + + {copied ? 'Copied to clipboard' : ''} + @@ -53,21 +68,33 @@ export function ChangelogLlmMarkdownButton({ className, markdownPath = '/changel + sendTelemetryEvent({ + action: 'ask_ai_clicked', + properties: { agent: 'chatgpt', pageType: 'changelog' }, + }) + } > - + Ask ChatGPT + sendTelemetryEvent({ + action: 'ask_ai_clicked', + properties: { agent: 'claude', pageType: 'changelog' }, + }) + } > - + Ask Claude diff --git a/apps/www/components/MarkdownActions.tsx b/apps/www/components/MarkdownActions.tsx new file mode 100644 index 00000000000..236c0679ee3 --- /dev/null +++ b/apps/www/components/MarkdownActions.tsx @@ -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 ( + + ) +} diff --git a/apps/www/pages/customers/[slug].tsx b/apps/www/pages/customers/[slug].tsx index 4f6e26eff3a..ed3451e3936 100644 --- a/apps/www/pages/customers/[slug].tsx +++ b/apps/www/pages/customers/[slug].tsx @@ -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) { ) })} + +

Ready to get started?

diff --git a/apps/www/pages/events/[slug].tsx b/apps/www/pages/events/[slug].tsx index 029b30eae2f..106be659865 100644 --- a/apps/www/pages/events/[slug].tsx +++ b/apps/www/pages/events/[slug].tsx @@ -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) =>
+
Share on diff --git a/packages/common/hooks/useCopyMarkdownFromUrl.ts b/packages/common/hooks/useCopyMarkdownFromUrl.ts index 8174c6734b4..f3a85a43a2b 100644 --- a/packages/common/hooks/useCopyMarkdownFromUrl.ts +++ b/packages/common/hooks/useCopyMarkdownFromUrl.ts @@ -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) diff --git a/packages/common/index.tsx b/packages/common/index.tsx index 13233f82400..ddafa4401c8 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -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' diff --git a/packages/common/markdown-affordance.test.ts b/packages/common/markdown-affordance.test.ts new file mode 100644 index 00000000000..7a127b93cf5 --- /dev/null +++ b/packages/common/markdown-affordance.test.ts @@ -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' + ) + }) +}) diff --git a/packages/common/markdown-affordance.ts b/packages/common/markdown-affordance.ts new file mode 100644 index 00000000000..6405485cf2b --- /dev/null +++ b/packages/common/markdown-affordance.ts @@ -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}`, + } +} diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 14feba7fab0..688b44468ac 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -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 } }