feat(web): improve skill detail overview UX and markdown highlighting

This commit is contained in:
dongmucat 2026-03-18 16:17:55 +08:00 • committed by Xudong Sun
parent 82fe91adde
commit b7fd95459c
10 changed files with 436 additions and 11 deletions

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { inferMarkdownCodeLanguage } from './code-language'
describe('inferMarkdownCodeLanguage', () => {
it('infers python code blocks', () => {
expect(inferMarkdownCodeLanguage('from pypdf import PdfReader\nprint(\"ok\")')).toBe('python')
})
it('infers bash command blocks', () => {
expect(inferMarkdownCodeLanguage('pip install pypdf\npython script.py')).toBe('bash')
})
it('infers json payloads', () => {
expect(inferMarkdownCodeLanguage('{\n \"name\": \"skillhub\"\n}')).toBe('json')
})
it('infers yaml frontmatter style snippets', () => {
expect(inferMarkdownCodeLanguage('name: pdf\nversion: 1.0.0')).toBe('yaml')
})
it('returns undefined for prose-like content', () => {
expect(inferMarkdownCodeLanguage('This extracts all images as output files.')).toBeUndefined()
})
})

View file

@ -0,0 +1,66 @@
import type { Code, Root } from 'mdast'
import { visit } from 'unist-util-visit'
const BASH_PREFIX_PATTERN = /^(?:\$ |pip3? |python3? -m |python3? |npm |pnpm |yarn |npx |git |make |curl |wget |docker(?:-compose)? |kubectl |helm |cd |ls |cat |cp |mv |rm |mkdir |chmod |export |set |echo )/m
const PYTHON_PATTERN = /(?:^|\n)(?:from [\w.]+ import |import [\w.]+|def \w+\(|class \w+|with open\(|print\(|if __name__ == ['"]__main__['"]|for \w+ in |try:|except )/
const SQL_PATTERN = /^(?:select|insert\s+into|update|delete\s+from|create\s+table|alter\s+table|with\s+\w+\s+as)\b/im
const TYPESCRIPT_PATTERN = /(?:^|\n)(?:interface \w+|type \w+\s*=|import type |export type |export interface |const \w+:\s|:\s(?:string|number|boolean|Record<|Array<)|as const\b)/
const JAVASCRIPT_PATTERN = /(?:^|\n)(?:const |let |var |function \w+\(|export default |export function |module\.exports|import .* from |=>)/
const YAML_LINE_PATTERN = /^(\s*-\s+)?[\w"'./-]+:\s*.+$/m
function looksLikeJson(value: string) {
try {
JSON.parse(value)
return true
} catch {
return false
}
}
export function inferMarkdownCodeLanguage(value: string): string | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
if (looksLikeJson(trimmed)) {
return 'json'
}
if (PYTHON_PATTERN.test(trimmed)) {
return 'python'
}
if (BASH_PREFIX_PATTERN.test(trimmed)) {
return 'bash'
}
if (SQL_PATTERN.test(trimmed)) {
return 'sql'
}
if (TYPESCRIPT_PATTERN.test(trimmed)) {
return 'ts'
}
if (JAVASCRIPT_PATTERN.test(trimmed)) {
return 'javascript'
}
if (trimmed.includes(':') && YAML_LINE_PATTERN.test(trimmed)) {
return 'yaml'
}
return undefined
}
export function remarkInferCodeLanguage() {
return (tree: Root) => {
visit(tree, 'code', (node: Code) => {
if (!node.lang) {
node.lang = inferMarkdownCodeLanguage(node.value)
}
})
}
}

View file

@ -2,6 +2,8 @@ import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '@/shared/lib/utils'
import { remarkInferCodeLanguage } from './code-language'
import { stripMarkdownFrontmatter } from './markdown-frontmatter'
interface MarkdownRendererProps {
@ -12,7 +14,7 @@ interface MarkdownRendererProps {
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const containerClassName = [
className,
'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert',
'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]',
]
.filter(Boolean)
.join(' ')
@ -21,12 +23,83 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
return (
<div className={containerClassName}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
remarkPlugins={[remarkGfm, remarkInferCodeLanguage]}
rehypePlugins={[rehypeSanitize, [rehypeHighlight, { detect: true, ignoreMissing: true }]]}
components={{
p: ({ className: paragraphClassName, children, ...props }) => (
<p className={cn('my-4 text-[15px] leading-8 text-foreground/85', paragraphClassName)} {...props}>
{children}
</p>
),
a: ({ className: linkClassName, children, ...props }) => (
<a
className={cn(
'font-medium text-primary underline decoration-primary/30 underline-offset-4 transition-colors hover:text-primary/80',
linkClassName
)}
{...props}
>
{children}
</a>
),
strong: ({ className: strongClassName, children, ...props }) => (
<strong className={cn('font-semibold text-foreground', strongClassName)} {...props}>
{children}
</strong>
),
h1: ({ className: headingClassName, children, ...props }) => (
<h1
className={cn(
'scroll-mt-24 mb-6 border-b border-border/50 pb-4 font-heading text-3xl font-bold tracking-tight text-foreground',
headingClassName
)}
{...props}
>
{children}
</h1>
),
h2: ({ className: headingClassName, children, ...props }) => (
<h2
className={cn(
'scroll-mt-24 mt-10 mb-4 border-b border-border/40 pb-3 font-heading text-2xl font-semibold tracking-tight text-foreground',
headingClassName
)}
{...props}
>
{children}
</h2>
),
h3: ({ className: headingClassName, children, ...props }) => (
<h3
className={cn(
'scroll-mt-24 mt-8 mb-3 font-heading text-xl font-semibold tracking-tight text-foreground',
headingClassName
)}
{...props}
>
{children}
</h3>
),
ul: ({ className: listClassName, children, ...props }) => (
<ul className={cn('my-5 list-disc space-y-2 pl-6 text-foreground/85 marker:text-primary/60', listClassName)} {...props}>
{children}
</ul>
),
ol: ({ className: listClassName, children, ...props }) => (
<ol className={cn('my-5 list-decimal space-y-2 pl-6 text-foreground/85 marker:text-primary/60', listClassName)} {...props}>
{children}
</ol>
),
li: ({ className: itemClassName, children, ...props }) => (
<li className={cn('pl-1 leading-7', itemClassName)} {...props}>
{children}
</li>
),
pre: ({ children }) => (
<div className="max-w-full overflow-x-auto rounded-lg bg-muted/40 p-4">
<pre className="m-0 min-w-max bg-transparent p-0">{children}</pre>
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
</div>
</div>
),
code: ({ className: codeClassName, children, ...props }) => {
@ -34,7 +107,10 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
if (isInline) {
return (
<code className="break-words rounded bg-muted px-1 py-0.5 text-sm" {...props}>
<code
className="break-words rounded-md border border-border/40 bg-secondary/45 px-1.5 py-0.5 text-[0.9em] font-medium text-foreground/95"
{...props}
>
{children}
</code>
)
@ -46,11 +122,67 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
</code>
)
},
blockquote: ({ className: blockquoteClassName, children, ...props }) => (
<blockquote
className={cn(
'relative my-6 overflow-hidden rounded-r-xl border-l-4 border-l-primary/35 bg-secondary/30 px-5 py-4 text-foreground/80 shadow-sm',
blockquoteClassName
)}
{...props}
>
{children}
</blockquote>
),
hr: ({ className: hrClassName, ...props }) => (
<hr className={cn('my-10 mx-auto w-full max-w-full border-border/50', hrClassName)} {...props} />
),
table: ({ children }) => (
<div className="max-w-full overflow-x-auto">
<table>{children}</table>
<div className="my-6 overflow-hidden rounded-2xl border border-border/80 bg-card/80 shadow-sm">
<div className="max-w-full overflow-x-auto">
<table className="m-0 min-w-full border-separate border-spacing-0 text-sm">{children}</table>
</div>
</div>
),
thead: ({ className: sectionClassName, children, ...props }) => (
<thead className={cn('bg-secondary/55', sectionClassName)} {...props}>
{children}
</thead>
),
tbody: ({ className: sectionClassName, children, ...props }) => (
<tbody className={cn('[&_tr:nth-child(even)]:bg-secondary/12', sectionClassName)} {...props}>
{children}
</tbody>
),
tr: ({ className: rowClassName, children, ...props }) => (
<tr className={cn('transition-colors hover:bg-secondary/25', rowClassName)} {...props}>
{children}
</tr>
),
th: ({ className: cellClassName, children, ...props }) => (
<th
className={cn(
'border-b border-r border-border/70 px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground last:border-r-0',
cellClassName
)}
{...props}
>
{children}
</th>
),
td: ({ className: cellClassName, children, ...props }) => (
<td
className={cn(
'border-b border-r border-border/60 px-4 py-3 align-top text-foreground/85 last:border-r-0',
cellClassName
)}
{...props}
>
{children}
</td>
),
img: ({ className: imageClassName, alt, ...props }) => (
<img className={cn('w-full', imageClassName)} alt={alt ?? ''} {...props} />
),
}}
>
{normalizedContent}

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO,
getOverviewCollapseMaxHeight,
shouldCollapseOverview,
} from './overview-collapse'
describe('overview collapse helpers', () => {
it('uses a fixed max height on desktop viewports', () => {
expect(getOverviewCollapseMaxHeight(1280, 900)).toBe(OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT)
})
it('uses a viewport-based max height on mobile viewports', () => {
expect(getOverviewCollapseMaxHeight(375, 900)).toBe(Math.round(900 * OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO))
})
it('marks overview content as collapsible only when content exceeds the threshold', () => {
const maxHeight = getOverviewCollapseMaxHeight(1280, 900)
expect(shouldCollapseOverview(maxHeight, 1280, 900)).toBe(false)
expect(shouldCollapseOverview(maxHeight + 1, 1280, 900)).toBe(true)
})
})

View file

@ -0,0 +1,15 @@
export const OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT = 720
export const OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO = 0.6
const OVERVIEW_COLLAPSE_MOBILE_BREAKPOINT = 768
export function getOverviewCollapseMaxHeight(viewportWidth: number, viewportHeight: number) {
if (viewportWidth < OVERVIEW_COLLAPSE_MOBILE_BREAKPOINT) {
return Math.round(viewportHeight * OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO)
}
return OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT
}
export function shouldCollapseOverview(contentHeight: number, viewportWidth: number, viewportHeight: number) {
return contentHeight > getOverviewCollapseMaxHeight(viewportWidth, viewportHeight)
}

View file

@ -556,6 +556,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.",
"expandOverview": "Expand full overview",
"collapseOverview": "Collapse content",
"noDocumentationTitle": "No package documentation",
"noDocumentationDescription": "This skill version does not include a readable overview file.",
"noDocumentationHint": "Many skills only ship executable files. You can continue with the file list and version details below.",

View file

@ -556,6 +556,8 @@
"documentationSource": "来源:{{path}}",
"documentationUnavailableTitle": "文档暂时不可用",
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
"expandOverview": "展开全文",
"collapseOverview": "收起内容",
"noDocumentationTitle": "这个版本没有概览文档",
"noDocumentationDescription": "该技能版本没有包含可直接展示的 README.md 或 SKILL.md。",
"noDocumentationHint": "很多技能包只包含可执行文件或配置文件,可以继续查看下方文件列表和版本信息。",

View file

@ -390,6 +390,65 @@
border-radius: 12px;
}
/* ─── Markdown code highlighting ─── */
.hljs {
display: block;
color: hsl(var(--foreground) / 0.92);
background: transparent;
}
.hljs-comment,
.hljs-quote {
color: hsl(var(--muted-foreground) / 0.9);
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-name,
.hljs-section,
.hljs-link {
color: hsl(var(--primary) / 0.95);
font-weight: 600;
}
.hljs-string,
.hljs-title,
.hljs-attribute,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: hsl(var(--accent) / 0.9);
}
.hljs-number,
.hljs-meta,
.hljs-variable,
.hljs-template-variable,
.hljs-type,
.hljs-built_in,
.hljs-params {
color: hsl(218 70% 45%);
}
.hljs-function,
.hljs-title.function_,
.hljs-title.class_ {
color: hsl(var(--foreground) / 0.95);
font-weight: 600;
}
.hljs-regexp,
.hljs-subst,
.hljs-doctag {
color: hsl(24 85% 52%);
}
.hljs-deletion {
color: hsl(var(--destructive) / 0.88);
}
/* ─── Upload zone ─── */
.upload-zone {
border: 2px dashed hsl(var(--border));

View file

@ -1,11 +1,16 @@
import { useState } from 'react'
import { useEffect, useRef, useState } 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 } from 'lucide-react'
import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { FileTree } from '@/features/skill/file-tree'
import { InstallCommand } from '@/features/skill/install-command'
import {
getOverviewCollapseMaxHeight,
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
shouldCollapseOverview,
} from '@/features/skill/overview-collapse'
import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error'
import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
@ -27,6 +32,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { toast } from '@/shared/lib/toast'
import { cn } from '@/shared/lib/utils'
import {
useSkillDetail,
useSkillVersions,
@ -90,6 +96,11 @@ export function SkillDetailPage() {
const [targetVersionInput, setTargetVersionInput] = useState('')
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
const [diffCompareVersion, setDiffCompareVersion] = useState<string | null>(null)
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false)
const [overviewMaxHeight, setOverviewMaxHeight] = useState(OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT)
const overviewContentRef = useRef<HTMLDivElement | null>(null)
const overviewSectionRef = useRef<HTMLDivElement | null>(null)
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
const { user, hasRole } = useAuth()
@ -120,6 +131,62 @@ export function SkillDetailPage() {
const canReport = skill?.canReport ?? true
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
useEffect(() => {
if (!readme || typeof window === 'undefined') {
setIsOverviewCollapsible(false)
setIsOverviewExpanded(false)
return
}
const updateOverviewState = () => {
if (!overviewContentRef.current) {
return
}
const nextMaxHeight = getOverviewCollapseMaxHeight(window.innerWidth, window.innerHeight)
const nextCollapsible = shouldCollapseOverview(
overviewContentRef.current.scrollHeight,
window.innerWidth,
window.innerHeight,
)
setOverviewMaxHeight(nextMaxHeight)
setIsOverviewCollapsible(nextCollapsible)
if (!nextCollapsible) {
setIsOverviewExpanded(false)
}
}
updateOverviewState()
window.addEventListener('resize', updateOverviewState)
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => updateOverviewState())
: null
if (resizeObserver && overviewContentRef.current) {
resizeObserver.observe(overviewContentRef.current)
}
return () => {
window.removeEventListener('resize', updateOverviewState)
resizeObserver?.disconnect()
}
}, [readme])
const handleToggleOverview = () => {
if (!isOverviewExpanded) {
setIsOverviewExpanded(true)
return
}
setIsOverviewExpanded(false)
requestAnimationFrame(() => {
overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
const refreshSkill = () => {
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug, 'versions'] })
@ -506,7 +573,37 @@ export function SkillDetailPage() {
{t('skillDetail.documentationSource', { path: documentationPath })}
</div>
) : null}
<MarkdownRenderer content={readme} />
<div ref={overviewSectionRef} className="space-y-4">
<div
className={cn(
'relative overflow-hidden transition-[max-height] duration-300 ease-out',
!isOverviewExpanded && isOverviewCollapsible && 'rounded-2xl',
)}
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}
>
<div ref={overviewContentRef}>
<MarkdownRenderer content={readme} />
</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" />
) : null}
</div>
{isOverviewCollapsible ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2 rounded-full border-border/70 bg-background/90 px-5 shadow-sm backdrop-blur-sm"
aria-expanded={isOverviewExpanded}
onClick={handleToggleOverview}
>
{isOverviewExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{isOverviewExpanded ? t('skillDetail.collapseOverview') : t('skillDetail.expandOverview')}
</Button>
</div>
) : null}
</div>
</Card>
) : readmeError ? (
<Card className="p-8 text-center">

View file

@ -11,6 +11,10 @@ export default defineConfig({
},
server: {
port: 3000,
watch: {
usePolling: true,
interval: 150,
},
proxy: {
'/api': {
target: 'http://localhost:8080',