feat(theme-mermaid): upgrade Mermaid to v10.4 - handle async rendering (#9305)

This commit is contained in:
Sébastien Lorber 2023-09-14 17:23:07 +02:00 committed by GitHub
parent dc7ae426ac
commit 58be496da2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 327 additions and 86 deletions

View file

@ -5,9 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import {useMemo} from 'react';
import {useState, useEffect, useMemo, useRef} from 'react';
import {useColorMode, useThemeConfig} from '@docusaurus/theme-common';
import mermaid, {type MermaidConfig} from 'mermaid';
import mermaid from 'mermaid';
import type {RenderResult, MermaidConfig} from 'mermaid';
import type {ThemeConfig} from '@docusaurus/theme-mermaid';
// Stable className to allow users to easily target with CSS
@ -30,48 +31,84 @@ export function useMermaidConfig(): MermaidConfig {
);
}
export function useMermaidSvg(
txt: string,
mermaidConfigParam?: MermaidConfig,
): string {
function useMermaidId(): string {
/*
Random client-only id, we don't care much but mermaid want an id so...
Note: Mermaid doesn't like values provided by Rect.useId() and throws
*/
// return useId(); // tried that, doesn't work ('#d:re:' is not a valid selector.)
return useRef(`mermaid-svg-${Math.round(Math.random() * 10000000)}`).current!;
}
async function renderMermaid({
id,
text,
config,
}: {
id: string;
text: string;
config: MermaidConfig;
}): Promise<RenderResult> {
/*
Mermaid API is really weird :s
It is a big mutable singleton with multiple config levels
Note: most recent API type definitions are missing
There are 2 kind of configs:
- siteConfig: some kind of global/protected shared config
you can only set with "initialize"
- config/currentConfig
the config the renderer will use
it is reset to siteConfig before each render
but it can be altered by the mermaid txt content itself through directives
To use a new mermaid config (on colorMode change for example) we should
update siteConfig, and it can only be done with initialize()
*/
mermaid.mermaidAPI.initialize(config);
try {
return await mermaid.render(id, text);
} catch (e) {
// Because Mermaid add a weird SVG/Message to the DOM on error
// https://github.com/mermaid-js/mermaid/issues/3205#issuecomment-1719620183
document.querySelector(`#d${id}`)?.remove();
throw e;
}
}
export function useMermaidRenderResult({
text,
config: providedConfig,
}: {
text: string;
config?: MermaidConfig;
}): RenderResult | null {
const [result, setResult] = useState<RenderResult | null>(null);
const id = useMermaidId();
/*
For flexibility, we allow the hook to receive a custom Mermaid config
The user could inject a modified version of the default config for example
*/
const defaultMermaidConfig = useMermaidConfig();
const mermaidConfig = mermaidConfigParam ?? defaultMermaidConfig;
const config = providedConfig ?? defaultMermaidConfig;
return useMemo(() => {
/*
Mermaid API is really weird :s
It is a big mutable singleton with multiple config levels
Note: most recent API type definitions are missing
useEffect(() => {
renderMermaid({id, text, config})
// TODO maybe try to use Suspense here and throw the promise?
// See also https://github.com/pmndrs/suspend-react
.then(setResult)
.catch((e) => {
// Funky way to trigger parent React error boundary
// See https://twitter.com/sebastienlorber/status/1628340871899893768
setResult(() => {
throw e;
});
});
}, [id, text, config]);
There are 2 kind of configs:
- siteConfig: some kind of global/protected shared config
you can only set with "initialize"
- config/currentConfig
the config the renderer will use
it is reset to siteConfig before each render
but it can be altered by the mermaid txt content itself through directives
To use a new mermaid config (on colorMode change for example) we should
update siteConfig, and it can only be done with initialize()
*/
mermaid.mermaidAPI.initialize(mermaidConfig);
/*
Random client-only id, we don't care much about it
But mermaid want an id so...
*/
const mermaidId = `mermaid-svg-${Math.round(Math.random() * 10000000)}`;
/*
Not even documented: mermaid.render returns the svg string
Using the documented form is un-necessary
*/
return mermaid.render(mermaidId, txt);
}, [txt, mermaidConfig]);
return result;
}

View file

@ -5,28 +5,54 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
import React, {useEffect, useRef} from 'react';
import type {ReactNode} from 'react';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import {ErrorBoundaryErrorMessageFallback} from '@docusaurus/theme-common';
import {
MermaidContainerClassName,
useMermaidSvg,
useMermaidRenderResult,
} from '@docusaurus/theme-mermaid/client';
import type {Props} from '@theme/Mermaid';
import type {RenderResult} from 'mermaid';
import styles from './styles.module.css';
function MermaidDiagram({value}: Props): JSX.Element {
const svg = useMermaidSvg(value);
function MermaidRenderResult({
renderResult,
}: {
renderResult: RenderResult;
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = ref.current!;
renderResult.bindFunctions?.(div);
}, [renderResult]);
return (
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: svg}}
dangerouslySetInnerHTML={{__html: renderResult.svg}}
/>
);
}
export default function Mermaid(props: Props): JSX.Element {
return <BrowserOnly>{() => <MermaidDiagram {...props} />}</BrowserOnly>;
function MermaidRenderer({value}: Props): ReactNode {
const renderResult = useMermaidRenderResult({text: value});
if (renderResult === null) {
return null;
}
return <MermaidRenderResult renderResult={renderResult} />;
}
export default function Mermaid(props: Props): JSX.Element {
return (
<ErrorBoundary
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}