diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index c5699ae281..f7eff67318 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -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", diff --git a/packages/mermaid/src/docs.mts b/packages/mermaid/src/docs.mts index 33649ce6d9..d7cf5bac8f 100644 --- a/packages/mermaid/src/docs.mts +++ b/packages/mermaid/src/docs.mts @@ -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]; @@ -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 @@ -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 diff --git a/packages/mermaid/src/docs.spec.ts b/packages/mermaid/src/docs.spec.ts index ea5f54d692..50feaee6ad 100644 --- a/packages/mermaid/src/docs.spec.ts +++ b/packages/mermaid/src/docs.spec.ts @@ -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); }); @@ -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' ); @@ -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' + @@ -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', () => { diff --git a/packages/mermaid/src/docs/syntax/flowchart.md b/packages/mermaid/src/docs/syntax/flowchart.md index 9a0e7bc247..4ca3c5466b 100644 --- a/packages/mermaid/src/docs/syntax/flowchart.md +++ b/packages/mermaid/src/docs/syntax/flowchart.md @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53017ba5b0..e2c9758eb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,6 +275,9 @@ importers: remark: specifier: ^14.0.2 version: 14.0.2 + remark-frontmatter: + specifier: ^4.0.1 + version: 4.0.1 remark-gfm: specifier: ^3.0.1 version: 3.0.1 @@ -4106,7 +4109,7 @@ packages: /axios/0.21.4_debug@4.3.2: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2_debug@4.3.2 + follow-redirects: 1.15.2 transitivePeerDependencies: - debug dev: true @@ -6799,6 +6802,12 @@ packages: reusify: 1.0.4 dev: true + /fault/2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + dependencies: + format: 0.2.2 + dev: true + /faye-websocket/0.11.4: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} @@ -6898,7 +6907,7 @@ packages: resolution: {integrity: sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA==} dev: true - /follow-redirects/1.15.2_debug@4.3.2: + /follow-redirects/1.15.2: resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} peerDependencies: @@ -6906,8 +6915,6 @@ packages: peerDependenciesMeta: debug: optional: true - dependencies: - debug: 4.3.2 dev: true /foreground-child/2.0.0: @@ -6949,6 +6956,11 @@ packages: mime-types: 2.1.35 dev: true + /format/0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + dev: true + /forwarded/0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7442,7 +7454,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.2_debug@4.3.2 + follow-redirects: 1.15.2 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -8883,6 +8895,12 @@ packages: - supports-color dev: true + /mdast-util-frontmatter/1.0.0: + resolution: {integrity: sha512-7itKvp0arEVNpCktOET/eLFAYaZ+0cNjVtFtIPxgQ5tV+3i+D4SDDTjTzPWl44LT59PC+xdx+glNTawBdF98Mw==} + dependencies: + micromark-extension-frontmatter: 1.0.0 + dev: true + /mdast-util-gfm-autolink-literal/1.0.2: resolution: {integrity: sha512-FzopkOd4xTTBeGXhXSBU0OCDDh5lUj2rd+HQqG92Ld+jL4lpUfgX2AT2OHAVP9aEeDKp7G92fuooSZcYJA3cRg==} dependencies: @@ -9053,6 +9071,14 @@ packages: uvu: 0.5.6 dev: true + /micromark-extension-frontmatter/1.0.0: + resolution: {integrity: sha512-EXjmRnupoX6yYuUJSQhrQ9ggK0iQtQlpi6xeJzVD5xscyAI+giqco5fdymayZhJMbIFecjnE2yz85S9NzIgQpg==} + dependencies: + fault: 2.0.1 + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + dev: true + /micromark-extension-gfm-autolink-literal/1.0.3: resolution: {integrity: sha512-i3dmvU0htawfWED8aHMMAzAVp/F0Z+0bPh3YrbTPPL1v4YAlCZpy5rBO5p0LPYiZo0zFVkoYh7vDU7yQSiCMjg==} dependencies: @@ -10284,6 +10310,15 @@ packages: jsesc: 0.5.0 dev: true + /remark-frontmatter/4.0.1: + resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} + dependencies: + '@types/mdast': 3.0.10 + mdast-util-frontmatter: 1.0.0 + micromark-extension-frontmatter: 1.0.0 + unified: 10.1.2 + dev: true + /remark-gfm/3.0.1: resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} dependencies: