fix(web): replace default markdown renderer and expand syntax support (#5689)
* fix(markdown): render default markdown with marked - switch default frontend markdown rendering from react-markdown/remark-gfm to marked to avoid old WebKit parse failures from lookbehind regex literals - sanitize marked HTML output with DOMPurify and preserve external link target and rel behavior - remove default direct dependencies on react-markdown, remark-gfm, and rehype-raw while leaving classic unchanged * fix(markdown): expand default markdown rendering support - render default markdown with marked extensions for KaTeX formulas, page breaks, and common emoji shortcodes. - sanitize KaTeX output with an explicit DOMPurify allowlist while preserving external link behavior. - avoid overriding marked text rendering so lists and inline parsing keep their internal parser context. * fix(markdown): render diagram code blocks in default UI - add sanitized SVG rendering for flow and sequence diagram code blocks. - size flow nodes from their labels and route edges from node anchors to prevent clipping. - style diagram nodes, arrows, labels, and notes with theme-aware classes.
This commit is contained in:
Vendored
+31
-6
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "new-api-web-workspace",
|
||||
@@ -89,10 +90,13 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "catalog:",
|
||||
"dompurify": "3.4.5",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.5",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -103,12 +107,9 @@
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "3.8.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "catalog:",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "catalog:",
|
||||
@@ -1642,7 +1643,7 @@
|
||||
|
||||
"doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
|
||||
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
@@ -2080,7 +2081,7 @@
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
"katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
@@ -2162,7 +2163,7 @@
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
|
||||
"marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
@@ -3082,6 +3083,8 @@
|
||||
|
||||
"@lobehub/ui/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@lobehub/ui/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"@lobehub/ui/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||
|
||||
"@lobehub/ui/uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="],
|
||||
@@ -3260,8 +3263,14 @@
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
|
||||
|
||||
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-math/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"msw/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
@@ -3300,14 +3309,20 @@
|
||||
|
||||
"react-template/i18next-browser-languagedetector": ["i18next-browser-languagedetector@7.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ=="],
|
||||
|
||||
"react-template/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"react-template/lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="],
|
||||
|
||||
"react-template/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
|
||||
|
||||
"react-template/react-i18next": ["react-i18next@13.5.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA=="],
|
||||
|
||||
"react-template/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
|
||||
|
||||
"rehype-katex/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -3360,6 +3375,8 @@
|
||||
|
||||
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@lobehub/ui/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
|
||||
@@ -3482,6 +3499,10 @@
|
||||
|
||||
"leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
|
||||
|
||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"react-template/@visactor/react-vchart/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="],
|
||||
@@ -3504,12 +3525,16 @@
|
||||
|
||||
"react-template/@visactor/vchart/@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="],
|
||||
|
||||
"react-template/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"react-template/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"react-template/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"react-template/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"shiki-stream/@shikijs/core/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
|
||||
|
||||
Vendored
+3
-3
@@ -41,10 +41,13 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "catalog:",
|
||||
"dompurify": "3.4.5",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.5",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -55,12 +58,9 @@
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "3.8.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "catalog:",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "catalog:",
|
||||
|
||||
+696
-19
@@ -16,9 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import DOMPurify from 'dompurify'
|
||||
import * as katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { Marked, Renderer, type MarkedExtension, type Tokens } from 'marked'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MarkdownProps {
|
||||
@@ -26,7 +28,682 @@ interface MarkdownProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
const markdownOptions = {
|
||||
async: false,
|
||||
breaks: false,
|
||||
gfm: true,
|
||||
} as const
|
||||
|
||||
const emojiShortcodes: Record<string, string> = {
|
||||
':fa-gear:': '\u2699\ufe0f',
|
||||
':fa-star:': '\u2b50',
|
||||
':smiley:': '\ud83d\ude03',
|
||||
':star:': '\u2b50',
|
||||
}
|
||||
|
||||
const allowedAttributes = [
|
||||
'checked',
|
||||
'class',
|
||||
'd',
|
||||
'data-diagram',
|
||||
'disabled',
|
||||
'fill',
|
||||
'height',
|
||||
'id',
|
||||
'marker-end',
|
||||
'markerheight',
|
||||
'markerHeight',
|
||||
'markerUnits',
|
||||
'markerunits',
|
||||
'markerWidth',
|
||||
'markerwidth',
|
||||
'offset',
|
||||
'orient',
|
||||
'points',
|
||||
'preserveAspectRatio',
|
||||
'preserveaspectratio',
|
||||
'r',
|
||||
'refX',
|
||||
'refx',
|
||||
'refY',
|
||||
'refy',
|
||||
'rx',
|
||||
'ry',
|
||||
'stroke',
|
||||
'stroke-dasharray',
|
||||
'stroke-width',
|
||||
'style',
|
||||
'target',
|
||||
'text-anchor',
|
||||
'dominant-baseline',
|
||||
'dy',
|
||||
'viewBox',
|
||||
'viewbox',
|
||||
'width',
|
||||
'x',
|
||||
'x1',
|
||||
'x2',
|
||||
'y',
|
||||
'y1',
|
||||
'y2',
|
||||
]
|
||||
|
||||
const allowedTags = [
|
||||
'annotation',
|
||||
'circle',
|
||||
'defs',
|
||||
'ellipse',
|
||||
'line',
|
||||
'math',
|
||||
'marker',
|
||||
'mfrac',
|
||||
'mi',
|
||||
'mn',
|
||||
'mo',
|
||||
'mover',
|
||||
'mpadded',
|
||||
'mrow',
|
||||
'mspace',
|
||||
'msqrt',
|
||||
'mstyle',
|
||||
'msub',
|
||||
'msubsup',
|
||||
'msup',
|
||||
'mtable',
|
||||
'mtd',
|
||||
'mtext',
|
||||
'mtr',
|
||||
'path',
|
||||
'polygon',
|
||||
'rect',
|
||||
'semantics',
|
||||
'stop',
|
||||
'svg',
|
||||
'text',
|
||||
'tspan',
|
||||
]
|
||||
|
||||
const sanitizeOptions = {
|
||||
ADD_ATTR: allowedAttributes,
|
||||
ADD_TAGS: allowedTags,
|
||||
} as const
|
||||
|
||||
type FlowNode = {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type FlowEdge = {
|
||||
from: string
|
||||
label?: string
|
||||
to: string
|
||||
}
|
||||
|
||||
type FlowNodeLayout = {
|
||||
height: number
|
||||
labelLines: string[]
|
||||
node: FlowNode
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type SequenceMessage = {
|
||||
from?: string
|
||||
isNote?: boolean
|
||||
label: string
|
||||
lineStyle?: 'solid' | 'dashed'
|
||||
noteSide?: 'left' | 'right'
|
||||
target: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function normalizeMathSource(source: string): string {
|
||||
return source
|
||||
.trim()
|
||||
.replace(/^\\\(/, '')
|
||||
.replace(/\\\)$/, '')
|
||||
.replace(/^\\\[/, '')
|
||||
.replace(/\\\]$/, '')
|
||||
}
|
||||
|
||||
function renderMath(source: string, displayMode: boolean): string {
|
||||
return katex.renderToString(normalizeMathSource(source), {
|
||||
displayMode,
|
||||
output: 'htmlAndMathml',
|
||||
throwOnError: false,
|
||||
})
|
||||
}
|
||||
|
||||
function replaceEmojiShortcodes(value: string): string {
|
||||
return value.replace(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
|
||||
return emojiShortcodes[shortcode] ?? shortcode
|
||||
})
|
||||
}
|
||||
|
||||
function getTextUnits(value: string): number {
|
||||
return Array.from(value).reduce((total, character) => {
|
||||
if (/\s/.test(character)) {
|
||||
return total + 0.5
|
||||
}
|
||||
|
||||
if (/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(character)) {
|
||||
return total + 2
|
||||
}
|
||||
|
||||
return total + 1
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function splitFlowLabel(label: string, maxUnits: number): string[] {
|
||||
const words = label.trim().split(/(\s+)/).filter(Boolean)
|
||||
const lines: string[] = []
|
||||
let currentLine = ''
|
||||
|
||||
words.forEach((word) => {
|
||||
const candidate = `${currentLine}${word}`
|
||||
|
||||
if (currentLine && getTextUnits(candidate) > maxUnits) {
|
||||
lines.push(currentLine.trim())
|
||||
currentLine = word.trimStart()
|
||||
return
|
||||
}
|
||||
|
||||
currentLine = candidate
|
||||
})
|
||||
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine.trim())
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines : [label]
|
||||
}
|
||||
|
||||
function renderFlowText(layout: FlowNodeLayout): string {
|
||||
const lineHeight = 18
|
||||
const firstLineY = layout.y - ((layout.labelLines.length - 1) * lineHeight) / 2 + 5
|
||||
|
||||
return layout.labelLines
|
||||
.map((line, index) => {
|
||||
return `<text x="${layout.x}" y="${firstLineY + index * lineHeight}" text-anchor="middle" class="markdown-diagram-text">${escapeHtml(line)}</text>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function getFlowNodeLayout(node: FlowNode, index: number, centerX: number): FlowNodeLayout {
|
||||
const isCondition = node.type === 'condition'
|
||||
const labelLines = splitFlowLabel(node.label, isCondition ? 14 : 18)
|
||||
const labelWidth = Math.max(...labelLines.map((line) => getTextUnits(line) * 7.2))
|
||||
const textHeight = labelLines.length * 18
|
||||
|
||||
if (isCondition) {
|
||||
return {
|
||||
height: Math.max(112, textHeight + 76),
|
||||
labelLines,
|
||||
node,
|
||||
width: Math.max(190, labelWidth + 92),
|
||||
x: centerX,
|
||||
y: 64 + index * 132,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'start' || node.type === 'end') {
|
||||
return {
|
||||
height: 38,
|
||||
labelLines,
|
||||
node,
|
||||
width: Math.max(124, labelWidth + 44),
|
||||
x: centerX,
|
||||
y: 64 + index * 132,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
height: Math.max(54, textHeight + 28),
|
||||
labelLines,
|
||||
node,
|
||||
width: Math.max(166, labelWidth + 52),
|
||||
x: centerX,
|
||||
y: 64 + index * 132,
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowAnchor(layout: FlowNodeLayout, side: 'bottom' | 'left' | 'right' | 'top'): {
|
||||
x: number
|
||||
y: number
|
||||
} {
|
||||
if (side === 'top') {
|
||||
return { x: layout.x, y: layout.y - layout.height / 2 }
|
||||
}
|
||||
|
||||
if (side === 'bottom') {
|
||||
return { x: layout.x, y: layout.y + layout.height / 2 }
|
||||
}
|
||||
|
||||
if (side === 'left') {
|
||||
return { x: layout.x - layout.width / 2, y: layout.y }
|
||||
}
|
||||
|
||||
return { x: layout.x + layout.width / 2, y: layout.y }
|
||||
}
|
||||
|
||||
function renderFlowShape(layout: FlowNodeLayout): string {
|
||||
const halfWidth = layout.width / 2
|
||||
const halfHeight = layout.height / 2
|
||||
const label = renderFlowText(layout)
|
||||
|
||||
if (layout.node.type === 'condition') {
|
||||
return `
|
||||
<polygon points="${layout.x},${layout.y - halfHeight} ${layout.x + halfWidth},${layout.y} ${layout.x},${layout.y + halfHeight} ${layout.x - halfWidth},${layout.y}" class="markdown-diagram-node markdown-flow-condition" />
|
||||
${label}
|
||||
`
|
||||
}
|
||||
|
||||
if (layout.node.type === 'start' || layout.node.type === 'end') {
|
||||
return `
|
||||
<rect x="${layout.x - halfWidth}" y="${layout.y - halfHeight}" width="${layout.width}" height="${layout.height}" rx="${halfHeight}" ry="${halfHeight}" class="markdown-diagram-node markdown-flow-terminal" />
|
||||
${label}
|
||||
`
|
||||
}
|
||||
|
||||
return `
|
||||
<rect x="${layout.x - halfWidth}" y="${layout.y - halfHeight}" width="${layout.width}" height="${layout.height}" rx="6" ry="6" class="markdown-diagram-node markdown-flow-operation" />
|
||||
${label}
|
||||
`
|
||||
}
|
||||
|
||||
function parseFlowDiagram(source: string): { edges: FlowEdge[]; nodes: FlowNode[] } {
|
||||
const lines = source
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const nodes: FlowNode[] = []
|
||||
const edges: FlowEdge[] = []
|
||||
|
||||
lines.forEach((line) => {
|
||||
const nodeMatch = /^([A-Za-z][\w-]*)=>([A-Za-z]+):\s*(.+)$/.exec(line)
|
||||
|
||||
if (nodeMatch) {
|
||||
const [, id, type, label] = nodeMatch
|
||||
nodes.push({ id, label, type: type.toLowerCase() })
|
||||
return
|
||||
}
|
||||
|
||||
const edgeParts = line.split('->')
|
||||
|
||||
if (edgeParts.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < edgeParts.length - 1; index += 1) {
|
||||
const fromMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(edgeParts[index])
|
||||
const toMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(edgeParts[index + 1])
|
||||
|
||||
if (!fromMatch || !toMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const from = fromMatch[1]
|
||||
const to = toMatch[1]
|
||||
const edgeLabel = fromMatch[2]
|
||||
edges.push({ from, label: edgeLabel, to })
|
||||
}
|
||||
})
|
||||
|
||||
return { edges, nodes }
|
||||
}
|
||||
|
||||
function renderFlowDiagram(source: string): string {
|
||||
const { edges, nodes } = parseFlowDiagram(source)
|
||||
const width = 660
|
||||
const centerX = 300
|
||||
const loopX = 520
|
||||
const nodeIndex = new Map(nodes.map((node, index) => [node.id, index]))
|
||||
const nodePositions = new Map(
|
||||
nodes.map((node, index) => [node.id, getFlowNodeLayout(node, index, centerX)])
|
||||
)
|
||||
const lastNode = nodes.length > 0 ? nodePositions.get(nodes[nodes.length - 1].id) : undefined
|
||||
const height = Math.max(180, (lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54)
|
||||
const renderedEdges = edges
|
||||
.map((edge) => {
|
||||
const from = nodePositions.get(edge.from)
|
||||
const to = nodePositions.get(edge.to)
|
||||
|
||||
if (!from || !to) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const isBackward = (nodeIndex.get(edge.to) ?? 0) <= (nodeIndex.get(edge.from) ?? 0)
|
||||
|
||||
if (isBackward) {
|
||||
const fromAnchor = getFlowAnchor(from, 'right')
|
||||
const toAnchor = getFlowAnchor(to, 'right')
|
||||
const d = `M ${fromAnchor.x} ${fromAnchor.y} C ${loopX} ${fromAnchor.y}, ${loopX} ${toAnchor.y}, ${toAnchor.x} ${toAnchor.y}`
|
||||
const label = edge.label
|
||||
? `<text x="${loopX - 38}" y="${(fromAnchor.y + toAnchor.y) / 2 - 8}" class="markdown-diagram-edge-label">${escapeHtml(edge.label)}</text>`
|
||||
: ''
|
||||
|
||||
return `<path d="${d}" class="markdown-diagram-edge" marker-end="url(#markdown-diagram-arrow)" />${label}`
|
||||
}
|
||||
|
||||
const fromAnchor = getFlowAnchor(from, 'bottom')
|
||||
const toAnchor = getFlowAnchor(to, 'top')
|
||||
const label = edge.label
|
||||
? `<text x="${fromAnchor.x + 38}" y="${(fromAnchor.y + toAnchor.y) / 2 - 8}" class="markdown-diagram-edge-label">${escapeHtml(edge.label)}</text>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<line x1="${fromAnchor.x}" y1="${fromAnchor.y}" x2="${toAnchor.x}" y2="${toAnchor.y}" class="markdown-diagram-edge" marker-end="url(#markdown-diagram-arrow)" />
|
||||
${label}
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
const renderedNodes = nodes
|
||||
.map((node) => {
|
||||
const position = nodePositions.get(node.id)
|
||||
|
||||
if (!position) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return renderFlowShape(position)
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="not-prose markdown-diagram">
|
||||
<svg data-diagram="flow" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" preserveAspectRatio="xMidYMin meet">
|
||||
<defs>
|
||||
<marker id="markdown-diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" class="markdown-diagram-arrow" />
|
||||
</marker>
|
||||
</defs>
|
||||
${renderedEdges}
|
||||
${renderedNodes}
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function parseSequenceDiagram(source: string): { messages: SequenceMessage[]; participants: string[] } {
|
||||
const lines = source
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const participants: string[] = []
|
||||
const messages: SequenceMessage[] = []
|
||||
|
||||
function addParticipant(name: string): void {
|
||||
if (!participants.includes(name)) {
|
||||
participants.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
const noteMatch = /^Note\s+(left|right)\s+of\s+([^:]+):\s*(.+)$/.exec(line)
|
||||
|
||||
if (noteMatch) {
|
||||
const [, side, target, label] = noteMatch
|
||||
const participant = target.trim()
|
||||
addParticipant(participant)
|
||||
messages.push({
|
||||
isNote: true,
|
||||
label: label.replaceAll('\\n', '\n'),
|
||||
noteSide: side as 'left' | 'right',
|
||||
target: participant,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const messageMatch = /^([^-\s]+)\s*(-{1,2}>>?|-->)\s*([^:]+):\s*(.+)$/.exec(line)
|
||||
|
||||
if (!messageMatch) {
|
||||
return
|
||||
}
|
||||
|
||||
const [, from, arrow, to, label] = messageMatch
|
||||
const fromName = from.trim()
|
||||
const toName = to.trim()
|
||||
addParticipant(fromName)
|
||||
addParticipant(toName)
|
||||
messages.push({
|
||||
from: fromName,
|
||||
label,
|
||||
lineStyle: arrow.startsWith('--') ? 'dashed' : 'solid',
|
||||
target: toName,
|
||||
to: toName,
|
||||
})
|
||||
})
|
||||
|
||||
return { messages, participants }
|
||||
}
|
||||
|
||||
function renderSequenceDiagram(source: string): string {
|
||||
const { messages, participants } = parseSequenceDiagram(source)
|
||||
const laneGap = 190
|
||||
const marginX = 80
|
||||
const top = 42
|
||||
const rowGap = 72
|
||||
const width = Math.max(360, marginX * 2 + Math.max(0, participants.length - 1) * laneGap)
|
||||
const height = Math.max(180, 126 + messages.length * rowGap)
|
||||
const positions = new Map(
|
||||
participants.map((participant, index) => [participant, marginX + index * laneGap])
|
||||
)
|
||||
const participantBoxes = participants
|
||||
.map((participant) => {
|
||||
const x = positions.get(participant) ?? marginX
|
||||
const label = escapeHtml(participant)
|
||||
|
||||
return `
|
||||
<rect x="${x - 64}" y="${top}" width="128" height="44" rx="4" ry="4" class="markdown-diagram-node" />
|
||||
<text x="${x}" y="${top + 27}" text-anchor="middle" class="markdown-diagram-text">${label}</text>
|
||||
<line x1="${x}" y1="${top + 44}" x2="${x}" y2="${height - 46}" class="markdown-sequence-lifeline" />
|
||||
<rect x="${x - 64}" y="${height - 46}" width="128" height="44" rx="4" ry="4" class="markdown-diagram-node" />
|
||||
<text x="${x}" y="${height - 19}" text-anchor="middle" class="markdown-diagram-text">${label}</text>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
const renderedMessages = messages
|
||||
.map((message, index) => {
|
||||
const y = top + 78 + index * rowGap
|
||||
|
||||
if (message.isNote) {
|
||||
const targetX = positions.get(message.target) ?? marginX
|
||||
const noteX = message.noteSide === 'left' ? targetX - 154 : targetX + 24
|
||||
const lines = message.label.split('\n')
|
||||
const noteHeight = 28 + Math.max(0, lines.length - 1) * 16
|
||||
const textLines = lines
|
||||
.map((line, lineIndex) => {
|
||||
return `<text x="${noteX + 8}" y="${y + 18 + lineIndex * 16}" class="markdown-sequence-note-text">${escapeHtml(line)}</text>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<rect x="${noteX}" y="${y}" width="132" height="${noteHeight}" rx="3" ry="3" class="markdown-sequence-note" />
|
||||
${textLines}
|
||||
`
|
||||
}
|
||||
|
||||
const fromX = positions.get(message.from ?? '') ?? marginX
|
||||
const toX = positions.get(message.to ?? '') ?? marginX
|
||||
const labelX = (fromX + toX) / 2
|
||||
const label = escapeHtml(message.label)
|
||||
const dash = message.lineStyle === 'dashed' ? ' stroke-dasharray="4 4"' : ''
|
||||
|
||||
return `
|
||||
<line x1="${fromX}" y1="${y}" x2="${toX}" y2="${y}" class="markdown-diagram-edge"${dash} marker-end="url(#markdown-diagram-arrow)" />
|
||||
<text x="${labelX}" y="${y - 8}" text-anchor="middle" class="markdown-diagram-edge-label">${label}</text>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="not-prose markdown-diagram">
|
||||
<svg data-diagram="sequence" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" preserveAspectRatio="xMidYMin meet">
|
||||
<defs>
|
||||
<marker id="markdown-diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" class="markdown-diagram-arrow" />
|
||||
</marker>
|
||||
</defs>
|
||||
${participantBoxes}
|
||||
${renderedMessages}
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const markdownRenderer = new Renderer()
|
||||
const renderDefaultCode = markdownRenderer.code.bind(markdownRenderer)
|
||||
|
||||
markdownRenderer.code = (token: Tokens.Code): string => {
|
||||
const language = token.lang?.toLowerCase()
|
||||
|
||||
if (language === 'math' || language === 'katex' || language === 'latex') {
|
||||
return renderMath(token.text, true)
|
||||
}
|
||||
|
||||
if (language === 'flow') {
|
||||
return renderFlowDiagram(token.text)
|
||||
}
|
||||
|
||||
if (language === 'seq') {
|
||||
return renderSequenceDiagram(token.text)
|
||||
}
|
||||
|
||||
return renderDefaultCode(token)
|
||||
}
|
||||
|
||||
const markdownExtensions: MarkedExtension[] = [
|
||||
{
|
||||
walkTokens(token) {
|
||||
if (token.type !== 'text') {
|
||||
return
|
||||
}
|
||||
|
||||
token.text = replaceEmojiShortcodes(token.text)
|
||||
},
|
||||
extensions: [
|
||||
{
|
||||
level: 'block',
|
||||
name: 'pageBreak',
|
||||
renderer() {
|
||||
return '<hr class="markdown-page-break">'
|
||||
},
|
||||
start(source: string) {
|
||||
return source.match(/^\[========\]/m)?.index
|
||||
},
|
||||
tokenizer(source: string) {
|
||||
const match = /^\[========\](?:\n|$)/.exec(source)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
raw: match[0],
|
||||
type: 'pageBreak',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
level: 'block',
|
||||
name: 'blockMath',
|
||||
renderer(token) {
|
||||
return renderMath(String(token.text), true)
|
||||
},
|
||||
start(source: string) {
|
||||
return source.match(/^\$\$/m)?.index
|
||||
},
|
||||
tokenizer(source: string) {
|
||||
const match = /^\$\$\n?([\s\S]+?)\n?\$\$(?:\n|$)/.exec(source)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
type: 'blockMath',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
level: 'inline',
|
||||
name: 'inlineMath',
|
||||
renderer(token) {
|
||||
return renderMath(String(token.text), false)
|
||||
},
|
||||
start(source: string) {
|
||||
const index = source.indexOf('$$')
|
||||
|
||||
if (index === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return index
|
||||
},
|
||||
tokenizer(source: string) {
|
||||
const match = /^\$\$([^\n$]+?)\$\$/.exec(source)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
type: 'inlineMath',
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const markdownParser = new Marked({
|
||||
...markdownOptions,
|
||||
renderer: markdownRenderer,
|
||||
})
|
||||
|
||||
markdownParser.use(...markdownExtensions)
|
||||
|
||||
function addExternalLinkAttributes(html: string): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return html
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
|
||||
template.content.querySelectorAll('a[href]').forEach((link) => {
|
||||
link.setAttribute('target', '_blank')
|
||||
link.setAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
return template.innerHTML
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const parsedHtml = markdownParser.parse(markdown, markdownOptions)
|
||||
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
|
||||
|
||||
return addExternalLinkAttributes(html)
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const html = useMemo(() => renderMarkdown(props.children), [props.children])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -42,23 +719,23 @@ export function Markdown({ children, className }: MarkdownProps) {
|
||||
'prose-table:border prose-thead:bg-muted',
|
||||
'prose-td:border prose-th:border prose-td:px-3 prose-th:px-3',
|
||||
'prose-img:rounded-lg prose-img:shadow-sm',
|
||||
'[&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden',
|
||||
'[&_.markdown-page-break]:my-6 [&_.markdown-page-break]:border-dashed',
|
||||
'[&_.markdown-diagram]:my-4 [&_.markdown-diagram]:overflow-x-auto [&_.markdown-diagram]:rounded-md [&_.markdown-diagram]:border [&_.markdown-diagram]:bg-background [&_.markdown-diagram]:p-4',
|
||||
'[&_.markdown-diagram_svg]:mx-auto [&_.markdown-diagram_svg]:max-w-full',
|
||||
'[&_.markdown-diagram-node]:fill-[color-mix(in_oklch,var(--primary)_8%,var(--background))] [&_.markdown-diagram-node]:stroke-primary [&_.markdown-diagram-node]:stroke-[1.5]',
|
||||
'[&_.markdown-diagram-text]:fill-foreground [&_.markdown-diagram-text]:text-sm [&_.markdown-diagram-text]:font-medium',
|
||||
'[&_.markdown-diagram-edge]:stroke-muted-foreground [&_.markdown-diagram-edge]:stroke-[1.5] [&_.markdown-diagram-edge]:fill-none',
|
||||
'[&_.markdown-diagram-arrow]:fill-muted-foreground',
|
||||
'[&_.markdown-diagram-edge-label]:fill-muted-foreground [&_.markdown-diagram-edge-label]:text-xs',
|
||||
'[&_.markdown-sequence-lifeline]:stroke-primary [&_.markdown-sequence-lifeline]:stroke-[1.5]',
|
||||
'[&_.markdown-sequence-note]:fill-warning/20 [&_.markdown-sequence-note]:stroke-warning',
|
||||
'[&_.markdown-sequence-note-text]:fill-foreground [&_.markdown-sequence-note-text]:text-xs',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[overflow-wrap:anywhere] break-words',
|
||||
className
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
// 自定义组件渲染(可选)
|
||||
a: ({ node, ...props }) => (
|
||||
<a {...props} target='_blank' rel='noopener noreferrer' />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user