Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions web/src/features/skill/code-language.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
66 changes: 66 additions & 0 deletions web/src/features/skill/code-language.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
148 changes: 140 additions & 8 deletions web/src/features/skill/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(' ')
Expand All @@ -21,20 +23,94 @@ 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 }) => {
const isInline = !codeClassName?.includes('language-')

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>
)
Expand All @@ -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}
Expand Down
24 changes: 24 additions & 0 deletions web/src/features/skill/overview-collapse.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
15 changes: 15 additions & 0 deletions web/src/features/skill/overview-collapse.ts
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,8 @@
"documentationSource": "来源:{{path}}",
"documentationUnavailableTitle": "文档暂时不可用",
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
"expandOverview": "展开全文",
"collapseOverview": "收起内容",
"noDocumentationTitle": "这个版本没有概览文档",
"noDocumentationDescription": "该技能版本没有包含可直接展示的 README.md 或 SKILL.md。",
"noDocumentationHint": "很多技能包只包含可执行文件或配置文件,可以继续查看下方文件列表和版本信息。",
Expand Down
Loading
Loading