mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(web): preview relative markdown package links (#502)
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
7e23508a32
commit
6779c1eecd
11 changed files with 473 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
55
web/e2e/skill-detail-relative-links.spec.ts
Normal file
55
web/e2e/skill-detail-relative-links.spec.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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(<MarkdownRenderer content="[Usage](docs/usage.md)" onLinkClick={onLinkClick} />)
|
||||
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(<MarkdownRenderer content="[Usage](docs/usage.md)" />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HTMLAnchorElement>) => 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}
|
||||
</p>
|
||||
),
|
||||
a: ({ className: linkClassName, children, ...props }) => (
|
||||
a: ({ className: linkClassName, children, href, ...props }) => (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-primary underline decoration-primary/30 underline-offset-4 transition-colors hover:text-primary/80',
|
||||
linkClassName
|
||||
)}
|
||||
{...props}
|
||||
href={href}
|
||||
onClick={(event) => onLinkClick?.(href ?? '', event)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
|
|
|||
80
web/src/features/skill/package-relative-link.test.ts
Normal file
80
web/src/features/skill/package-relative-link.test.ts
Normal file
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
112
web/src/features/skill/package-relative-link.ts
Normal file
112
web/src/features/skill/package-relative-link.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -790,6 +790,8 @@
|
|||
"documentationSource": "来源:{{path}}",
|
||||
"documentationUnavailableTitle": "文档暂时不可用",
|
||||
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
|
||||
"packageLinkMissingTitle": "文件未找到",
|
||||
"packageLinkMissingDescription": "该链接指向的文件不在当前技能版本中。",
|
||||
"authorLabel": "作者 {{name}}",
|
||||
"expandOverview": "展开全文",
|
||||
"collapseOverview": "收起内容",
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: () => <div>markdown</div>,
|
||||
MarkdownRenderer: ({
|
||||
content,
|
||||
onLinkClick,
|
||||
}: {
|
||||
content: string
|
||||
onLinkClick?: (href: string, event: MouseEvent<HTMLAnchorElement>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div>markdown:{content}</div>
|
||||
<a href="docs/usage.md" onClick={(event) => onLinkClick?.('docs/usage.md', event)}>
|
||||
Usage
|
||||
</a>
|
||||
<a href="docs/missing.md" onClick={(event) => onLinkClick?.('docs/missing.md', event)}>
|
||||
Missing
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onLinkClick?.('https://example.com', event)
|
||||
}}
|
||||
>
|
||||
External
|
||||
</a>
|
||||
<a
|
||||
href="#intro"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onLinkClick?.('#intro', event)
|
||||
}}
|
||||
>
|
||||
Anchor
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/file-preview-dialog', () => ({
|
||||
FilePreviewDialog: ({ open, node }: { open: boolean; node: { path: string } | null }) => (
|
||||
open && node ? <div role="dialog">preview:{node.path}</div> : 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<string, unknown> = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
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(<SkillDetailPage />)
|
||||
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(<SkillDetailPage />)
|
||||
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(<SkillDetailPage />)
|
||||
fireEvent.click(screen.getByRole('link', { name: 'External' }))
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Anchor' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(toastMocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HTMLAnchorElement>) => {
|
||||
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}
|
||||
>
|
||||
<div ref={overviewContentRef}>
|
||||
<MarkdownRenderer content={readme} />
|
||||
<MarkdownRenderer content={readme} onLinkClick={handleOverviewLinkClick} />
|
||||
</div>
|
||||
{!isOverviewExpanded && isOverviewCollapsible ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-card via-card/95 to-transparent" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue