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
+ }) => (
+
+ ),
+}))
+
+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 ? (