Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for YAML frontmatter in Markdown docs (used for Vitepress config) #3962

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/mermaid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"remark": "^14.0.2",
"remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"start-server-and-test": "^1.14.0",
Expand Down
121 changes: 82 additions & 39 deletions packages/mermaid/src/docs.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,14 @@ import { JSDOM } from 'jsdom';
import type { Code, Root } from 'mdast';
import { posix, dirname, relative, join } from 'path';
import prettier from 'prettier';
import { remark as remarkBuilder } from 'remark';
import { remark } from 'remark';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import chokidar from 'chokidar';
import mm from 'micromatch';
// @ts-ignore No typescript declaration file
import flatmap from 'unist-util-flatmap';

// support tables and other GitHub Flavored Markdown syntax in markdown
const remark = remarkBuilder().use(remarkGfm);

const MERMAID_MAJOR_VERSION = (
JSON.parse(readFileSync('../mermaid/package.json', 'utf8')).version as string
).split('.')[0];
Expand Down Expand Up @@ -201,48 +199,86 @@ const transformIncludeStatements = (file: string, text: string): string => {
});
};

/** Options for {@link transformMarkdownAst} */
interface TransformMarkdownAstOptions {
/**
* Used to indicate the original/source file.
*/
originalFilename: string;
/** If `true`, add a warning that the file is autogenerated */
addAutogeneratedWarning?: boolean;
/**
* If `true`, remove the YAML metadata from the Markdown input.
* Generally, YAML metadata is only used for Vitepress.
*/
removeYAML?: boolean;
}

/**
* Transform code blocks in a Markdown file.
* Use remark.parse() to turn the given content (a String) into an AST.
* Remark plugin that transforms mermaid repo markdown to Vitepress/GFM markdown.
*
* For any AST node that is a code block: transform it as needed:
* - blocks marked as MERMAID_DIAGRAM_ONLY will be set to a 'mermaid' code block so it will be rendered as (only) a diagram
* - blocks marked as MERMAID_EXAMPLE_KEYWORDS will be copied and the original node will be a code only block and the copy with be rendered as the diagram
* - blocks marked as BLOCK_QUOTE_KEYWORDS will be transformed into block quotes
*
* Convert the AST back to a string and return it.
* If `addAutogeneratedWarning` is `true`, generates a header stating that this file is autogenerated.
*
* @param content - the contents of a Markdown file
* @returns the contents with transformed code blocks
* @returns plugin function for Remark
*/
export const transformBlocks = (content: string): string => {
const ast: Root = remark.parse(content);
const astWithTransformedBlocks = flatmap(ast, (node: Code) => {
if (node.type !== 'code' || !node.lang) {
return [node]; // no transformation if this is not a code block
export function transformMarkdownAst({
originalFilename,
addAutogeneratedWarning,
removeYAML,
}: TransformMarkdownAstOptions) {
return (tree: Root, _file?: any): Root => {
const astWithTransformedBlocks = flatmap(tree, (node: Code) => {
if (node.type !== 'code' || !node.lang) {
return [node]; // no transformation if this is not a code block
}

if (node.lang === MERMAID_DIAGRAM_ONLY) {
// Set the lang to 'mermaid' so it will be rendered as a diagram.
node.lang = MERMAID_KEYWORD;
return [node];
} else if (MERMAID_EXAMPLE_KEYWORDS.includes(node.lang)) {
// Return 2 nodes:
// 1. the original node with the language now set to 'mermaid-example' (will be rendered as code), and
// 2. a copy of the original node with the language set to 'mermaid' (will be rendered as a diagram)
node.lang = MERMAID_CODE_ONLY_KEYWORD;
return [node, Object.assign({}, node, { lang: MERMAID_KEYWORD })];
}

// Transform these blocks into block quotes.
if (BLOCK_QUOTE_KEYWORDS.includes(node.lang)) {
return [remark.parse(transformToBlockQuote(node.value, node.lang, node.meta))];
}

return [node]; // default is to do nothing to the node
}) as Root;

if (addAutogeneratedWarning) {
// Add the header to the start of the file
const headerNode = remark.parse(generateHeader(originalFilename)).children[0];
if (astWithTransformedBlocks.children[0].type === 'yaml') {
// insert header after the YAML frontmatter if it exists
astWithTransformedBlocks.children.splice(1, 0, headerNode);
} else {
astWithTransformedBlocks.children.unshift(headerNode);
}
}

if (node.lang === MERMAID_DIAGRAM_ONLY) {
// Set the lang to 'mermaid' so it will be rendered as a diagram.
node.lang = MERMAID_KEYWORD;
return [node];
} else if (MERMAID_EXAMPLE_KEYWORDS.includes(node.lang)) {
// Return 2 nodes:
// 1. the original node with the language now set to 'mermaid-example' (will be rendered as code), and
// 2. a copy of the original node with the language set to 'mermaid' (will be rendered as a diagram)
node.lang = MERMAID_CODE_ONLY_KEYWORD;
return [node, Object.assign({}, node, { lang: MERMAID_KEYWORD })];
if (removeYAML) {
const firstNode = astWithTransformedBlocks.children[0];
if (firstNode.type == 'yaml') {
// YAML is currently only used for Vitepress metadata, so we should remove it for GFM output
astWithTransformedBlocks.children.shift();
}
}

// Transform these blocks into block quotes.
if (BLOCK_QUOTE_KEYWORDS.includes(node.lang)) {
return [remark.parse(transformToBlockQuote(node.value, node.lang, node.meta))];
}

return [node]; // default is to do nothing to the node
});

return remark.stringify(astWithTransformedBlocks);
};
return astWithTransformedBlocks;
};
}

/**
* Transform a markdown file and write the transformed file to the directory for published
Expand All @@ -260,11 +296,18 @@ export const transformBlocks = (content: string): string => {
*/
const transformMarkdown = (file: string) => {
const doc = injectPlaceholders(transformIncludeStatements(file, readSyncedUTF8file(file)));
let transformed = transformBlocks(doc);
if (!noHeader) {
// Add the header to the start of the file
transformed = `${generateHeader(file)}\n${transformed}`;
}

let transformed = remark()
.use(remarkGfm)
.use(remarkFrontmatter, ['yaml']) // support YAML front-matter in Markdown
.use(transformMarkdownAst, {
// mermaid project specific plugin
originalFilename: file,
addAutogeneratedWarning: !noHeader,
removeYAML: !noHeader,
})
.processSync(doc)
.toString();

if (vitepress && file === 'src/docs/index.md') {
// Skip transforming index if vitepress is enabled
Expand Down
82 changes: 49 additions & 33 deletions packages/mermaid/src/docs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,24 @@
import { transformBlocks, transformToBlockQuote } from './docs.mjs';
import { remark as remarkBuilder } from 'remark'; // import it this way so we can mock it
import { vi, afterEach, describe, it, expect } from 'vitest';

const remark = remarkBuilder();

vi.mock('remark', async (importOriginal) => {
const { remark: originalRemarkBuilder } = (await importOriginal()) as {
remark: typeof remarkBuilder;
};
import { transformMarkdownAst, transformToBlockQuote } from './docs.mjs';

// make sure that both `docs.mts` and this test file are using the same remark
// object so that we can mock it
const sharedRemark = originalRemarkBuilder();
return {
remark: () => sharedRemark,
};
});
import { remark } from 'remark'; // import it this way so we can mock it
import remarkFrontmatter from 'remark-frontmatter';
import { vi, afterEach, describe, it, expect } from 'vitest';

afterEach(() => {
vi.restoreAllMocks();
});

const originalFilename = 'example-input-filename.md';
const remarkBuilder = remark().use(remarkFrontmatter, ['yaml']); // support YAML front-matter in Markdown

describe('docs.mts', () => {
describe('transformBlocks', () => {
it('uses remark.parse to create the AST for the file ', () => {
const remarkParseSpy = vi
.spyOn(remark, 'parse')
.mockReturnValue({ type: 'root', children: [] });
const contents = 'Markdown file contents';
transformBlocks(contents);
expect(remarkParseSpy).toHaveBeenCalledWith(contents);
});
describe('transformMarkdownAst', () => {
describe('checks each AST node', () => {
it('does no transformation if there are no code blocks', async () => {
const contents = 'Markdown file contents\n';
const result = transformBlocks(contents);
const result = (
await remarkBuilder().use(transformMarkdownAst, { originalFilename }).process(contents)
).toString();
expect(result).toEqual(contents);
});

Expand All @@ -46,8 +30,12 @@ describe('docs.mts', () => {
const lang_keyword = 'mermaid-nocode';
const contents = beforeCodeLine + '```' + lang_keyword + '\n' + diagram_text + '\n```\n';

it('changes the language to "mermaid"', () => {
const result = transformBlocks(contents);
it('changes the language to "mermaid"', async () => {
const result = (
await remarkBuilder()
.use(transformMarkdownAst, { originalFilename })
.process(contents)
).toString();
expect(result).toEqual(
beforeCodeLine + '\n' + '```' + 'mermaid' + '\n' + diagram_text + '\n```\n'
);
Expand All @@ -61,8 +49,12 @@ describe('docs.mts', () => {
const contents =
beforeCodeLine + '```' + lang_keyword + '\n' + diagram_text + '\n```\n';

it('changes the language to "mermaid-example" and adds a copy of the code block with language = "mermaid"', () => {
const result = transformBlocks(contents);
it('changes the language to "mermaid-example" and adds a copy of the code block with language = "mermaid"', async () => {
const result = (
await remarkBuilder()
.use(transformMarkdownAst, { originalFilename })
.process(contents)
).toString();
expect(result).toEqual(
beforeCodeLine +
'\n' +
Expand All @@ -77,16 +69,40 @@ describe('docs.mts', () => {
});
});

it('calls transformToBlockQuote with the node information', () => {
it('calls transformToBlockQuote with the node information', async () => {
const lang_keyword = 'note';
const contents =
beforeCodeLine + '```' + lang_keyword + '\n' + 'This is the text\n' + '```\n';

const result = transformBlocks(contents);
const result = (
await remarkBuilder().use(transformMarkdownAst, { originalFilename }).process(contents)
).toString();
expect(result).toEqual(beforeCodeLine + '\n> **Note**\n' + '> This is the text\n');
});
});
});

it('should remove YAML if `removeYAML` is true', async () => {
const contents = `---
title: Flowcharts Syntax
---

This Markdown should be kept.
`;
const withYaml = (
await remarkBuilder().use(transformMarkdownAst, { originalFilename }).process(contents)
).toString();
// no change
expect(withYaml).toEqual(contents);

const withoutYaml = (
await remarkBuilder()
.use(transformMarkdownAst, { originalFilename, removeYAML: true })
.process(contents)
).toString();
// no change
expect(withoutYaml).toEqual('This Markdown should be kept.\n');
});
});

describe('transformToBlockQuote', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/mermaid/src/docs/syntax/flowchart.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
title: Flowcharts Syntax
outline: 'deep' # shows all h3 headings in outline in Vitepress
---

# Flowcharts - Basic Syntax

All Flowcharts are composed of **nodes**, the geometric shapes and **edges**, the arrows or lines. The mermaid code defines the way that these **nodes** and **edges** are made and interact.
Expand Down
Loading