diff --git a/web/src/features/skill/code-language.test.ts b/web/src/features/skill/code-language.test.ts new file mode 100644 index 00000000..d1ad1186 --- /dev/null +++ b/web/src/features/skill/code-language.test.ts @@ -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() + }) +}) diff --git a/web/src/features/skill/code-language.ts b/web/src/features/skill/code-language.ts new file mode 100644 index 00000000..58133fc4 --- /dev/null +++ b/web/src/features/skill/code-language.ts @@ -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) + } + }) + } +} diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 9478b105..0a9931a3 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -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 (
( +

+ {children} +

+ ), + a: ({ className: linkClassName, children, ...props }) => ( + + {children} + + ), + strong: ({ className: strongClassName, children, ...props }) => ( + + {children} + + ), + h1: ({ className: headingClassName, children, ...props }) => ( +

+ {children} +

+ ), + h2: ({ className: headingClassName, children, ...props }) => ( +

+ {children} +

+ ), + h3: ({ className: headingClassName, children, ...props }) => ( +

+ {children} +

+ ), + ul: ({ className: listClassName, children, ...props }) => ( + + ), + ol: ({ className: listClassName, children, ...props }) => ( +
    + {children} +
+ ), + li: ({ className: itemClassName, children, ...props }) => ( +
  • + {children} +
  • + ), pre: ({ children }) => ( -
    -
    {children}
    +
    +
    +
    {children}
    +
    ), code: ({ className: codeClassName, children, ...props }) => { @@ -34,7 +107,10 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) if (isInline) { return ( - + {children} ) @@ -46,11 +122,67 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ) }, + blockquote: ({ className: blockquoteClassName, children, ...props }) => ( +
    + {children} +
    + ), + hr: ({ className: hrClassName, ...props }) => ( +
    + ), table: ({ children }) => ( -
    - {children}
    +
    +
    + {children}
    +
    ), + thead: ({ className: sectionClassName, children, ...props }) => ( + + {children} + + ), + tbody: ({ className: sectionClassName, children, ...props }) => ( + + {children} + + ), + tr: ({ className: rowClassName, children, ...props }) => ( + + {children} + + ), + th: ({ className: cellClassName, children, ...props }) => ( + + {children} + + ), + td: ({ className: cellClassName, children, ...props }) => ( + + {children} + + ), + img: ({ className: imageClassName, alt, ...props }) => ( + {alt + ), }} > {normalizedContent} diff --git a/web/src/features/skill/overview-collapse.test.ts b/web/src/features/skill/overview-collapse.test.ts new file mode 100644 index 00000000..e05451b6 --- /dev/null +++ b/web/src/features/skill/overview-collapse.test.ts @@ -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) + }) +}) diff --git a/web/src/features/skill/overview-collapse.ts b/web/src/features/skill/overview-collapse.ts new file mode 100644 index 00000000..6011ae9b --- /dev/null +++ b/web/src/features/skill/overview-collapse.ts @@ -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) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 83645f49..f1089eab 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index f4b9e035..cf2c16d6 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -556,6 +556,8 @@ "documentationSource": "来源:{{path}}", "documentationUnavailableTitle": "文档暂时不可用", "documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。", + "expandOverview": "展开全文", + "collapseOverview": "收起内容", "noDocumentationTitle": "这个版本没有概览文档", "noDocumentationDescription": "该技能版本没有包含可直接展示的 README.md 或 SKILL.md。", "noDocumentationHint": "很多技能包只包含可执行文件或配置文件,可以继续查看下方文件列表和版本信息。", diff --git a/web/src/index.css b/web/src/index.css index 94936b61..cec718cb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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)); diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 05abbc66..67839e39 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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(null) const [diffCompareVersion, setDiffCompareVersion] = useState(null) + const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) + const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false) + const [overviewMaxHeight, setOverviewMaxHeight] = useState(OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT) + const overviewContentRef = useRef(null) + const overviewSectionRef = useRef(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 })}
    ) : null} - +
    +
    +
    + +
    + {!isOverviewExpanded && isOverviewCollapsible ? ( +
    + ) : null} +
    + {isOverviewCollapsible ? ( +
    + +
    + ) : null} +
    ) : readmeError ? ( diff --git a/web/vite.config.ts b/web/vite.config.ts index 4ae7e2e5..44db2323 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -11,6 +11,10 @@ export default defineConfig({ }, server: { port: 3000, + watch: { + usePolling: true, + interval: 150, + }, proxy: { '/api': { target: 'http://localhost:8080',