From f4473d963c7c07c3bdd82167be2a2586c1efa7dc Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Wed, 24 Jun 2026 19:44:34 +0800 Subject: [PATCH] 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. --- web/bun.lock | 37 +- web/default/package.json | 6 +- web/default/src/components/ui/markdown.tsx | 715 ++++++++++++++++++++- 3 files changed, 730 insertions(+), 28 deletions(-) diff --git a/web/bun.lock b/web/bun.lock index 3e51d4f8..8baa28cb 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/default/package.json b/web/default/package.json index ac4225fa..88f56d82 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -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:", diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index f0e817a2..0739b57e 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -16,9 +16,11 @@ along with this program. If not, see . 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 = { + ':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 `${escapeHtml(line)}` + }) + .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 ` + + ${label} + ` + } + + if (layout.node.type === 'start' || layout.node.type === 'end') { + return ` + + ${label} + ` + } + + return ` + + ${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 + ? `${escapeHtml(edge.label)}` + : '' + + return `${label}` + } + + const fromAnchor = getFlowAnchor(from, 'bottom') + const toAnchor = getFlowAnchor(to, 'top') + const label = edge.label + ? `${escapeHtml(edge.label)}` + : '' + + return ` + + ${label} + ` + }) + .join('') + const renderedNodes = nodes + .map((node) => { + const position = nodePositions.get(node.id) + + if (!position) { + return '' + } + + return renderFlowShape(position) + }) + .join('') + + return ` +
+ + + + + + + ${renderedEdges} + ${renderedNodes} + +
+ ` +} + +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 ` + + ${label} + + + ${label} + ` + }) + .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 `${escapeHtml(line)}` + }) + .join('') + + return ` + + ${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 ` + + ${label} + ` + }) + .join('') + + return ` +
+ + + + + + + ${participantBoxes} + ${renderedMessages} + +
+ ` +} + +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 '
' + }, + 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 (
*:first-child]:mt-0 [&>*:last-child]:mb-0', '[overflow-wrap:anywhere] break-words', - className + props.className )} - > - ( - - ), - }} - > - {children} - -
+ dangerouslySetInnerHTML={{ __html: html }} + /> ) }