diff --git a/web/e2e/skill-detail-relative-links.spec.ts b/web/e2e/skill-detail-relative-links.spec.ts index 5f1f353c..89164c02 100644 --- a/web/e2e/skill-detail-relative-links.spec.ts +++ b/web/e2e/skill-detail-relative-links.spec.ts @@ -28,8 +28,12 @@ test.describe('Skill Detail Relative Links (Real API)', () => { extraFiles: [ { path: 'docs/usage.md', - content: '# Usage\n\nThis is linked documentation.', + content: '# Usage\n\nThis is linked documentation.\n\n[Nested](nested.md)', }, + { + path: 'docs/nested.md', + content: '# Nested\n\nSecond-level linked documentation.', + } ], }) @@ -40,6 +44,11 @@ test.describe('Skill Detail Relative Links (Real API)', () => { await page.getByRole('link', { name: 'Usage' }).click() await expect(page.getByRole('dialog')).toContainText('usage.md') await expect(page.getByRole('dialog')).toContainText('This is linked documentation.') + await expect(page.getByRole('dialog').getByRole('link', { name: 'Nested' })).toBeVisible() + + await page.getByRole('dialog').getByRole('link', { name: 'Nested' }).click() + await expect(page.getByRole('dialog')).toContainText('nested.md') + await expect(page.getByRole('dialog')).toContainText('Second-level linked documentation.') await page.getByRole('button', { name: 'Close' }).click() await expect(page.getByRole('dialog')).toBeHidden() diff --git a/web/src/features/skill/file-preview-dialog.tsx b/web/src/features/skill/file-preview-dialog.tsx index 14bba880..21aabbfa 100644 --- a/web/src/features/skill/file-preview-dialog.tsx +++ b/web/src/features/skill/file-preview-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, type MouseEvent } from 'react' import { Copy, Check, Download, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Dialog, DialogContent } from '@/shared/ui/dialog' @@ -18,6 +18,7 @@ interface FilePreviewDialogProps { isLoading: boolean error: Error | null onDownload: () => void + onLinkClick?: (href: string, event: MouseEvent) => void } /** @@ -33,6 +34,7 @@ export function FilePreviewDialog({ isLoading, error, onDownload, + onLinkClick, }: FilePreviewDialogProps) { const { t } = useTranslation() // Tracks the copy animation state: idle → spinning → done @@ -143,7 +145,7 @@ export function FilePreviewDialog({ ) : content && isMarkdown ? ( - + ) : content && shouldHighlight ? ( ) : content ? ( diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 1c6374ce..8da128c0 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -127,8 +127,27 @@ vi.mock('@/features/skill/markdown-renderer', () => ({ })) vi.mock('@/features/skill/file-preview-dialog', () => ({ - FilePreviewDialog: ({ open, node }: { open: boolean; node: { path: string } | null }) => ( - open && node ?
preview:{node.path}
: null + FilePreviewDialog: ({ + open, + node, + onLinkClick, + }: { + open: boolean + node: { path: string } | null + onLinkClick?: (href: string, event: MouseEvent) => void + }) => ( + open && node + ? ( +
+ preview:{node.path} + {onLinkClick && ( + onLinkClick('nested.md', event)}> + Nested + + )} +
+ ) + : null ), })) @@ -490,6 +509,25 @@ describe('SkillDetailPage', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('resolves links inside previewed markdown files against the previewed file path', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + createSkillFile('docs/nested.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + expect(screen.getByRole('dialog').textContent).toContain('preview:docs/usage.md') + + fireEvent.click(screen.getByRole('link', { name: 'Nested' })) + + expect(screen.getByRole('dialog').textContent).toContain('preview:docs/nested.md') + expect(toastMocks.error).not.toHaveBeenCalled() + }) + it('keeps the viewer on the detail page and shows a toast for missing package files', () => { useSkillFilesMock.mockReturnValue({ data: [ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 77b0c291..ca62eb49 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -300,8 +300,12 @@ export function SkillDetailPage() { setPreviewDialogOpen(true) } - const handleOverviewLinkClick = (href: string, event: MouseEvent) => { - const resolution = resolvePackageRelativeLink(href, documentationPath, files) + const handlePackageMarkdownLinkClick = ( + href: string, + event: MouseEvent, + currentFilePath: string | null | undefined, + ) => { + const resolution = resolvePackageRelativeLink(href, currentFilePath, files) if (resolution.status === 'ignored') { return @@ -318,6 +322,14 @@ export function SkillDetailPage() { toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription')) } + const handleOverviewLinkClick = (href: string, event: MouseEvent) => { + handlePackageMarkdownLinkClick(href, event, documentationPath) + } + + const handlePreviewLinkClick = (href: string, event: MouseEvent) => { + handlePackageMarkdownLinkClick(href, event, previewNode?.path) + } + // Download a single file from the skill version const handleDownloadFile = () => { const isAnonymousAllowed = namespace === 'global' && skill?.visibility === 'PUBLIC' @@ -1652,6 +1664,7 @@ export function SkillDetailPage() { isLoading={isLoadingPreview} error={previewError} onDownload={handleDownloadFile} + onLinkClick={handlePreviewLinkClick} /> )