Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/website/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const config = {
});
return config;
},
experimental: {
optimizePackageImports: ['@vapor-ui/icons', '@vapor-ui/core'],
},
};

export default withMDX(config);
2 changes: 2 additions & 0 deletions apps/website/src/app/blocks/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';

import { BlockPageBody } from '~/components/block-page-body';
import { BlockPageHeader } from '~/components/block-page-header';
import { SITE_URL } from '~/constants/domain';
import { blockSource } from '~/lib/source';
import { getMDXComponents } from '~/mdx-components';

Expand All @@ -23,6 +24,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
title={title}
description={description}
previewImageUrl={previewImageUrl}
markdownUrl={`${SITE_URL}${page.url}.mdx`}
/>
<BlockPageBody toc={toc}>
<MDX components={getMDXComponents({})} />
Expand Down
3 changes: 2 additions & 1 deletion apps/website/src/app/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DocsBody, DocsPage } from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';

import { DocsPageHeader } from '~/components/docs-page-header';
import { SITE_URL } from '~/constants/domain';
import { source } from '~/lib/source';
import { getMDXComponents } from '~/mdx-components';
import { generatePageMetadata } from '~/utils/metadata';
Expand Down Expand Up @@ -35,7 +36,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
<DocsPageHeader
title={page.data.title}
description={page.data.description}
markdownUrl={`${page.url}.mdx`}
markdownUrl={`${SITE_URL}${page.url}.mdx`}
/>
<DocsBody>
<MDX components={getMDXComponents({})} />
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/docs/components/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const page = async ({ params }: { params: Promise<{ slug?: string[] }> }) => {
<DocsPageHeader
title={page.data.title}
description={page.data.description}
markdownUrl={`${page.url}.mdx`}
markdownUrl={`https://vapor-ui.goorm.io${page.url}.mdx`}
/>
<DocsBody className="px-0 flex flex-col">
<MDX components={getMDXComponents({})} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const ToolDetailSheetClient = ({
{description}
</Text>
</Sheet.Description>
<CopyButton markdownUrl={markdownUrl} size="sm" />
<CopyButton markdownUrl={markdownUrl} />
</div>
</Sheet.Header>

Expand Down
3 changes: 2 additions & 1 deletion apps/website/src/app/theme/[[...tool]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SITE_URL } from '~/constants/domain';
import { themeSource } from '~/lib/source';
import { getMDXComponents } from '~/mdx-components';

Expand Down Expand Up @@ -38,7 +39,7 @@ export default async function ThemePage({ params }: PageProps) {
// MDX frontmatter에서 직접 가져옴 (Single Source of Truth)
title: page.data.title,
description: page.data.description ?? '',
markdownUrl: `${page.url}.mdx`,
markdownUrl: `${SITE_URL}${page.url}.mdx`,
children: (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MDX components={getMDXComponents({})} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ interface BlockPageHeaderProps {
title: string;
description?: string;
previewImageUrl?: string;
markdownUrl?: string;
}

export const BlockPageHeader = ({ title, description, previewImageUrl }: BlockPageHeaderProps) => {
export const BlockPageHeader = ({
title,
description,
previewImageUrl,
markdownUrl,
}: BlockPageHeaderProps) => {
return (
<div className="flex gap-v-400 items-start justify-between w-full max-[1200px]:flex-col">
{/* Header Section */}
Expand Down Expand Up @@ -41,7 +47,7 @@ export const BlockPageHeader = ({ title, description, previewImageUrl }: BlockPa
/>
)}
</div>
<CopyButton markdownUrl="/" />
{markdownUrl && <CopyButton markdownUrl={markdownUrl} />}
</div>

{/* Preview Section */}
Expand Down
25 changes: 25 additions & 0 deletions apps/website/src/components/copy-button/copy-button.icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const AnthropicIcon = () => (
<svg
fill="currentColor"
fillRule="evenodd"
height={16}
width={16}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
</svg>
);

export const OpenAIIcon = () => (
<svg
fill="currentColor"
fillRule="evenodd"
height={16}
width={16}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z" />
</svg>
);
185 changes: 162 additions & 23 deletions apps/website/src/components/copy-button/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,177 @@
'use client';

import { Button } from '@vapor-ui/core';
import { CopyAsMarkdownOutlineIcon, CopyIcon } from '@vapor-ui/icons';
import { useMemo, useState } from 'react';

import { Button, HStack, IconButton, Menu } from '@vapor-ui/core';
import {
ChevronDownOutlineIcon,
ConfirmOutlineIcon,
CopyAsMarkdownOutlineIcon,
OpenInNewOutlineIcon,
} from '@vapor-ui/icons';
import { track } from '@vercel/analytics';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';

import {
COPY_BUTTON_ACTIONS,
type CopyButtonAction,
createCopyButtonEventName,
} from '~/constants/analytics';

import { AnthropicIcon, OpenAIIcon } from './copy-button.icons';

const markdownIcon = <CopyAsMarkdownOutlineIcon width={16} height={16} />;
const anthropicIcon = <AnthropicIcon />;
const openAIIcon = <OpenAIIcon />;

const cache = new Map<string, string>();

type CopyButtonProps = Omit<Button.Props, 'onClick' | 'disabled'> & {
const TRUSTED_DOMAINS = ['vapor-ui.goorm.io', 'localhost'] as const;

const isValidMarkdownUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return TRUSTED_DOMAINS.some(
(domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
);
} catch {
return false;
}
};

type CopyButtonProps = {
markdownUrl: string;
};

export const CopyButton = ({ markdownUrl, ...props }: CopyButtonProps) => {
const handleCopyContent = async (url: string) => {
const cached = cache.get(url);
if (cached) return navigator.clipboard.writeText(cached);

await navigator.clipboard.write([
new ClipboardItem({
'text/plain': fetch(url).then(async (res) => {
const content = await res.text();
cache.set(url, content);

return content;
}),
}),
]);
const LLM_URLS = {
claude: 'https://claude.ai/new',
chatgpt: 'https://chat.openai.com/',
} as const;

const LLM_PROMPT_MESSAGE = ' 문서를 읽고 질문에 답해줘.';

const openLLMChat = (llmType: keyof typeof LLM_URLS, docUrl: string) => {
if (!isValidMarkdownUrl(docUrl)) {
console.error('Invalid doc URL:', docUrl);
return;
}

const prompt = encodeURIComponent(`${docUrl}${LLM_PROMPT_MESSAGE}`);
const url = `${LLM_URLS[llmType]}?q=${prompt}`;
window.open(url, '_blank');
};

const trackCopyButtonEvent = (action: CopyButtonAction, markdownUrl: string) => {
const eventName = createCopyButtonEventName(action, markdownUrl);
track(eventName);
};

export const CopyButton = ({ markdownUrl }: CopyButtonProps) => {
const [checked, onCopy] = useCopyButton(() => handleCopyContent());
const [isLoading, setIsLoading] = useState(false);

const handleCopyContent = async () => {
trackCopyButtonEvent(COPY_BUTTON_ACTIONS.COPY_MARKDOWN, markdownUrl);

const cached = cache.get(markdownUrl);
if (cached) {
await navigator.clipboard.writeText(cached);
return;
}

if (!isValidMarkdownUrl(markdownUrl)) {
console.error('Invalid markdown URL:', markdownUrl);
return;
}

setIsLoading(true);
try {
const res = await fetch(markdownUrl);
const content = await res.text();
cache.set(markdownUrl, content);
await navigator.clipboard.writeText(content);
} finally {
setIsLoading(false);
}
};

const [checked, onClick] = useCopyButton(() => handleCopyContent(markdownUrl));
const menuItems = useMemo(
() => [
{
label: '마크다운으로 보기',
icon: markdownIcon,
onClick: () => {
if (isValidMarkdownUrl(markdownUrl)) {
trackCopyButtonEvent(COPY_BUTTON_ACTIONS.VIEW_MARKDOWN, markdownUrl);
window.open(markdownUrl, '_blank');
}
},
Comment on lines +103 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "View in Markdown" item cannot be tracked when clicked. However, the other two elements trigger event tracking when clicked, even if no action occurs. This issue arises due to differences in validation locations using isValidMarkdownUrl. To ensure consistency, it would be best to standardize this behavior.

isExternal: true,
},
{
label: 'Claude에게 질문하기',
icon: anthropicIcon,
onClick: () => {
trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CLAUDE, markdownUrl);
openLLMChat('claude', markdownUrl);
},
isExternal: true,
},
{
label: 'ChatGPT에게 질문하기',
icon: openAIIcon,
onClick: () => {
trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CHATGPT, markdownUrl);
openLLMChat('chatgpt', markdownUrl);
},
isExternal: true,
},
],
[markdownUrl],
);
Comment on lines +98 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

menuItems should ideally only know what actions each button actually performs. Currently, each menu item handles validation, event tracking, and even the action logic it must process, making it difficult to grasp at a glance what each element does.

Therefore, I propose the following structure to separate the MenuItem handler from the actions of the buttons:

const handleMenuItemClick = (action: CopyButtonAction, handler: () => void) => {
    if (!isValidMarkdownUrl(markdownUrl)) return;
    trackCopyButtonEvent(action, markdownUrl);
    handler();
};

const menuItems = useMemo(
    () => [
        {
            label: '마크다운으로 보기',
            icon: markdownIcon,
            action: COPY_BUTTON_ACTIONS.VIEW_MARKDOWN,
            handler: () => window.open(markdownUrl, '_blank'),
            isExternal: true,
        },
        {
            label: 'Claude에게 질문하기',
            icon: anthropicIcon,
            action: COPY_BUTTON_ACTIONS.ASK_CLAUDE,
            handler: () => openLLMChat('claude', markdownUrl),
            isExternal: true,
        },
        {
            label: 'ChatGPT에게 질문하기',
            icon: openAIIcon,
            action: COPY_BUTTON_ACTIONS.ASK_CHATGPT,
            handler: () => openLLMChat('chatgpt', markdownUrl),
            isExternal: true,
        },
    ],
    [markdownUrl],
);

// ...

{
    menuItems.map(({ label, icon, action, handler, isExternal }) => (
        <Menu.Item key={label} onClick={() => handleMenuItemClick(action, handler)}>
            {icon}
            {label}
            {isExternal && <OpenInNewOutlineIcon width={16} height={16} className="ml-auto" />}
        </Menu.Item>
    ));
}

This approach should also maintain the consistency of the validation logic mentioned earlier.


return (
<Button colorPalette="secondary" variant="outline" onClick={onClick} {...props}>
{checked ? <CopyAsMarkdownOutlineIcon /> : <CopyIcon />}
Copy as Markdown
</Button>
<HStack
gap="0"
className="rounded-md shadow-[inset_0_0_0_1px_var(--vapor-color-border-secondary)] w-fit"
>
Comment on lines +134 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding role="group" might be worth considering.

<Button
colorPalette="secondary"
variant="ghost"
onClick={onCopy}
disabled={isLoading}
className="rounded-r-none"
>
{checked ? <ConfirmOutlineIcon /> : <CopyAsMarkdownOutlineIcon />}
마크다운 복사
</Button>
<span className="w-px bg-v-gray-200 self-stretch" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This element is used as a separator, so adding role="separator" would make it clearer.

<Menu.Root>
<Menu.Trigger
render={
<IconButton
colorPalette="secondary"
variant="ghost"
aria-label="더보기"
disabled={isLoading}
className="rounded-l-none"
/>
}
>
<ChevronDownOutlineIcon />
</Menu.Trigger>
<Menu.Popup className="min-w-[200px]">
{menuItems.map((item) => (
<Menu.Item key={item.label} onClick={item.onClick}>
{item.icon}
{item.label}
{item.isExternal && (
<OpenInNewOutlineIcon width={16} height={16} className="ml-auto" />
)}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Root>
</HStack>
);
};
43 changes: 43 additions & 0 deletions apps/website/src/constants/analytics.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tracking logic seems tightly coupled only to the Copy Button, and since these functions are directly called within the Copy Button to create the tracking functions, it feels like concerns aren't well separated.

Event tracking could be useful elsewhere too, so it might be a good idea to refactor it over time.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Vercel Analytics event constants and type definitions
*/

export const COPY_BUTTON_ACTIONS = {
COPY_MARKDOWN: 'copy_markdown',
VIEW_MARKDOWN: 'view_markdown',
ASK_CLAUDE: 'ask_claude',
ASK_CHATGPT: 'ask_chatgpt',
} as const;

export type CopyButtonAction = (typeof COPY_BUTTON_ACTIONS)[keyof typeof COPY_BUTTON_ACTIONS];

/**
* Extracts docs path from document URL
* Example: /docs/components/navigation-menu.mdx → components/navigation-menu
*/
export const extractComponentName = (markdownUrl: string): string => {
try {
const url = new URL(markdownUrl);
const pathname = url.pathname
.split('/')
.slice(-2)
.join('/')
.replace(/\.mdx?$/, '');
const segments = pathname.split('/').filter(Boolean);
return segments.join('/') || 'unknown';
} catch {
return 'unknown';
}
};

/**
* Creates CopyButton event name
* Example: copy_markdown:components/navigation-menu, ask_claude:components/button
*/
export const createCopyButtonEventName = (
action: CopyButtonAction,
markdownUrl: string,
): string => {
const componentName = extractComponentName(markdownUrl);
return `${action}:${componentName}`;
};
1 change: 1 addition & 0 deletions apps/website/src/constants/domain.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const STATICS_DOMAIN = 'https://statics.goorm.io';
export const SITE_URL = 'https://vapor-ui.goorm.io';
Loading