diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f2cb47d6..a6b77adc 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' @@ -65,6 +65,11 @@ export interface SeedSkillOptions { description?: string version?: string readmeHeading?: string + readmeBody?: string + extraFiles?: Array<{ + path: string + content: string + }> } function asApiErrorBody(value: unknown): string { @@ -130,8 +135,13 @@ function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return readFileSync(zipPath) } finally { rmSync(tempRoot, { recursive: true, force: true }) @@ -146,8 +156,13 @@ function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') - execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + writeFileSync(path.join(packageDir, 'README.md'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') + for (const extraFile of options?.extraFiles ?? []) { + const targetPath = path.join(packageDir, extraFile.path) + mkdirSync(path.dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, extraFile.content, 'utf8') + } + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return { filePath: zipPath, diff --git a/web/e2e/skill-detail-relative-links.spec.ts b/web/e2e/skill-detail-relative-links.spec.ts new file mode 100644 index 00000000..5f1f353c --- /dev/null +++ b/web/e2e/skill-detail-relative-links.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Skill Detail Relative Links (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('previews package files from overview relative links and reports missing files', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = `relative-links-${Date.now().toString(36)}` + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + readmeBody: [ + `# ${skillName}`, + '', + '[Usage](docs/usage.md)', + '', + '[Missing](docs/missing.md)', + ].join('\n'), + extraFiles: [ + { + path: 'docs/usage.md', + content: '# Usage\n\nThis is linked documentation.', + }, + ], + }) + + await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`) + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('link', { name: 'Usage' })).toBeVisible() + 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 page.getByRole('button', { name: 'Close' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + + await page.getByRole('link', { name: 'Missing' }).click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByText('File not found')).toBeVisible() + await expect(page.getByText('not included in the current skill version')).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx index d7f39c4c..20fe3413 100644 --- a/web/src/features/skill/markdown-renderer.test.tsx +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { MARKDOWN_IMAGE_CLASS_NAME } from './markdown-renderer' +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MARKDOWN_IMAGE_CLASS_NAME, MarkdownRenderer } from './markdown-renderer' + +afterEach(() => cleanup()) describe('MARKDOWN_IMAGE_CLASS_NAME', () => { it('keeps markdown images at their intrinsic width while remaining responsive', () => { @@ -10,3 +15,21 @@ describe('MARKDOWN_IMAGE_CLASS_NAME', () => { expect(classNames).not.toContain('w-full') }) }) + +describe('MarkdownRenderer links', () => { + it('passes the raw markdown href to the optional link click handler', () => { + const onLinkClick = vi.fn() + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(onLinkClick).toHaveBeenCalledTimes(1) + expect(onLinkClick.mock.calls[0][0]).toBe('docs/usage.md') + }) + + it('keeps links renderable without a click handler', () => { + render() + + expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md') + }) +}) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 1f787021..8bb0c198 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, type MouseEvent } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -12,6 +12,7 @@ export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full' interface MarkdownRendererProps { content: string className?: string + onLinkClick?: (href: string, event: MouseEvent) => void } /** @@ -20,7 +21,7 @@ interface MarkdownRendererProps { * dedicated UI sections and should not appear twice in the document body. * Memoized to prevent re-parsing on every render. */ -export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { +export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRendererProps) { const containerClassName = [ className, 'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]', @@ -45,13 +46,15 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {children}

), - a: ({ className: linkClassName, children, ...props }) => ( + a: ({ className: linkClassName, children, href, ...props }) => ( onLinkClick?.(href ?? '', event)} > {children} diff --git a/web/src/features/skill/package-relative-link.test.ts b/web/src/features/skill/package-relative-link.test.ts new file mode 100644 index 00000000..737be487 --- /dev/null +++ b/web/src/features/skill/package-relative-link.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFile } from '@/api/types' +import { resolvePackageRelativeLink } from './package-relative-link' + +function file(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + +const packageFiles = [ + file('README.md'), + file('docs/SKILL.md'), + file('docs/usage.md'), + file('shared.md'), + file('space name.md'), + file('使用.md'), +] + +describe('resolvePackageRelativeLink', () => { + it('matches same-directory and explicit current-directory links from the package root', () => { + expect(resolvePackageRelativeLink('docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + expect(resolvePackageRelativeLink('./docs/usage.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + }) + }) + + it('normalizes parent-directory links against the current documentation file', () => { + expect(resolvePackageRelativeLink('../shared.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'shared.md', + }) + }) + + it('keeps fragment information while matching the file path', () => { + expect(resolvePackageRelativeLink('docs/usage.md#intro', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'docs/usage.md', + fragment: 'intro', + }) + }) + + it('decodes encoded file paths before matching package files', () => { + expect(resolvePackageRelativeLink('space%20name.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: 'space name.md', + }) + expect(resolvePackageRelativeLink('%E4%BD%BF%E7%94%A8.md', 'README.md', packageFiles)).toMatchObject({ + status: 'matched', + path: '使用.md', + }) + }) + + it('ignores links that should keep native browser behavior', () => { + for (const href of ['https://example.com', 'mailto:team@example.com', '#intro', '/absolute/path.md', '']) { + expect(resolvePackageRelativeLink(href, 'README.md', packageFiles)).toMatchObject({ + status: 'ignored', + }) + } + }) + + it('returns missing for relative links that do not resolve to a package file', () => { + expect(resolvePackageRelativeLink('docs/missing.md', 'README.md', packageFiles)).toMatchObject({ + status: 'missing', + path: 'docs/missing.md', + }) + expect(resolvePackageRelativeLink('../../outside.md', 'docs/SKILL.md', packageFiles)).toMatchObject({ + status: 'missing', + path: null, + }) + }) +}) diff --git a/web/src/features/skill/package-relative-link.ts b/web/src/features/skill/package-relative-link.ts new file mode 100644 index 00000000..15c7bb25 --- /dev/null +++ b/web/src/features/skill/package-relative-link.ts @@ -0,0 +1,112 @@ +import type { SkillFile } from '@/api/types' + +export type PackageRelativeLinkResolution = + | { + status: 'ignored' + href: string + } + | { + status: 'matched' + href: string + path: string + fragment: string | null + file: SkillFile + } + | { + status: 'missing' + href: string + path: string | null + fragment: string | null + } + +function splitHref(href: string) { + const hashIndex = href.indexOf('#') + const beforeHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href + const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : null + const queryIndex = beforeHash.indexOf('?') + return { + path: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash, + fragment, + } +} + +function decodePath(path: string) { + try { + return decodeURIComponent(path) + } catch { + return path + } +} + +function directoryOf(filePath?: string | null) { + if (!filePath) { + return '' + } + const normalized = filePath.replace(/^\/+/, '') + const lastSlash = normalized.lastIndexOf('/') + return lastSlash >= 0 ? normalized.slice(0, lastSlash) : '' +} + +function normalizePackagePath(baseDirectory: string, relativePath: string) { + const stack: string[] = [] + const rawParts = [...baseDirectory.split('/'), ...relativePath.split('/')] + + for (const part of rawParts) { + if (!part || part === '.') { + continue + } + if (part === '..') { + if (stack.length === 0) { + return null + } + stack.pop() + continue + } + stack.push(part) + } + + return stack.join('/') +} + +function shouldIgnoreLink(href: string, rawPath: string) { + if (!href.trim()) { + return true + } + if (!rawPath || href.startsWith('#')) { + return true + } + if (rawPath.startsWith('/') || rawPath.startsWith('//')) { + return true + } + return /^[a-z][a-z0-9+.-]*:/i.test(rawPath) +} + +export function resolvePackageRelativeLink( + href: string, + currentFilePath: string | null | undefined, + files: SkillFile[] | null | undefined, +): PackageRelativeLinkResolution { + const { path: rawPath, fragment } = splitHref(href) + + if (shouldIgnoreLink(href, rawPath)) { + return { status: 'ignored', href } + } + + const normalizedPath = normalizePackagePath(directoryOf(currentFilePath), decodePath(rawPath)) + if (!normalizedPath) { + return { status: 'missing', href, path: null, fragment } + } + + const matchedFile = (files ?? []).find((file) => file.filePath === normalizedPath) + if (!matchedFile) { + return { status: 'missing', href, path: normalizedPath, fragment } + } + + return { + status: 'matched', + href, + path: normalizedPath, + fragment, + file: matchedFile, + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ea5e049d..b63da59f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -790,6 +790,8 @@ "documentationSource": "Source: {{path}}", "documentationUnavailableTitle": "Documentation is unavailable", "documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.", + "packageLinkMissingTitle": "File not found", + "packageLinkMissingDescription": "This link points to a file that is not included in the current skill version.", "authorLabel": "By {{name}}", "expandOverview": "Expand full overview", "collapseOverview": "Collapse content", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b8f0cd7a..243ec719 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -790,6 +790,8 @@ "documentationSource": "来源:{{path}}", "documentationUnavailableTitle": "文档暂时不可用", "documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。", + "packageLinkMissingTitle": "文件未找到", + "packageLinkMissingDescription": "该链接指向的文件不在当前技能版本中。", "authorLabel": "作者 {{name}}", "expandOverview": "展开全文", "collapseOverview": "收起内容", diff --git a/web/src/i18n/skill-detail-locale.test.ts b/web/src/i18n/skill-detail-locale.test.ts index 6434f4d5..a237ef60 100644 --- a/web/src/i18n/skill-detail-locale.test.ts +++ b/web/src/i18n/skill-detail-locale.test.ts @@ -7,4 +7,11 @@ describe('skill detail lifecycle locales', () => { expect(zh.skillDetail.unarchiveSkill).toBe('恢复技能') expect(en.skillDetail.unarchiveSkill).toBe('Restore Skill') }) + + it('defines package relative link missing messages in both locales', () => { + expect(zh.skillDetail.packageLinkMissingTitle).toBe('文件未找到') + expect(zh.skillDetail.packageLinkMissingDescription).toBe('该链接指向的文件不在当前技能版本中。') + expect(en.skillDetail.packageLinkMissingTitle).toBe('File not found') + expect(en.skillDetail.packageLinkMissingDescription).toBe('This link points to a file that is not included in the current skill version.') + }) }) diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 87bdcb48..1c6374ce 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -1,11 +1,24 @@ +/** @vitest-environment jsdom */ + import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MouseEvent } from 'react' +import type { SkillFile } from '@/api/types' + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) const navigateMock = vi.fn() const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +const useSkillFilesMock = vi.fn() +const useSkillReadmeMock = vi.fn() +const useSkillFileMock = vi.fn() let authState: { user: { userId: string; platformRoles: string[] } | null hasRole: (role: string) => boolean @@ -47,7 +60,7 @@ vi.mock('@/features/report/use-skill-reports', () => ({ })) vi.mock('@/shared/lib/toast', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: toastMocks.success, error: toastMocks.error }, })) vi.mock('@/api/client', () => ({ @@ -76,7 +89,47 @@ vi.mock('@/shared/lib/number-format', () => ({ })) vi.mock('@/features/skill/markdown-renderer', () => ({ - MarkdownRenderer: () =>
markdown
, + MarkdownRenderer: ({ + content, + onLinkClick, + }: { + content: string + onLinkClick?: (href: string, event: MouseEvent) => void + }) => ( +
+
markdown:{content}
+ onLinkClick?.('docs/usage.md', event)}> + Usage + + onLinkClick?.('docs/missing.md', event)}> + Missing + + { + event.preventDefault() + onLinkClick?.('https://example.com', event) + }} + > + External + + { + event.preventDefault() + onLinkClick?.('#intro', event) + }} + > + Anchor + +
+ ), +})) + +vi.mock('@/features/skill/file-preview-dialog', () => ({ + FilePreviewDialog: ({ open, node }: { open: boolean; node: { path: string } | null }) => ( + open && node ?
preview:{node.path}
: null + ), })) vi.mock('@/features/skill/file-tree', () => ({ @@ -107,9 +160,9 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), useSkillVersionDetail: () => ({ data: undefined }), - useSkillFiles: () => ({ data: [] }), - useSkillReadme: () => ({ data: '# Demo', error: null }), - useSkillFile: () => ({ data: null, isLoading: false, error: null }), + useSkillFiles: (...args: unknown[]) => useSkillFilesMock(...args), + useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), + useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), useArchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), @@ -165,9 +218,26 @@ function createSkill(overrides: Record = {}) { } } +function createSkillFile(filePath: string): SkillFile { + return { + id: filePath.length, + filePath, + fileSize: 128, + contentType: 'text/markdown', + sha256: `sha-${filePath}`, + } +} + describe('SkillDetailPage', () => { + afterEach(() => cleanup()) + beforeEach(() => { navigateMock.mockReset() + useSkillFilesMock.mockReset() + useSkillReadmeMock.mockReset() + useSkillFileMock.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') authState = { user: { userId: 'owner-1', platformRoles: ['USER'] }, @@ -196,6 +266,9 @@ describe('SkillDetailPage', () => { useSkillLabelsMock.mockReturnValue({ data: undefined, }) + useSkillFilesMock.mockReturnValue({ data: [] }) + useSkillReadmeMock.mockReturnValue({ data: '# Demo', error: null }) + useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) }) it('shows hard delete action for the skill owner', () => { @@ -401,4 +474,53 @@ describe('SkillDetailPage', () => { expect(html).toContain('break-all') expect(html).toContain('leading-snug') }) + + it('opens a file preview when overview markdown relative link matches a package file', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Usage' })) + + expect(screen.getByRole('dialog').textContent).toContain('preview:docs/usage.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: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'Missing' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).toHaveBeenCalledWith( + 'skillDetail.packageLinkMissingTitle', + 'skillDetail.packageLinkMissingDescription', + ) + }) + + it('leaves external links and same-document anchors alone', () => { + useSkillFilesMock.mockReturnValue({ + data: [ + createSkillFile('README.md'), + createSkillFile('docs/usage.md'), + ], + }) + + render() + fireEvent.click(screen.getByRole('link', { name: 'External' })) + fireEvent.click(screen.getByRole('link', { name: 'Anchor' })) + + expect(screen.queryByRole('dialog')).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) }) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 43c05b7f..77b0c291 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' +import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' +import type { SkillFile } from '@/api/types' import { InstallCommand } from '@/features/skill/install-command' import { ShareButton } from '@/features/skill/share-button' import { SkillLabelPanel } from '@/features/skill/skill-label-panel' @@ -87,6 +89,20 @@ function parseMetadataJson(parsed?: string) { } } +function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { + const pathParts = file.filePath.split('/').filter(Boolean) + const name = pathParts[pathParts.length - 1] ?? file.filePath + + return { + id: file.filePath, + name, + path: file.filePath, + type: 'file', + file, + depth: Math.max(pathParts.length - 1, 0), + } +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -284,6 +300,24 @@ export function SkillDetailPage() { setPreviewDialogOpen(true) } + const handleOverviewLinkClick = (href: string, event: MouseEvent) => { + const resolution = resolvePackageRelativeLink(href, documentationPath, files) + + if (resolution.status === 'ignored') { + return + } + + event.preventDefault() + + if (resolution.status === 'matched') { + setPreviewNode(createPackageFilePreviewNode(resolution.file)) + setPreviewDialogOpen(true) + return + } + + toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription')) + } + // Download a single file from the skill version const handleDownloadFile = () => { const isAnonymousAllowed = namespace === 'global' && skill?.visibility === 'PUBLIC' @@ -844,7 +878,7 @@ export function SkillDetailPage() { style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined} >
- +
{!isOverviewExpanded && isOverviewCollapsible ? (