Skip to content

Commit

Permalink
feat(markdown-renderer): add syntax highlighting
Browse files Browse the repository at this point in the history
  • Loading branch information
iipanda committed Nov 13, 2024
1 parent 5482eb3 commit e80ba1c
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 282 deletions.
2 changes: 1 addition & 1 deletion apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"remark-code-import": "^1.2.0",
"remark-gfm": "^4.0.0",
"remeda": "^2.14.0",
"shiki": "^1.22.0",
"sonner": "^1.2.3",
"tailwind-merge": "^1.12.0",
"ts-morph": "^22.0.0",
Expand All @@ -79,7 +80,6 @@
"postcss": "^8.4.24",
"remark": "^14.0.3",
"rimraf": "^4.1.3",
"shiki": "^1.10.1",
"tailwindcss": "3.4.6",
"typescript": "^5.5.3",
"unist-builder": "3.0.0",
Expand Down
21 changes: 19 additions & 2 deletions apps/www/public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,31 @@
"type": "registry:ui",
"dependencies": [
"react-markdown",
"remark-gfm"
"remark-gfm",
"shiki"
],
"files": [
{
"path": "ui/markdown-renderer.tsx",
"type": "registry:ui"
}
]
],
"tailwind": {
"config": {
"theme": {
"extend": {
"colors": {
"shiki": {
"light": "var(--shiki-light)",
"light-bg": "var(--shiki-light-bg)",
"dark": "var(--shiki-dark)",
"dark-bg": "var(--shiki-dark-bg)"
}
}
}
}
}
}
},
{
"name": "message-input",
Expand Down
23 changes: 20 additions & 3 deletions apps/www/public/r/markdown-renderer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,31 @@
"type": "registry:ui",
"dependencies": [
"react-markdown",
"remark-gfm"
"remark-gfm",
"shiki"
],
"files": [
{
"path": "ui/markdown-renderer.tsx",
"content": "import React from \"react\"\nimport Markdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { cn } from \"@/lib/utils\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\n\ninterface MarkdownRendererProps {\n children: string\n}\n\nexport function MarkdownRenderer({ children }: MarkdownRendererProps) {\n return (\n <Markdown\n remarkPlugins={[remarkGfm]}\n components={COMPONENTS}\n className=\"space-y-3\"\n >\n {children}\n </Markdown>\n )\n}\n\nconst CodeBlock = ({ children, className, ...restProps }: any) => {\n const code =\n typeof children === \"string\"\n ? children\n : childrenTakeAllStringContents(children)\n\n return (\n <div className=\"group/code relative mb-4\">\n <pre\n className={cn(\n \"bg-background/50 overflow-x-scroll rounded-md border p-4 font-mono text-sm [scrollbar-width:none]\",\n className\n )}\n {...restProps}\n >\n {children}\n </pre>\n\n <div className=\"invisible absolute right-2 top-2 flex space-x-1 rounded-lg p-1 opacity-0 transition-all duration-200 group-hover/code:visible group-hover/code:opacity-100\">\n <CopyButton content={code} copyMessage=\"Copied code to clipboard\" />\n </div>\n </div>\n )\n}\n\nfunction childrenTakeAllStringContents(element: any): string {\n if (typeof element === \"string\") {\n return element\n }\n\n if (element?.props?.children) {\n let children = element.props.children\n\n if (Array.isArray(children)) {\n return children\n .map((child) => childrenTakeAllStringContents(child))\n .join(\"\")\n } else {\n return childrenTakeAllStringContents(children)\n }\n }\n\n return \"\"\n}\n\nconst COMPONENTS = {\n h1: withClass(\"h1\", \"text-2xl font-semibold\"),\n h2: withClass(\"h2\", \"font-semibold text-xl\"),\n h3: withClass(\"h3\", \"font-semibold text-lg\"),\n h4: withClass(\"h4\", \"font-semibold text-base\"),\n h5: withClass(\"h5\", \"font-medium\"),\n strong: withClass(\"strong\", \"font-semibold\"),\n a: withClass(\"a\", \"text-primary underline underline-offset-2\"),\n blockquote: withClass(\"blockquote\", \"border-l-2 border-primary pl-4\"),\n code: ({ children, className, node, ...rest }: any) => {\n const match = /language-(\\w+)/.exec(className || \"\")\n return match ? (\n <CodeBlock className={className} {...rest}>\n {children}\n </CodeBlock>\n ) : (\n <code\n className={cn(\n \"[:not(pre)>&]:bg-background/50 font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5\"\n )}\n {...rest}\n >\n {children}\n </code>\n )\n },\n pre: ({ children }: any) => children,\n ol: withClass(\"ol\", \"list-decimal space-y-2 pl-6\"),\n ul: withClass(\"ul\", \"list-disc space-y-2 pl-6\"),\n li: withClass(\"li\", \"my-1.5\"),\n table: withClass(\n \"table\",\n \"w-full border-collapse overflow-y-auto rounded-md border border-foreground/20\"\n ),\n th: withClass(\n \"th\",\n \"border border-foreground/20 px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right\"\n ),\n td: withClass(\n \"td\",\n \"border border-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right\"\n ),\n tr: withClass(\"tr\", \"m-0 border-t p-0 even:bg-muted\"),\n p: withClass(\"p\", \"whitespace-pre-wrap\"),\n hr: withClass(\"hr\", \"border-foreground/20\"),\n}\n\nfunction withClass(Tag: keyof JSX.IntrinsicElements, classes: string) {\n const Component = ({ node, ...props }: any) => (\n <Tag className={classes} {...props} />\n )\n Component.displayName = Tag\n return Component\n}\n\nexport default MarkdownRenderer\n",
"content": "import React, { Suspense } from \"react\"\nimport Markdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { cn } from \"@/lib/utils\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\n\ninterface MarkdownRendererProps {\n children: string\n}\n\nexport function MarkdownRenderer({ children }: MarkdownRendererProps) {\n return (\n <Markdown\n remarkPlugins={[remarkGfm]}\n components={COMPONENTS}\n className=\"space-y-3\"\n >\n {children}\n </Markdown>\n )\n}\n\nconst shikiPromise = import(\"shiki\")\ninterface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {\n children: string\n language: string\n}\n\nconst HighlightedPre = React.memo(\n async ({ children, language, ...props }: HighlightedPre) => {\n const { codeToTokens, bundledLanguages } = await shikiPromise\n\n if (!(language in bundledLanguages)) {\n return <pre {...props}>{children}</pre>\n }\n\n const { tokens } = await codeToTokens(children, {\n lang: language as keyof typeof bundledLanguages,\n defaultColor: false,\n themes: {\n light: \"github-light\",\n dark: \"github-dark\",\n },\n })\n\n return (\n <pre {...props}>\n <code>\n {tokens.map((line, lineIndex) => (\n <>\n <span key={lineIndex}>\n {line.map((token, tokenIndex) => {\n const style =\n typeof token.htmlStyle === \"string\"\n ? undefined\n : token.htmlStyle\n\n return (\n <span\n key={tokenIndex}\n className=\"text-shiki-light bg-shiki-light-bg dark:text-shiki-dark dark:bg-shiki-dark-bg\"\n style={style}\n >\n {token.content}\n </span>\n )\n })}\n </span>\n {\"\\n\"}\n </>\n ))}\n </code>\n </pre>\n )\n }\n)\nHighlightedPre.displayName = \"HighlightedCode\"\n\ninterface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {\n children: React.ReactNode\n className?: string\n language: string\n}\n\nconst CodeBlock = ({\n children,\n className,\n language,\n ...restProps\n}: CodeBlockProps) => {\n const code =\n typeof children === \"string\"\n ? children\n : childrenTakeAllStringContents(children)\n\n const preClass = cn(\n \"overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]\",\n className\n )\n\n return (\n <div className=\"group/code relative mb-4\">\n <Suspense\n fallback={\n <pre className={preClass} {...restProps}>\n {children}\n </pre>\n }\n >\n <HighlightedPre language={language} className={preClass}>\n {code}\n </HighlightedPre>\n </Suspense>\n\n <div className=\"invisible absolute right-2 top-2 flex space-x-1 rounded-lg p-1 opacity-0 transition-all duration-200 group-hover/code:visible group-hover/code:opacity-100\">\n <CopyButton content={code} copyMessage=\"Copied code to clipboard\" />\n </div>\n </div>\n )\n}\n\nfunction childrenTakeAllStringContents(element: any): string {\n if (typeof element === \"string\") {\n return element\n }\n\n if (element?.props?.children) {\n let children = element.props.children\n\n if (Array.isArray(children)) {\n return children\n .map((child) => childrenTakeAllStringContents(child))\n .join(\"\")\n } else {\n return childrenTakeAllStringContents(children)\n }\n }\n\n return \"\"\n}\n\nconst COMPONENTS = {\n h1: withClass(\"h1\", \"text-2xl font-semibold\"),\n h2: withClass(\"h2\", \"font-semibold text-xl\"),\n h3: withClass(\"h3\", \"font-semibold text-lg\"),\n h4: withClass(\"h4\", \"font-semibold text-base\"),\n h5: withClass(\"h5\", \"font-medium\"),\n strong: withClass(\"strong\", \"font-semibold\"),\n a: withClass(\"a\", \"text-primary underline underline-offset-2\"),\n blockquote: withClass(\"blockquote\", \"border-l-2 border-primary pl-4\"),\n code: ({ children, className, node, ...rest }: any) => {\n const match = /language-(\\w+)/.exec(className || \"\")\n return match ? (\n <CodeBlock className={className} language={match[1]} {...rest}>\n {children}\n </CodeBlock>\n ) : (\n <code\n className={cn(\n \"font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:bg-background/50 [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5\"\n )}\n {...rest}\n >\n {children}\n </code>\n )\n },\n pre: ({ children }: any) => children,\n ol: withClass(\"ol\", \"list-decimal space-y-2 pl-6\"),\n ul: withClass(\"ul\", \"list-disc space-y-2 pl-6\"),\n li: withClass(\"li\", \"my-1.5\"),\n table: withClass(\n \"table\",\n \"w-full border-collapse overflow-y-auto rounded-md border border-foreground/20\"\n ),\n th: withClass(\n \"th\",\n \"border border-foreground/20 px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right\"\n ),\n td: withClass(\n \"td\",\n \"border border-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right\"\n ),\n tr: withClass(\"tr\", \"m-0 border-t p-0 even:bg-muted\"),\n p: withClass(\"p\", \"whitespace-pre-wrap\"),\n hr: withClass(\"hr\", \"border-foreground/20\"),\n}\n\nfunction withClass(Tag: keyof JSX.IntrinsicElements, classes: string) {\n const Component = ({ node, ...props }: any) => (\n <Tag className={classes} {...props} />\n )\n Component.displayName = Tag\n return Component\n}\n\nexport default MarkdownRenderer\n",
"type": "registry:ui",
"target": ""
}
]
],
"tailwind": {
"config": {
"theme": {
"extend": {
"colors": {
"shiki": {
"light": "var(--shiki-light)",
"light-bg": "var(--shiki-light-bg)",
"dark": "var(--shiki-dark)",
"dark-bg": "var(--shiki-dark-bg)"
}
}
}
}
}
}
}
98 changes: 86 additions & 12 deletions apps/www/registry/default/ui/markdown-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react"
import React, { Suspense } from "react"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"

Expand All @@ -21,23 +21,97 @@ export function MarkdownRenderer({ children }: MarkdownRendererProps) {
)
}

const CodeBlock = ({ children, className, ...restProps }: any) => {
const shikiPromise = import("shiki")
interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {
children: string
language: string
}

const HighlightedPre = React.memo(
async ({ children, language, ...props }: HighlightedPre) => {
const { codeToTokens, bundledLanguages } = await shikiPromise

if (!(language in bundledLanguages)) {
return <pre {...props}>{children}</pre>
}

const { tokens } = await codeToTokens(children, {
lang: language as keyof typeof bundledLanguages,
defaultColor: false,
themes: {
light: "github-light",
dark: "github-dark",
},
})

return (
<pre {...props}>
<code>
{tokens.map((line, lineIndex) => (
<>
<span key={lineIndex}>
{line.map((token, tokenIndex) => {
const style =
typeof token.htmlStyle === "string"
? undefined
: token.htmlStyle

return (
<span
key={tokenIndex}
className="text-shiki-light bg-shiki-light-bg dark:text-shiki-dark dark:bg-shiki-dark-bg"
style={style}
>
{token.content}
</span>
)
})}
</span>
{"\n"}
</>
))}
</code>
</pre>
)
}
)
HighlightedPre.displayName = "HighlightedCode"

interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
children: React.ReactNode
className?: string
language: string
}

const CodeBlock = ({
children,
className,
language,
...restProps
}: CodeBlockProps) => {
const code =
typeof children === "string"
? children
: childrenTakeAllStringContents(children)

const preClass = cn(
"overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]",
className
)

return (
<div className="group/code relative mb-4">
<pre
className={cn(
"bg-background/50 overflow-x-scroll rounded-md border p-4 font-mono text-sm [scrollbar-width:none]",
className
)}
{...restProps}
<Suspense
fallback={
<pre className={preClass} {...restProps}>
{children}
</pre>
}
>
{children}
</pre>
<HighlightedPre language={language} className={preClass}>
{code}
</HighlightedPre>
</Suspense>

<div className="invisible absolute right-2 top-2 flex space-x-1 rounded-lg p-1 opacity-0 transition-all duration-200 group-hover/code:visible group-hover/code:opacity-100">
<CopyButton content={code} copyMessage="Copied code to clipboard" />
Expand Down Expand Up @@ -78,13 +152,13 @@ const COMPONENTS = {
code: ({ children, className, node, ...rest }: any) => {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<CodeBlock className={className} {...rest}>
<CodeBlock className={className} language={match[1]} {...rest}>
{children}
</CodeBlock>
) : (
<code
className={cn(
"[:not(pre)>&]:bg-background/50 font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5"
"font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:bg-background/50 [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5"
)}
{...rest}
>
Expand Down
18 changes: 17 additions & 1 deletion apps/www/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,23 @@ export const registry: Registry = [
name: "markdown-renderer",
type: "registry:ui",
files: ["ui/markdown-renderer.tsx"],
dependencies: ["react-markdown", "remark-gfm"],
dependencies: ["react-markdown", "remark-gfm", "shiki"],
tailwind: {
config: {
theme: {
extend: {
colors: {
shiki: {
light: "var(--shiki-light)",
"light-bg": "var(--shiki-light-bg)",
dark: "var(--shiki-dark)",
"dark-bg": "var(--shiki-dark-bg)",
},
},
},
},
},
},
},
{
name: "message-input",
Expand Down
Loading

0 comments on commit e80ba1c

Please sign in to comment.