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 ( +
+
{children}
);
}
- return (
-
- {children}
-
- );
+ return {children} ;
},
- pre: ({ className = '', children, ...p }) => (
-
- {children}
-
- ),
+ pre: ({ children }) => <>{children}>,
table: (p) => (
-
+
),
diff --git a/src/lib/markdown-headings.ts b/src/lib/markdown-headings.ts
new file mode 100644
index 0000000..25ed92a
--- /dev/null
+++ b/src/lib/markdown-headings.ts
@@ -0,0 +1,52 @@
+export type MarkdownHeading = {
+ id: string;
+ level: 1 | 2 | 3;
+ text: string;
+};
+
+function plainHeadingText(value: string) {
+ return value
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function headingIdBase(text: string) {
+ const normalized = text
+ .normalize("NFKC")
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .slice(0, 80);
+
+ return normalized || "section";
+}
+
+export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
+ const counters = new Map();
+
+ return (content || "")
+ .split(/\r?\n/)
+ .map((line) => {
+ const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim());
+ if (!match) return null;
+
+ const level = match[1].length as 1 | 2 | 3;
+ const text = plainHeadingText(match[2]);
+ const base = headingIdBase(text);
+ const nextCount = (counters.get(base) || 0) + 1;
+ counters.set(base, nextCount);
+
+ return {
+ id: nextCount === 1 ? base : `${base}-${nextCount}`,
+ level,
+ text,
+ };
+ })
+ .filter(Boolean) as MarkdownHeading[];
+}