diff --git a/package.json b/package.json index f67cdd26ff..70e3f98dae 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "hast-util-from-html": "2.0.1", "hast-util-to-html": "9.0.0", "hast-util-to-jsx-runtime": "2.3.0", + "hast-util-to-text": "4.0.0", "image-size": "1.1.1", "immer": "10.0.3", "ioredis": "5.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36bcf101f5..f2229b64cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ dependencies: hast-util-to-jsx-runtime: specifier: 2.3.0 version: 2.3.0 + hast-util-to-text: + specifier: 4.0.0 + version: 4.0.0 image-size: specifier: 1.1.1 version: 1.1.1 diff --git a/src/components/ui/Mermaid.tsx b/src/components/ui/Mermaid.tsx index 2452e7c8e0..bf65167a45 100644 --- a/src/components/ui/Mermaid.tsx +++ b/src/components/ui/Mermaid.tsx @@ -1,5 +1,7 @@ "use client" +import type { Element } from "hast" +import { toText } from "hast-util-to-text" import { nanoid } from "nanoid" import { memo, useEffect, useState } from "react" @@ -7,87 +9,84 @@ import { useIsDark } from "~/hooks/useDarkMode" import AdvancedImage from "./AdvancedImage" -const Mermaid = memo( - function Mermaid(props: { children: string }) { - const [loading, setLoading] = useState(true) - const [error, setError] = useState("") - const [svg, setSvg] = useState("") - const [width, setWidth] = useState() - const [height, setHeight] = useState() +const Mermaid = memo(function Mermaid(props: { + children: string + node: Element +}) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [svg, setSvg] = useState("") + const [width, setWidth] = useState() + const [height, setHeight] = useState() + const text = toText(props.node, { + whitespace: "pre", + }) - const isDark = useIsDark() + const isDark = useIsDark() - useEffect(() => { - import("mermaid").then(async (mo) => { - const mermaid = mo.default - mermaid.initialize({ - theme: isDark ? "dark" : "default", - }) + useEffect(() => { + import("mermaid").then(async (mo) => { + const mermaid = mo.default + mermaid.initialize({ + theme: isDark ? "dark" : "default", }) - }, [isDark]) + }) + }, [isDark]) - useEffect(() => { - if (typeof props.children === "string") { - setError("") - setLoading(true) + useEffect(() => { + if (text) { + setError("") + setLoading(true) - import("mermaid").then(async (mo) => { - const mermaid = mo.default - const id = nanoid() - let result - try { - result = await mermaid.render(`mermaid-${id}`, props.children) - } catch (error) { - document.getElementById(`dmermaid-${id}`)?.remove() - if (error instanceof Error) { - setError(error.message) - } - setSvg("") - setWidth(undefined) - setHeight(undefined) + import("mermaid").then(async (mo) => { + const mermaid = mo.default + const id = nanoid() + let result + try { + result = await mermaid.render(`mermaid-${id}`, text) + } catch (error) { + document.getElementById(`dmermaid-${id}`)?.remove() + if (error instanceof Error) { + setError(error.message) } + setSvg("") + setWidth(undefined) + setHeight(undefined) + } - if (result) { - setSvg(result.svg) + if (result) { + setSvg(result.svg) - const match = result.svg.match( - /viewBox="[^"]*\s([\d.]+)\s([\d.]+)"/, - ) - if (match?.[1] && match?.[2]) { - setWidth(parseInt(match?.[1])) - setHeight(parseInt(match?.[2])) - } - setError("") + const match = result.svg.match(/viewBox="[^"]*\s([\d.]+)\s([\d.]+)"/) + if (match?.[1] && match?.[2]) { + setWidth(parseInt(match?.[1])) + setHeight(parseInt(match?.[2])) } - setLoading(false) - }) - } - }, [props.children]) + setError("") + } + setLoading(false) + }) + } + }, [text]) - return loading ? ( -
- Mermaid Loading... -
- ) : svg ? ( -
- -
- ) : ( -
- {error || "Error"} -
- ) - }, - (prevProps, nextProps) => { - return prevProps.children?.[0] === nextProps.children?.[0] - }, -) + return loading ? ( +
+ Mermaid Loading... +
+ ) : svg ? ( +
+ +
+ ) : ( +
+ {error || "Error"} +
+ ) +}) export default Mermaid diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 8d91b30416..8e6d0af9ad 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -16,7 +16,7 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings" import rehypeInferDescriptionMeta from "rehype-infer-description-meta" import rehypeKatex from "rehype-katex" import rehypePrismGenerator from "rehype-prism-plus/generator" -import rehypeRaw, { Options as RehypeRawOptions } from "rehype-raw" +import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import rehypeSlug from "rehype-slug" import remarkBreaks from "remark-breaks" @@ -40,19 +40,14 @@ import AdvancedImage from "~/components/ui/AdvancedImage" import { isServerSide } from "~/lib/utils" import { transformers } from "./embed-transformers" -import { - allowedCustomWrappers, - defaultRules, - rehypeCustomWrapper, -} from "./rehype-custom-wrapper" import { rehypeEmbed } from "./rehype-embed" import { rehypeIpfs } from "./rehype-ipfs" import { rehypeMention } from "./rehype-mention" +import { rehypeMermaid } from "./rehype-mermaid" import { rehypeRemoveH1 } from "./rehype-remove-h1" import { rehypeTable } from "./rehype-table" import { rehypeWrapCode } from "./rehype-wrap-code" import { rehypeExternalLink } from "./rehyper-external-link" -import { remarkMermaid } from "./remark-mermaid" import { remarkPangu } from "./remark-pangu" import { remarkYoutube } from "./remark-youtube" import sanitizeScheme from "./sanitize-schema" @@ -91,19 +86,13 @@ export const renderPageContent = (content: string, strictMode?: boolean) => { .use(remarkDirectiveRehype) .use(remarkCalloutDirectives) .use(remarkYoutube) - .use(remarkMermaid) .use(remarkMath, { singleDollarTextMath: false, }) .use(remarkPangu) .use(emoji) .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw, { - passThrough: allowedCustomWrappers, - } as RehypeRawOptions) - .use(rehypeCustomWrapper, { - rules: defaultRules, - }) + .use(rehypeRaw) .use(rehypeIpfs) .use(rehypeSlug) .use(rehypeAutolinkHeadings, { @@ -125,6 +114,7 @@ export const renderPageContent = (content: string, strictMode?: boolean) => { .use(rehypeSanitize, strictMode ? undefined : sanitizeScheme) .use(rehypeTable) .use(rehypeExternalLink) + .use(rehypeMermaid) .use(rehypeWrapCode) .use(rehypeInferDescriptionMeta) .use(rehypeEmbed, { @@ -183,6 +173,7 @@ export const renderPageContent = (content: string, strictMode?: boolean) => { ignoreInvalidStyle: true, jsx, jsxs, + passNode: true, }), toMetadata: () => { let metadata = { diff --git a/src/markdown/rehype-custom-wrapper.ts b/src/markdown/rehype-custom-wrapper.ts deleted file mode 100644 index 841b06560b..0000000000 --- a/src/markdown/rehype-custom-wrapper.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Root } from "hast" -import { visit } from "unist-util-visit" - -// WHAT IS THIS? -// Wrap some tags with a custom container so that they can -// be replaced by rehype-react and avoid being modified by -// intermediary processes (such as rehype-raw). - -export const allowedCustomWrappers = ["mermaid"] - -export interface IDefaultRulesItem { - test: (node: any) => boolean - handler: (node: any) => void -} - -export const defaultRules: IDefaultRulesItem[] = [ - // 1. mermaid - // rehype-raw will split the node into multiple nodes - // if it contains html tags. (e.g.
) - { - test: (node: any) => - node.type === "raw" && node.value.startsWith(""), - handler: (node: any) => { - node.type = "element" - node.tagName = "mermaid" - node.children = [ - { - type: "text", - value: /([\s\S]*)<\/mermaid>/.exec(node.value)?.[1], - }, - ] - }, - }, -] - -export const rehypeCustomWrapper = ( - options: { rules?: IDefaultRulesItem[] } = {}, -) => { - const rules = options && options.rules ? options.rules : defaultRules - - return function transformer(tree: Root) { - visit(tree, (node: any) => { - for (let rule of rules) { - if (rule.test(node)) { - rule.handler(node) - break - } - } - }) - } -} diff --git a/src/markdown/rehype-mermaid.ts b/src/markdown/rehype-mermaid.ts new file mode 100644 index 0000000000..ea1ce74815 --- /dev/null +++ b/src/markdown/rehype-mermaid.ts @@ -0,0 +1,16 @@ +import type { Root } from "hast" +import type { Plugin } from "unified" +import { visit } from "unist-util-visit" + +export const rehypeMermaid: Plugin<[], Root> = () => (tree: Root) => { + visit(tree, { tagName: "code" }, (node, i, parent) => { + if ( + Array.isArray(node.properties.className) && + node.properties.className.includes("language-mermaid") && + parent?.type === "element" + ) { + parent.tagName = "mermaid" + node.tagName = "div" + } + }) +} diff --git a/src/markdown/rehype-wrap-code.ts b/src/markdown/rehype-wrap-code.ts index 8b56f1ce94..425711f72e 100644 --- a/src/markdown/rehype-wrap-code.ts +++ b/src/markdown/rehype-wrap-code.ts @@ -6,12 +6,7 @@ import { visit } from "unist-util-visit" export const rehypeWrapCode: Plugin, Root> = () => { return (tree: Root) => { visit(tree, { type: "element", tagName: "pre" }, (node, index, parent) => { - if ( - parent && - typeof index === "number" && - // @ts-ignore - node?.properties?.className?.[0] !== "mermaid" - ) { + if (parent && typeof index === "number") { const wrapper = u("element", { tagName: "div", properties: { diff --git a/src/markdown/remark-mermaid.ts b/src/markdown/remark-mermaid.ts deleted file mode 100644 index d23015d0da..0000000000 --- a/src/markdown/remark-mermaid.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Root } from "mdast" -import type { Plugin } from "unified" -import { visit } from "unist-util-visit" - -export const remarkMermaid: Plugin<[], Root> = () => (tree: Root) => { - visit(tree, (node) => { - if (node.type === "code" && node.lang === "mermaid") { - // @ts-ignore - node.type = "html" - node.value = `${node.value}` - } - }) -}