diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 32b905a..bfac60a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -26,6 +26,7 @@
"react-syntax-highlighter": "^16.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
+ "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"zustand": "^5.0.7"
@@ -117,6 +118,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -442,6 +444,7 @@
"url": "https://opencollective.com/csstools"
}
],
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -464,6 +467,7 @@
"url": "https://opencollective.com/csstools"
}
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -520,6 +524,7 @@
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -560,6 +565,7 @@
"version": "11.14.1",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1290,6 +1296,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.2.0.tgz",
"integrity": "sha512-NTuyFNen5Z2QY+I242MDZzXnFIVIR6ERxo7vntFi9K1wCgSwvIl0HcAO2OOydKqqKApE6omRiYhpny1ZhGuH7Q==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6",
"@mui/core-downloads-tracker": "^7.2.0",
@@ -1890,8 +1897,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2015,6 +2021,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
@@ -2039,6 +2046,7 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2048,6 +2056,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -2117,6 +2126,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz",
"integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0",
@@ -2468,6 +2478,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2514,7 +2525,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -2628,6 +2638,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -2984,8 +2995,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -3000,6 +3010,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
+ "peer": true,
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
@@ -3115,6 +3126,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3480,6 +3492,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/github-slugger": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+ "license": "ISC"
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3598,6 +3616,19 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-heading-rank": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
+ "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
@@ -3700,6 +3731,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/hast-util-to-string": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
+ "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@@ -4021,6 +4065,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
+ "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -4226,7 +4271,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5427,7 +5471,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5442,7 +5485,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -5454,8 +5496,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/prismjs": {
"version": "1.30.0",
@@ -5523,6 +5564,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5531,6 +5573,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -5721,6 +5764,23 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/rehype-slug": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
+ "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "github-slugger": "^2.0.0",
+ "hast-util-heading-rank": "^3.0.0",
+ "hast-util-to-string": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -6166,6 +6226,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6306,6 +6367,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6535,6 +6597,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -6645,6 +6708,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/frontend/package.json b/frontend/package.json
index c348ac7..d547712 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,6 +30,7 @@
"react-syntax-highlighter": "^16.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
+ "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"zustand": "^5.0.7"
diff --git a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx
index 95dddaa..0f976aa 100644
--- a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx
+++ b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx
@@ -2,6 +2,7 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
+import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { useTheme } from "@mui/material/styles";
@@ -9,6 +10,37 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import "katex/dist/katex.min.css";
+function HeadingWithAnchor({
+ level,
+ id,
+ children,
+ ...props
+}: {
+ level: 1 | 2 | 3 | 4 | 5 | 6;
+ id?: string;
+ children?: React.ReactNode;
+ [key: string]: unknown;
+}) {
+ const Tag = `h${level}` as keyof JSX.IntrinsicElements;
+
+ const handleClick = () => {
+ if (id) {
+ window.history.replaceState(null, "", `#${id}`);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
type MarkdownRendererProps = {
content: string;
imageProps?: MarkdownRendererImageProps;
@@ -55,8 +87,38 @@ const MarkdownRenderer: React.FC = ({
return (
(
+
+ {children}
+
+ ),
+ h2: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h3: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h4: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h5: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
+ h6: ({ node: _node, children, ...props }) => (
+
+ {children}
+
+ ),
a: ({ node: _node, ...props }) => (
{
+ if (!hash) return;
+ // Retry scrolling until the lazy-loaded markdown renders the target element
+ const id = hash.replace("#", "");
+ const interval = setInterval(() => {
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth" });
+ clearInterval(interval);
+ }
+ }, 100);
+ return () => clearInterval(interval);
+ }, [hash]);
return (