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}
+ {children}
+
+
{children}
)
@@ -46,11 +122,67 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
)
},
+ blockquote: ({ className: blockquoteClassName, children, ...props }) => (
+ + {children} ++ ), + hr: ({ className: hrClassName, ...props }) => ( +