feat(web): preview relative markdown package links

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-09 10:16:07 +08:00
parent 31b25fb6c5
commit f92eaea815
11 changed files with 473 additions and 18 deletions

View file

@ -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,

View 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()
}
})
})

View file

@ -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')
})
})

View file

@ -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>

View 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,
})
})
})

View 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,
}
}

View file

@ -784,6 +784,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",

View file

@ -784,6 +784,8 @@
"documentationSource": "来源:{{path}}",
"documentationUnavailableTitle": "文档暂时不可用",
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
"packageLinkMissingTitle": "文件未找到",
"packageLinkMissingDescription": "该链接指向的文件不在当前技能版本中。",
"authorLabel": "作者 {{name}}",
"expandOverview": "展开全文",
"collapseOverview": "收起内容",

View file

@ -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.')
})
})

View file

@ -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()
})
})

View file

@ -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'
@ -285,6 +301,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'
@ -845,7 +879,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" />