diff --git a/package-lock.json b/package-lock.json index 94dab25..d1020d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "react-markdown": "^9.0.3", "react-qr-code": "^2.0.11", "react-resizable-panels": "^2.1.9", + "react-syntax-highlighter": "^16.1.1", "recharts": "^2.15.4", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -72,6 +73,7 @@ "@types/node": "^22.16.5", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", + "@types/react-syntax-highlighter": "^15.5.13", "autoprefixer": "^10.4.21", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -96,9 +98,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.29.7", + "resolved": "https://package-mirror.liara.ir/repository/npm/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2663,6 +2665,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -2694,6 +2702,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4098,6 +4116,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -4182,6 +4213,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4476,6 +4515,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://package-mirror.liara.ir/repository/npm/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4875,6 +4929,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -6267,6 +6335,15 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6530,6 +6607,26 @@ } } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -6599,6 +6696,22 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", diff --git a/package.json b/package.json index a574077..ad737f8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "react-markdown": "^9.0.3", "react-qr-code": "^2.0.11", "react-resizable-panels": "^2.1.9", + "react-syntax-highlighter": "^16.1.1", "recharts": "^2.15.4", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -74,6 +75,7 @@ "@types/node": "^22.16.5", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", + "@types/react-syntax-highlighter": "^15.5.13", "autoprefixer": "^10.4.21", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 521c308..9481bb5 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,101 +1,164 @@ -import React from 'react'; -import ReactMarkdown from 'react-markdown'; -import type { PluggableList } from 'unified'; -import remarkGfm from 'remark-gfm'; -import rehypeRaw from 'rehype-raw'; -import rehypeSanitize from 'rehype-sanitize'; +"use client"; -type MarkdownSize = 'sm' | 'base' | 'lg'; +import React, { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import type { PluggableList } from "unified"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { extractMarkdownHeadings } from "@/lib/markdown-headings"; +import { cn } from "@/lib/utils"; + +type MarkdownSize = "sm" | "base" | "lg"; type MarkdownProps = { content?: string; allowHtml?: boolean; className?: string; - dir?: 'rtl' | 'ltr'; + dir?: "rtl" | "ltr"; justify?: boolean; size?: MarkdownSize; }; +function CodeBlock({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + const [copied, setCopied] = useState(false); + const language = /language-([\w-]+)/.exec(className || "")?.[1] || "text"; + const code = String(children).replace(/\n$/, ""); + + const copyCode = async () => { + if (!navigator.clipboard) return; + await navigator.clipboard.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }; + + return ( +
+
+ {language} + +
+ + {code} + +
+ ); +} + export default function Markdown({ - content = '', + content = "", allowHtml = false, - className = '', - dir = 'rtl', + className = "", + dir = "rtl", justify = false, - size = 'sm', + size = "sm", }: MarkdownProps) { const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined; + const headings = extractMarkdownHeadings(content); + let headingIndex = 0; const baseSizeClass = - size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base'; + size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base"; const hScale = - size === 'sm' - ? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' } - : size === 'base' - ? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' } - : { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' }; + size === "sm" + ? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" } + : size === "base" + ? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" } + : { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" }; const justifyStyle: React.CSSProperties | undefined = justify - ? { textAlign: 'justify', textJustify: 'inter-word' } + ? { textAlign: "justify", textJustify: "inter-word" } : undefined; + const nextHeadingId = (level: 1 | 2 | 3) => { + while (headingIndex < headings.length) { + const heading = headings[headingIndex]; + headingIndex += 1; + if (heading.level === level) return heading.id; + } + return undefined; + }; + return (

, - h2: (p) =>

, - h3: (p) =>

, - h4: (p) =>

, - p: (p) =>

, - a: (p) => , - ul: (p) =>