Skip to content

Commit

Permalink
Support error fallback (#14)
Browse files Browse the repository at this point in the history
If the user provides `errorFallback`, it will be called when mermaid
throws an error. It will receive the faulty code node, the mermaid error
message, and the vfile as arguments, and may return a node to replace
the code. If nothing is returned from the fallback, the code will be
removed instead. To keep the code as-is, simply return the it from the
fallback.

Also if no error fallback is provided, errors are now handled gracefully
using `file.fail()`.

Closes #10
  • Loading branch information
remcohaszing committed Dec 5, 2022
1 parent c7206ed commit cefeaf4
Show file tree
Hide file tree
Showing 19 changed files with 343 additions and 57 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ console.log(value);

### Options

#### `errorFallback`

Create a fallback node if processing of a mermaid diagram fails. If nothing is returned, the code
block is removed. The function receives the following arguments:

- `node`: The mdast `code` node that couldn’t be rendered.
- `error`: The error message that was thrown.
- `file`: The file on which the error occurred.

#### `launchOptions`

These options are passed to
Expand Down
48 changes: 23 additions & 25 deletions browser.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,37 @@
import { fromDom } from 'hast-util-from-dom';
import { type Code, type Parent, type Root } from 'mdast';
import mermaid from 'mermaid';
import { type Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { type RemarkMermaid } from 'remark-mermaidjs';

// eslint-disable-next-line jsdoc/require-jsdoc
function transformer(ast: Root): void {
const instances: [string, number, Parent][] = [];
import { extractCodeBlocks, replaceCodeBlocks } from './shared.js';

visit(ast, { type: 'code', lang: 'mermaid' }, (node: Code, index, parent: Parent) => {
instances.push([node.value, index, parent]);
});
const remarkMermaid: RemarkMermaid = (options) => (ast, file) => {
const instances = extractCodeBlocks(ast);

// Nothing to do. No need to start puppeteer in this case.
// Nothing to do. No need to do further processing.
if (!instances.length) {
return;
}

const results = instances.map(([code], index) =>
// @ts-expect-error The mermaid types are wrong.
mermaid.render(`remark-mermaid-${index}`, code),
);
const results = instances.map(([node], index) => {
try {
return {
success: true,
// @ts-expect-error The mermaid types are wrong.
result: mermaid.render(`remark-mermaid-${index}`, node.value),
};
} catch (error) {
return {
success: false,
result: error instanceof Error ? error.message : String(error),
};
}
});

const wrapper = document.createElement('div');
for (const [i, [, index, parent]] of instances.entries()) {
const value = results[i];
replaceCodeBlocks(instances, results, options, file, (value) => {
wrapper.innerHTML = value;
parent.children.splice(index, 1, {
type: 'paragraph',
children: [{ type: 'html', value }],
data: { hChildren: [fromDom(wrapper.firstChild!)] },
});
}
}

const remarkMermaid: Plugin<[], Root> = () => transformer;
return [value, fromDom(wrapper.firstChild!)];
});
};

export default remarkMermaid;
64 changes: 41 additions & 23 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createRequire } from 'node:module';

import { fromParse5 } from 'hast-util-from-parse5';
import { type Code, type Parent, type Root } from 'mdast';
import { type BlockContent, type Code, type Root } from 'mdast';
import { type MermaidConfig } from 'mermaid';
import { parseFragment } from 'parse5';
import puppeteer, { type Browser, type Page, type PuppeteerLaunchOptions } from 'puppeteer-core';
import { type Config, optimize } from 'svgo';
import { type Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { type VFile } from 'vfile';

import { extractCodeBlocks, replaceCodeBlocks, type Result } from './shared.js';

const mermaidScript = {
path: createRequire(import.meta.url).resolve('mermaid/dist/mermaid.min.js'),
Expand Down Expand Up @@ -42,12 +44,26 @@ export interface RemarkMermaidOptions {
* you use this in a browser, call `mermaid.initialize()` manually.
*/
mermaidOptions?: MermaidConfig;

/**
* Create a fallback node if processing of a mermaid diagram fails.
*
* @param node The mdast `code` node that couldn’t be rendered.
* @param error The error message that was thrown.
* @param file The file on which the error occurred.
* @returns A fallback node to render instead of the invalid diagram. If nothing is returned, the
* code block is removed
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
errorFallback?: (node: Code, error: string, file: VFile) => BlockContent | undefined | void;
}

export type RemarkMermaid = Plugin<[RemarkMermaidOptions?], Root>;

/**
* @param options Options that may be used to tweak the output.
*/
const remarkMermaid: Plugin<[RemarkMermaidOptions?], Root> = (options) => {
const remarkMermaid: RemarkMermaid = (options) => {
if (!options?.launchOptions?.executablePath) {
throw new Error('The option `launchOptions.executablePath` is required when using Node.js');
}
Expand All @@ -57,12 +73,8 @@ const remarkMermaid: Plugin<[RemarkMermaidOptions?], Root> = (options) => {
let browserPromise: Promise<Browser> | undefined;
let count = 0;

return async function transformer(ast) {
const instances: [string, number, Parent][] = [];

visit(ast, { type: 'code', lang: 'mermaid' }, (node: Code, index, parent: Parent) => {
instances.push([node.value, index, parent]);
});
return async function transformer(ast, file) {
const instances = extractCodeBlocks(ast);

// Nothing to do. No need to start puppeteer in this case.
if (!instances.length) {
Expand All @@ -76,7 +88,7 @@ const remarkMermaid: Plugin<[RemarkMermaidOptions?], Root> = (options) => {
});
const browser = await browserPromise;
let page: Page | undefined;
let results: string[];
let results: Result[];
try {
page = await browser.newPage();
await page.goto(String(new URL('index.html', import.meta.url)));
Expand All @@ -90,28 +102,34 @@ const remarkMermaid: Plugin<[RemarkMermaidOptions?], Root> = (options) => {
if (initOptions) {
mermaid.initialize(initOptions);
}
return codes.map((code, index) => mermaid.render(`remark-mermaid-${index}`, code));
return codes.map((code, index) => {
try {
return {
success: true,
result: mermaid.render(`remark-mermaid-${index}`, code),
};
} catch (error) {
return {
success: false,
result: error instanceof Error ? error.message : String(error),
};
}
});
},
/* C8 ignore stop */
instances.map((instance) => instance[0]),
instances.map((instance) => instance[0].value),
mermaidOptions,
);
} finally {
count -= 1;
await page?.close();
}

for (const [i, [, index, parent]] of instances.entries()) {
let value = results[i];
if (svgo !== false) {
value = optimize(value, svgo).data;
}
parent.children.splice(index, 1, {
type: 'paragraph',
children: [{ type: 'html', value }],
data: { hChildren: [fromParse5(parseFragment(value))] },
});
}
replaceCodeBlocks(instances, results, options, file, (value) => {
const processedValue = svgo === false ? value : optimize(value, svgo).data;
return [processedValue, fromParse5(parseFragment(processedValue))];
});

if (!count) {
browserPromise = undefined;
await browser?.close();
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"puppeteer-core": "^19.0.0",
"svgo": "^3.0.0",
"unified": "^10.0.0",
"unist-util-visit": "^4.0.0"
"unist-util-visit": "^4.0.0",
"vfile": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.0.0",
Expand Down
74 changes: 74 additions & 0 deletions shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type Node } from 'hast';
import { type Code, type Parent, type Root } from 'mdast';
import { visit } from 'unist-util-visit';
import { type VFile } from 'vfile';

import { type RemarkMermaidOptions } from './index.js';

type CodeInstance = [Code, number, Parent];

/**
* Extract Mermaid code blocks from the AST.
*
* @param ast The markdown AST to extract code blocks from.
* @returns A list of tuples that represent the code blocks.
*/
export function extractCodeBlocks(ast: Root): CodeInstance[] {
const instances: CodeInstance[] = [];

visit(ast, { type: 'code', lang: 'mermaid' }, (node: Code, index, parent: Parent) => {
instances.push([node, index, parent]);
});

return instances;
}

export interface Result {
/**
* This indicates diagram was rendered succesfully.
*/
success: boolean;

/**
* Either the resulting SVG code or the error message depending on the success status.
*/
result: string;
}

/**
* Replace the code blocks with rendered diagrams.
*
* @param instances The code block instances to replace.
* @param results The diagram rendering results.
* @param options The `remark-mermaidjs` options as given by the user.
* @param file The file to report errors on.
* @param processDiagram Postprocess a diagram.
*/
export function replaceCodeBlocks(
instances: CodeInstance[],
results: Result[],
options: RemarkMermaidOptions | undefined,
file: VFile,
processDiagram: (diagram: string) => [string, Node],
): void {
for (const [i, [node, index, parent]] of instances.entries()) {
const result = results[i];
if (result.success) {
const [value, hChild] = processDiagram(result.result);
parent.children[index] = {
type: 'paragraph',
children: [{ type: 'html', value }],
data: { hChildren: [hChild] },
};
} else if (options?.errorFallback) {
const fallback = options.errorFallback(node, result.result, file);
if (fallback) {
parent.children[index] = fallback;
} else {
parent.children.splice(index, 1);
}
} else {
file.fail(result.result, node, 'remark-mermaidjs:remark-mermaidjs');
}
}
}
9 changes: 9 additions & 0 deletions test/fixtures/error/input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Error

This is an invalid diagram

```mermaid
invalid
```

More content
10 changes: 10 additions & 0 deletions test/fixtures/error/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { RemarkMermaidOptions } from 'remark-mermaidjs';

export const options: RemarkMermaidOptions = {
errorFallback(node, error, vfile) {
return {
type: 'code',
value: `${vfile.basename}\n\n${error}\n\n${JSON.stringify(node, undefined, 2)}`,
};
},
};
9 changes: 9 additions & 0 deletions test/fixtures/errorEmpty/input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Error

This is an invalid diagram

```mermaid
invalid
```

More content
6 changes: 6 additions & 0 deletions test/fixtures/errorEmpty/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { RemarkMermaidOptions } from 'remark-mermaidjs';

export const options: RemarkMermaidOptions = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
errorFallback() {},
};
17 changes: 12 additions & 5 deletions test/runInBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import rehypeStringify from 'rehype-stringify';
import { remark } from 'remark';
import { type RemarkMermaidOptions } from 'remark-mermaidjs';
import remarkRehype from 'remark-rehype';

import remarkMermaid from '../browser.js';
import { options as error } from './fixtures/error/options.js';
import { options as errorEmpty } from './fixtures/errorEmpty/options.js';

const options: Record<string, RemarkMermaidOptions> = { error, errorEmpty };

/**
* Process a fixture using remark and remark-mermaidjs.
Expand All @@ -13,10 +18,12 @@ import remarkMermaid from '../browser.js';
* @returns A tuple of the file processed to both markdown and HTML.
*/
export async function processFixture(name: string): Promise<[string, string]> {
const response = await fetch(`fixtures/${name}/input.md`);
const original = await response.text();
const processor = remark().use(remarkMermaid);
const asMarkdown = processor.processSync(original);
const asHTML = processor().use(remarkRehype).use(rehypeStringify).processSync(original);
const testOptions = name in options ? options[name] : undefined;
const path = `fixtures/${name}/input.md`;
const response = await fetch(path);
const value = await response.text();
const processor = remark().use(remarkMermaid, testOptions);
const asMarkdown = processor.processSync({ path, value });
const asHTML = processor().use(remarkRehype).use(rehypeStringify).processSync({ path, value });
return [asMarkdown.value as string, asHTML.value as string];
}
Loading

0 comments on commit cefeaf4

Please sign in to comment.