UNPKG

@silverhand/mermaid-isomorphic

Version:

Transform mermaid diagrams in the browser or Node.js

354 lines (305 loc) 8.78 kB
import { type Mermaid, type MermaidConfig } from 'mermaid' import { type BrowserType, chromium, type LaunchOptions, type Page } from 'playwright' declare const mermaid: Mermaid const html = `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <script src="https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.1/css/all.css" /> </head> </html> ` export interface CreateMermaidRendererOptions { /** * The Playwright browser to use. * * @default chromium */ browserType?: BrowserType /** * The options used to launch the browser. */ launchOptions?: LaunchOptions } export interface RenderResult { /** * The aria description of the diagram. */ description?: string /** * The height of the resulting SVG. */ height: number /** * The DOM id of the SVG node. */ id: string /** * The diagram SVG rendered as a PNG buffer. */ screenshot?: Buffer /** * The diagram rendered as an SVG. */ svg: string /** * The title of the rendered diagram. */ title?: string /** * The width of the resulting SVG. */ width: number } export interface RenderOptions { /** * A URL that points to a custom CSS file to load. * * Use this to load custom fonts. * * This option is ignored in the browser. You need to include the CSS in your build manually. */ css?: Iterable<URL | string> | URL | string | undefined /** * If true, a PNG screenshot of the diagram will be added. * * This is only supported in the Node.js. */ screenshot?: boolean /** * The mermaid configuration. * * By default `fontFamily` is set to `arial,sans-serif`. * * This option is ignored in the browser. You need to call `mermaid.initialize()` manually. */ mermaidConfig?: MermaidConfig /** * The prefix of the id. * * @default 'mermaid' */ prefix?: string | undefined } /** * Render Mermaid diagrams in the browser. * * @param diagrams * The Mermaid diagrams to render. * @param options * Additional options to use when rendering the diagrams. * @returns * A list of settled promises that contains the rendered Mermaid diagram. Each result matches the * same index of the input diagrams. */ export type MermaidRenderer = ( diagrams: string[], options?: RenderOptions ) => Promise<PromiseSettledResult<RenderResult>[]> interface RenderDiagramsOptions extends Required<Pick<RenderOptions, 'mermaidConfig' | 'prefix' | 'screenshot'>> { /** * The diagrams to process. */ diagrams: string[] } /* c8 ignore start */ /** * Render mermaid diagrams in the browser. * * @param options * The options used to render the diagrams * @returns * A settled promise that holds the rendering results. */ async function renderDiagrams({ diagrams, mermaidConfig, prefix, screenshot }: RenderDiagramsOptions): Promise<PromiseSettledResult<RenderResult>[]> { await Promise.all(Array.from(document.fonts, (font) => font.load())) const parser = new DOMParser() const serializer = new XMLSerializer() mermaid.initialize(mermaidConfig) /** * Get an aria value form a referencing attribute. * * @param element * The SVG element the get the value from. * @param attribute * The attribute whose value to get. * @returns * The aria value. */ // eslint-disable-next-line unicorn/consistent-function-scoping function getAriaValue(element: SVGSVGElement, attribute: string): string | undefined { const value = element.getAttribute(attribute) if (!value) { return } let result = '' for (const id of value.split(/\s+/)) { const node = element.getElementById(id) if (node) { result += node.textContent } } return result } return Promise.allSettled( diagrams.map(async (diagram, index) => { const id = `${prefix}-${index}` try { const { svg } = await mermaid.render(id, diagram) const root = parser.parseFromString(svg, 'text/html') const [element] = root.getElementsByTagName('svg') const { height, width } = element.viewBox.baseVal const description = getAriaValue(element, 'aria-describedby') const title = getAriaValue(element, 'aria-labelledby') if (screenshot) { document.body.append(element) } const result: RenderResult = { height, id, svg: serializer.serializeToString(element), width } if (description) { result.description = description } if (title) { result.title = title } return result } catch (error) { throw error instanceof Error ? { name: error.name, stack: error.stack, message: error.message } : error } }) ) } /* c8 ignore stop */ interface SimpleContext { /** * Gracefully close the browser context and the browser. */ close: () => Promise<undefined> /** * Open a new page. */ newPage: () => Promise<Page> } /** * The options used to launch the browser. If a string is provided, it will be used as the CDP * connection string for `browserType.connectOverCDP()`; otherwise, it will be passed to * `browserType.launch()`. */ type BrowserLaunchOptions = LaunchOptions | string /** * Launch a browser and a single browser context. * * @param browserType * The browser type to launch. * @param launchOptions * Optional launch options * @returns * A simple browser context wrapper */ async function getBrowser( browserType: BrowserType, launchOptions: BrowserLaunchOptions | undefined ): Promise<SimpleContext> { const browser = await (typeof launchOptions === 'string' ? browserType.connectOverCDP(launchOptions) : browserType.launch(launchOptions)) const context = await browser.newContext({ bypassCSP: true }) return { async close() { await context.close() await browser.close() }, newPage() { return context.newPage() } } } /** * Create a Mermaid renderer. * * The Mermaid renderer manages a browser instance. If multiple diagrams are being rendered * simultaneously, the internal browser instance will be re-used. If no diagrams are being rendered, * the browser will be closed. * * @param options * The options of the Mermaid renderer. * @returns * A function that renders Mermaid diagrams in the browser. */ export function createMermaidRenderer(options: CreateMermaidRendererOptions = {}): MermaidRenderer { const { browserType = chromium, launchOptions } = options let browserPromise: Promise<SimpleContext> | undefined let count = 0 return async (diagrams, renderOptions) => { count += 1 if (!browserPromise) { browserPromise = getBrowser(browserType, launchOptions) } const context = await browserPromise let page: Page | undefined let renderResults: PromiseSettledResult<RenderResult>[] try { page = await context.newPage() await page.goto(`data:text/html,${encodeURIComponent(html)}`) const promises = [] const css = renderOptions?.css if (typeof css === 'string' || css instanceof URL) { promises.push(page.addStyleTag({ url: String(css) })) } else if (css) { for (const url of css) { promises.push(page.addStyleTag({ url: String(url) })) } } await Promise.all(promises) renderResults = await page.evaluate(renderDiagrams, { diagrams, screenshot: Boolean(renderOptions?.screenshot), mermaidConfig: { fontFamily: 'arial,sans-serif', ...renderOptions?.mermaidConfig }, prefix: renderOptions?.prefix ?? 'mermaid' }) if (renderOptions?.screenshot) { for (const result of renderResults) { if (result.status === 'fulfilled') { result.value.screenshot = await page .locator(`#${result.value.id}`) .screenshot({ omitBackground: true }) } } } } finally { await page?.close() count -= 1 if (!count) { browserPromise = undefined context.close() } } for (const result of renderResults) { if (result.status !== 'rejected') { continue } const { reason } = result if (reason && 'name' in reason && 'message' in reason && 'stack' in reason) { Object.setPrototypeOf(reason, Error.prototype) } } return renderResults } }