Skip to content

Commit

Permalink
feat(theme-mermaid): upgrade Mermaid to v10.4 - handle async rendering (
Browse files Browse the repository at this point in the history
  • Loading branch information
slorber committed Sep 14, 2023
1 parent dc7ae42 commit 58be496
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 87 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"website/src/data/users.tsx",
"website/src/data/tweets.tsx",
"website/docusaurus.config.localized.json",
"website/_dogfooding/_pages tests/diagrams.mdx",
"*.xyz",
"*.docx",
"*.gitignore",
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-theme-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,6 @@ export {
export {
ErrorBoundaryTryAgainButton,
ErrorBoundaryError,
ErrorBoundaryErrorMessageFallback,
ErrorCauseBoundary,
} from './utils/errorBoundaryUtils';
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
white-space: pre-wrap;
color: red;
}

.errorBoundaryFallback {
color: red;
padding: 0.55rem;
}
15 changes: 15 additions & 0 deletions packages/docusaurus-theme-common/src/utils/errorBoundaryUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import React, {type ComponentProps} from 'react';
import Translate from '@docusaurus/Translate';
import {getErrorCausalChain} from '@docusaurus/utils-common';
import type {Props as ErrorProps} from '@theme/Error';
import styles from './errorBoundaryUtils.module.css';

export function ErrorBoundaryTryAgainButton(
Expand All @@ -23,6 +24,20 @@ export function ErrorBoundaryTryAgainButton(
</button>
);
}

// A very simple reusable ErrorBoundary fallback component
export function ErrorBoundaryErrorMessageFallback({
error,
tryAgain,
}: ErrorProps): JSX.Element {
return (
<div className={styles.errorBoundaryFallback}>
<p>{error.message}</p>
<ErrorBoundaryTryAgainButton onClick={tryAgain} />
</div>
);
}

export function ErrorBoundaryError({error}: {error: Error}): JSX.Element {
const causalChain = getErrorCausalChain(error);
const fullMessage = causalChain.map((e) => e.message).join('\n\nCause:\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import BrowserOnly from '@docusaurus/BrowserOnly';
import {
ErrorBoundaryTryAgainButton,
ErrorBoundaryErrorMessageFallback,
usePrismTheme,
} from '@docusaurus/theme-common';
import ErrorBoundary from '@docusaurus/ErrorBoundary';

import type {Props} from '@theme/Playground';
import type {Props as ErrorProps} from '@theme/Error';
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';

import styles from './styles.module.css';
Expand All @@ -34,23 +33,17 @@ function LivePreviewLoader() {
return <div>Loading...</div>;
}

function ErrorFallback({error, tryAgain}: ErrorProps): JSX.Element {
return (
<div className={styles.errorFallback}>
<p>{error.message}</p>
<ErrorBoundaryTryAgainButton onClick={tryAgain} />
</div>
);
}

function Preview() {
// No SSR for the live preview
// See https://github.com/facebook/docusaurus/issues/5747
return (
<BrowserOnly fallback={<LivePreviewLoader />}>
{() => (
<>
<ErrorBoundary fallback={(params) => <ErrorFallback {...params} />}>
<ErrorBoundary
fallback={(params) => (
<ErrorBoundaryErrorMessageFallback {...params} />
)}>
<LivePreview />
</ErrorBoundary>
<LiveError />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,3 @@
padding: 1rem;
background-color: var(--ifm-pre-background);
}

.errorFallback {
padding: 0.55rem;
}
2 changes: 1 addition & 1 deletion packages/docusaurus-theme-mermaid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@docusaurus/theme-common": "3.0.0-alpha.0",
"@docusaurus/types": "3.0.0-alpha.0",
"@docusaurus/utils-validation": "3.0.0-alpha.0",
"mermaid": "^9.4.3",
"mermaid": "^10.4.0",
"tslib": "^2.6.0"
},
"devDependencies": {
Expand Down
119 changes: 78 additions & 41 deletions packages/docusaurus-theme-mermaid/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

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
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]);
const config = providedConfig ?? defaultMermaidConfig;

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]);

return result;
}
42 changes: 34 additions & 8 deletions packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
/>
);
}

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 <BrowserOnly>{() => <MermaidDiagram {...props} />}</BrowserOnly>;
return (
<ErrorBoundary
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}

0 comments on commit 58be496

Please sign in to comment.