• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mermaid-js / mermaid / 4779951918

pending completion
4779951918

push

github

Sidharth Vinod
Fix classParser

1724 of 2135 branches covered (80.75%)

Branch coverage included in aggregate %.

18591 of 33525 relevant lines covered (55.45%)

394.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.05
/packages/mermaid/src/docs.mts
1
/* eslint-disable no-console */
1✔
2

1✔
3
/**
1✔
4
 * @file Transform documentation source files into files suitable for publishing and optionally copy
1✔
5
 *   the transformed files from the source directory to the directory used for the final, published
1✔
6
 *   documentation directory. The list of files transformed and copied to final documentation
1✔
7
 *   directory are logged to the console. If a file in the source directory has the same contents in
1✔
8
 *   the final directory, nothing is done (the final directory is up-to-date).
1✔
9
 * @example
1✔
10
 *   docs
1✔
11
 *   Run with no option flags
1✔
12
 *
1✔
13
 * @example
1✔
14
 *   docs --verify
1✔
15
 *   If the --verify option is used, it only _verifies_ that the final directory has been updated with the transformed files in the source directory.
1✔
16
 *   No files will be copied to the final documentation directory, but the list of files to be changed is shown on the console.
1✔
17
 *   If the final documentation directory does not have the transformed files from source directory
1✔
18
 *   - a message to the console will show that this command should be run without the --verify flag so that the final directory is updated, and
1✔
19
 *   - it will return a fail exit code (1)
1✔
20
 *
1✔
21
 * @example
1✔
22
 *   docs --git
1✔
23
 *   If the --git option is used, the command `git add docs` will be run after all transformations (and/or verifications) have completed successfully
1✔
24
 *   If not files were transformed, the git command is not run.
1✔
25
 *
1✔
26
 * @todo Ensure that the documentation source and final paths are correct by using process.cwd() to
1✔
27
 *   get their absolute paths. Ensures that the location of those 2 directories is not dependent on
1✔
28
 *   where this file resides.
1✔
29
 *
1✔
30
 * @todo Write a test file for this. (Will need to be able to deal .mts file. Jest has trouble with
1✔
31
 *   it.)
1✔
32
 */
1✔
33
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, rmdirSync } from 'fs';
1✔
34
import { exec } from 'child_process';
1✔
35
import { globby } from 'globby';
1✔
36
import { JSDOM } from 'jsdom';
1✔
37
import type { Code, Root } from 'mdast';
1✔
38
import { posix, dirname, relative, join } from 'path';
1✔
39
import prettier from 'prettier';
1✔
40
import { remark } from 'remark';
1✔
41
import remarkFrontmatter from 'remark-frontmatter';
1✔
42
import remarkGfm from 'remark-gfm';
1✔
43
import chokidar from 'chokidar';
1✔
44
import mm from 'micromatch';
1✔
45
// @ts-ignore No typescript declaration file
1✔
46
import flatmap from 'unist-util-flatmap';
1✔
47

1✔
48
const MERMAID_MAJOR_VERSION = (
1✔
49
  JSON.parse(readFileSync('../mermaid/package.json', 'utf8')).version as string
1✔
50
).split('.')[0];
1✔
51
const CDN_URL = 'https://cdn.jsdelivr.net/npm'; // 'https://unpkg.com';
1✔
52

1✔
53
const MERMAID_KEYWORD = 'mermaid';
1✔
54
const MERMAID_CODE_ONLY_KEYWORD = 'mermaid-example';
1✔
55

1✔
56
// These keywords will produce both a mermaid diagram and a code block with the diagram source
1✔
57
const MERMAID_EXAMPLE_KEYWORDS = [MERMAID_KEYWORD, 'mmd', MERMAID_CODE_ONLY_KEYWORD]; // 'mmd' is an old keyword that used to be used
1✔
58

1✔
59
// This keyword will only produce a mermaid diagram
1✔
60
const MERMAID_DIAGRAM_ONLY = 'mermaid-nocode';
1✔
61

1✔
62
// These will be transformed into block quotes
1✔
63
const BLOCK_QUOTE_KEYWORDS = ['note', 'tip', 'warning', 'danger'];
1✔
64

1✔
65
// options for running the main command
1✔
66
const verifyOnly: boolean = process.argv.includes('--verify');
1✔
67
const git: boolean = process.argv.includes('--git');
1✔
68
const watch: boolean = process.argv.includes('--watch');
1✔
69
const vitepress: boolean = process.argv.includes('--vitepress');
1✔
70
const noHeader: boolean = process.argv.includes('--noHeader') || vitepress;
1✔
71

1✔
72
// These paths are from the root of the mono-repo, not from the mermaid subdirectory
1✔
73
const SOURCE_DOCS_DIR = 'src/docs';
1✔
74
const FINAL_DOCS_DIR = vitepress ? 'src/vitepress' : '../../docs';
1!
75

1✔
76
const LOGMSG_TRANSFORMED = 'transformed';
1✔
77
const LOGMSG_TO_BE_TRANSFORMED = 'to be transformed';
1✔
78
const LOGMSG_COPIED = `, and copied to ${FINAL_DOCS_DIR}`;
1✔
79

1✔
80
const WARN_DOCSDIR_DOESNT_MATCH = `Changed files were transformed in ${SOURCE_DOCS_DIR} but do not match the files in ${FINAL_DOCS_DIR}. Please run 'pnpm --filter mermaid run docs:build' after making changes to ${SOURCE_DOCS_DIR} to update the ${FINAL_DOCS_DIR} directory with the transformed files.`;
1✔
81

1✔
82
const prettierConfig = prettier.resolveConfig.sync('.') ?? {};
1!
83
// From https://github.com/vuejs/vitepress/blob/428eec3750d6b5648a77ac52d88128df0554d4d1/src/node/markdownToVue.ts#L20-L21
1✔
84
const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g;
1✔
85
const includedFiles: Set<string> = new Set();
1✔
86

1✔
87
const filesTransformed: Set<string> = new Set();
1✔
88

1✔
89
const generateHeader = (file: string): string => {
1✔
90
  // path from file in docs/* to repo root, e.g ../ or ../../ */
×
91
  const relativePath = relative(file, SOURCE_DOCS_DIR).replaceAll('\\', '/');
×
92
  const filePathFromRoot = posix.join('/packages/mermaid', file);
×
93
  const sourcePathRelativeToGenerated = posix.join(relativePath, filePathFromRoot);
×
94
  return `
×
95
> **Warning**
×
96
> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. 
×
97
> ## Please edit the corresponding file in [${filePathFromRoot}](${sourcePathRelativeToGenerated}).`;
×
98
};
×
99

1✔
100
/**
1✔
101
 * Given a source file name and path, return the documentation destination full path and file name
1✔
102
 * Create the destination path if it does not already exist.
1✔
103
 *
1✔
104
 * @param {string} file - Name of the file (including full path)
1✔
105
 * @returns {string} Name of the file with the path changed from the source directory to final
1✔
106
 *   documentation directory
1✔
107
 * @todo Possible Improvement: combine with lint-staged to only copy files that have changed
1✔
108
 */
1✔
109
const changeToFinalDocDir = (file: string): string => {
1✔
110
  const newDir = file.replace(SOURCE_DOCS_DIR, FINAL_DOCS_DIR);
×
111
  mkdirSync(dirname(newDir), { recursive: true });
×
112
  return newDir;
×
113
};
×
114

1✔
115
/**
1✔
116
 * Log messages to the console showing if the transformed file copied to the final documentation
1✔
117
 * directory or still needs to be copied.
1✔
118
 *
1✔
119
 * @param {string} filename Name of the file that was transformed
1✔
120
 * @param {boolean} wasCopied Whether or not the file was copied
1✔
121
 */
1✔
122
const logWasOrShouldBeTransformed = (filename: string, wasCopied: boolean) => {
1✔
123
  const changeMsg = wasCopied ? LOGMSG_TRANSFORMED : LOGMSG_TO_BE_TRANSFORMED;
×
124
  let logMsg: string;
×
125
  logMsg = `  File ${changeMsg}: ${filename}`;
×
126
  if (wasCopied) {
×
127
    logMsg += LOGMSG_COPIED;
×
128
  }
×
129
  console.log(logMsg);
×
130
};
×
131

1✔
132
/**
1✔
133
 * If the file contents were transformed, set the _filesWereTransformed_ flag to true and copy the
1✔
134
 * transformed contents to the final documentation directory if the doCopy flag is true. Log
1✔
135
 * messages to the console.
1✔
136
 *
1✔
137
 * @param filename Name of the file that will be verified
1✔
138
 * @param doCopy?=false Whether we should copy that transformedContents to the final
1✔
139
 *   documentation directory. Default is `false`
1✔
140
 * @param transformedContent? New contents for the file
1✔
141
 */
1✔
142
const copyTransformedContents = (filename: string, doCopy = false, transformedContent?: string) => {
1✔
143
  const fileInFinalDocDir = changeToFinalDocDir(filename);
×
144
  const existingBuffer = existsSync(fileInFinalDocDir)
×
145
    ? readFileSync(fileInFinalDocDir)
×
146
    : Buffer.from('#NEW FILE#');
×
147
  const newBuffer = transformedContent ? Buffer.from(transformedContent) : readFileSync(filename);
×
148
  if (existingBuffer.equals(newBuffer)) {
×
149
    return; // Files are same, skip.
×
150
  }
×
151

×
152
  filesTransformed.add(fileInFinalDocDir);
×
153
  if (doCopy) {
×
154
    writeFileSync(fileInFinalDocDir, newBuffer);
×
155
  }
×
156
  logWasOrShouldBeTransformed(fileInFinalDocDir, doCopy);
×
157
};
×
158

1✔
159
const readSyncedUTF8file = (filename: string): string => {
1✔
160
  return readFileSync(filename, 'utf8');
×
161
};
×
162

1✔
163
const blockIcons: Record<string, string> = {
1✔
164
  tip: '💡 ',
1✔
165
  danger: '‼️ ',
1✔
166
};
1✔
167

1✔
168
const capitalize = (word: string) => word[0].toUpperCase() + word.slice(1);
1✔
169

1✔
170
export const transformToBlockQuote = (
1✔
171
  content: string,
4✔
172
  type: string,
4✔
173
  customTitle?: string | null
4✔
174
) => {
4✔
175
  if (vitepress) {
4!
176
    const vitepressType = type === 'note' ? 'info' : type;
×
177
    return `::: ${vitepressType} ${customTitle || ''}\n${content}\n:::`;
×
178
  } else {
4✔
179
    const icon = blockIcons[type] || '';
4✔
180
    const title = `${icon}${customTitle || capitalize(type)}`;
4✔
181
    return `> **${title}** \n> ${content.replace(/\n/g, '\n> ')}`;
4✔
182
  }
4✔
183
};
3✔
184

1✔
185
const injectPlaceholders = (text: string): string =>
1✔
186
  text.replace(/<MERMAID_VERSION>/g, MERMAID_MAJOR_VERSION).replace(/<CDN_URL>/g, CDN_URL);
×
187

1✔
188
const transformIncludeStatements = (file: string, text: string): string => {
1✔
189
  // resolve includes - src https://github.com/vuejs/vitepress/blob/428eec3750d6b5648a77ac52d88128df0554d4d1/src/node/markdownToVue.ts#L65-L76
×
190
  return text.replace(includesRE, (m, m1) => {
×
191
    try {
×
192
      const includePath = join(dirname(file), m1).replaceAll('\\', '/');
×
193
      const content = readSyncedUTF8file(includePath);
×
194
      includedFiles.add(changeToFinalDocDir(includePath));
×
195
      return content;
×
196
    } catch (error) {
×
197
      throw new Error(`Failed to resolve include "${m1}" in "${file}": ${error}`);
×
198
    }
×
199
  });
×
200
};
×
201

1✔
202
/** Options for {@link transformMarkdownAst} */
1✔
203
interface TransformMarkdownAstOptions {
1✔
204
  /**
1✔
205
   * Used to indicate the original/source file.
1✔
206
   */
1✔
207
  originalFilename: string;
1✔
208
  /** If `true`, add a warning that the file is autogenerated */
1✔
209
  addAutogeneratedWarning?: boolean;
1✔
210
  /**
1✔
211
   * If `true`, remove the YAML metadata from the Markdown input.
1✔
212
   * Generally, YAML metadata is only used for Vitepress.
1✔
213
   */
1✔
214
  removeYAML?: boolean;
1✔
215
}
1✔
216

1✔
217
/**
1✔
218
 * Remark plugin that transforms mermaid repo markdown to Vitepress/GFM markdown.
1✔
219
 *
1✔
220
 * For any AST node that is a code block: transform it as needed:
1✔
221
 * - blocks marked as MERMAID_DIAGRAM_ONLY will be set to a 'mermaid' code block so it will be rendered as (only) a diagram
1✔
222
 * - 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
1✔
223
 * - blocks marked as BLOCK_QUOTE_KEYWORDS will be transformed into block quotes
1✔
224
 *
1✔
225
 * If `addAutogeneratedWarning` is `true`, generates a header stating that this file is autogenerated.
1✔
226
 *
1✔
227
 * @returns plugin function for Remark
1✔
228
 */
1✔
229
export function transformMarkdownAst({
1✔
230
  originalFilename,
8✔
231
  addAutogeneratedWarning,
8✔
232
  removeYAML,
8✔
233
}: TransformMarkdownAstOptions) {
8✔
234
  return (tree: Root, _file?: any): Root => {
8✔
235
    const astWithTransformedBlocks = flatmap(tree, (node: Code) => {
8✔
236
      if (node.type !== 'code' || !node.lang) {
31✔
237
        return [node]; // no transformation if this is not a code block
26✔
238
      }
26✔
239

5✔
240
      if (node.lang === MERMAID_DIAGRAM_ONLY) {
31✔
241
        // Set the lang to 'mermaid' so it will be rendered as a diagram.
1✔
242
        node.lang = MERMAID_KEYWORD;
1✔
243
        return [node];
1✔
244
      } else if (MERMAID_EXAMPLE_KEYWORDS.includes(node.lang)) {
31✔
245
        // Return 2 nodes:
3✔
246
        //   1. the original node with the language now set to 'mermaid-example' (will be rendered as code), and
3✔
247
        //   2. a copy of the original node with the language set to 'mermaid' (will be rendered as a diagram)
3✔
248
        node.lang = MERMAID_CODE_ONLY_KEYWORD;
3✔
249
        return [node, Object.assign({}, node, { lang: MERMAID_KEYWORD })];
3✔
250
      }
3✔
251

1✔
252
      // Transform these blocks into block quotes.
1✔
253
      if (BLOCK_QUOTE_KEYWORDS.includes(node.lang)) {
1✔
254
        return [remark.parse(transformToBlockQuote(node.value, node.lang, node.meta))];
1✔
255
      }
1!
256

×
257
      return [node]; // default is to do nothing to the node
×
258
    }) as Root;
8✔
259

8✔
260
    if (addAutogeneratedWarning) {
8!
261
      // Add the header to the start of the file
×
262
      const headerNode = remark.parse(generateHeader(originalFilename)).children[0];
×
263
      if (astWithTransformedBlocks.children[0].type === 'yaml') {
×
264
        // insert header after the YAML frontmatter if it exists
×
265
        astWithTransformedBlocks.children.splice(1, 0, headerNode);
×
266
      } else {
×
267
        astWithTransformedBlocks.children.unshift(headerNode);
×
268
      }
×
269
    }
×
270

8✔
271
    if (removeYAML) {
8✔
272
      const firstNode = astWithTransformedBlocks.children[0];
1✔
273
      if (firstNode.type == 'yaml') {
1✔
274
        // YAML is currently only used for Vitepress metadata, so we should remove it for GFM output
1✔
275
        astWithTransformedBlocks.children.shift();
1✔
276
      }
1✔
277
    }
1✔
278

8✔
279
    return astWithTransformedBlocks;
8✔
280
  };
8✔
281
}
8✔
282

1✔
283
/**
1✔
284
 * Transform a markdown file and write the transformed file to the directory for published
1✔
285
 * documentation
1✔
286
 *
1✔
287
 * 1. include any included files (copy and insert the source)
1✔
288
 * 2. Add a `mermaid-example` block before every `mermaid` or `mmd` block On the main documentation site (one
1✔
289
 *    place where the documentation is published), this will show the code for the mermaid diagram
1✔
290
 * 3. Transform blocks to block quotes as needed
1✔
291
 * 4. Add the text that says the file is automatically generated
1✔
292
 * 5. Use prettier to format the file.
1✔
293
 * 6. Verify that the file has been changed and write out the changes
1✔
294
 *
1✔
295
 * @param file {string} name of the file that will be verified
1✔
296
 */
1✔
297
const transformMarkdown = (file: string) => {
1✔
298
  const doc = injectPlaceholders(transformIncludeStatements(file, readSyncedUTF8file(file)));
×
299

×
300
  let transformed = remark()
×
301
    .use(remarkGfm)
×
302
    .use(remarkFrontmatter, ['yaml']) // support YAML front-matter in Markdown
×
303
    .use(transformMarkdownAst, {
×
304
      // mermaid project specific plugin
×
305
      originalFilename: file,
×
306
      addAutogeneratedWarning: !noHeader,
×
307
      removeYAML: !noHeader,
×
308
    })
×
309
    .processSync(doc)
×
310
    .toString();
×
311

×
312
  if (vitepress && file === 'src/docs/index.md') {
×
313
    // Skip transforming index if vitepress is enabled
×
314
    transformed = doc;
×
315
  }
×
316

×
317
  const formatted = prettier.format(transformed, {
×
318
    parser: 'markdown',
×
319
    ...prettierConfig,
×
320
  });
×
321
  copyTransformedContents(file, !verifyOnly, formatted);
×
322
};
×
323

1✔
324
/**
1✔
325
 * Transform an HTML file and write the transformed file to the directory for published
1✔
326
 * documentation
1✔
327
 *
1✔
328
 * - Add the text that says the file is automatically generated Verify that the file has been changed
1✔
329
 *   and write out the changes
1✔
330
 *
1✔
331
 * @param filename {string} name of the HTML file to transform
1✔
332
 */
1✔
333
const transformHtml = (filename: string) => {
1✔
334
  /**
×
335
   * Insert the '...auto generated...' comment into an HTML file after the<html> element
×
336
   *
×
337
   * @param fileName {string} file name that should have the comment inserted
×
338
   * @returns {string} The contents of the file with the comment inserted
×
339
   */
×
340
  const insertAutoGeneratedComment = (fileName: string): string => {
×
341
    const fileContents = injectPlaceholders(readSyncedUTF8file(fileName));
×
342

×
343
    if (noHeader) {
×
344
      return fileContents;
×
345
    }
×
346

×
347
    const jsdom = new JSDOM(fileContents);
×
348
    const htmlDoc = jsdom.window.document;
×
349
    const autoGeneratedComment = jsdom.window.document.createComment(generateHeader(fileName));
×
350

×
351
    const rootElement = htmlDoc.documentElement;
×
352
    rootElement.prepend(autoGeneratedComment);
×
353
    return jsdom.serialize();
×
354
  };
×
355

×
356
  const transformedHTML = insertAutoGeneratedComment(filename);
×
357
  const formattedHTML = prettier.format(transformedHTML, {
×
358
    parser: 'html',
×
359
    ...prettierConfig,
×
360
  });
×
361
  copyTransformedContents(filename, !verifyOnly, formattedHTML);
×
362
};
×
363

1✔
364
const getGlobs = (globs: string[]): string[] => {
1✔
365
  globs.push('!**/dist', '!**/redirect.spec.ts', '!**/landing');
2✔
366
  if (!vitepress) {
2✔
367
    globs.push('!**/.vitepress', '!**/vite.config.ts', '!src/docs/index.md');
2✔
368
  }
2✔
369
  return globs;
2✔
370
};
2✔
371

1✔
372
const getFilesFromGlobs = async (globs: string[]): Promise<string[]> => {
1✔
373
  return await globby(globs, { dot: true });
2✔
374
};
2✔
375

1✔
376
/** Main method (entry point) */
1✔
377
const main = async () => {
1✔
378
  if (verifyOnly) {
1!
379
    console.log('Verifying that all files are in sync with the source files');
×
380
  }
×
381

1✔
382
  const sourceDirGlob = posix.join('.', SOURCE_DOCS_DIR, '**');
1✔
383
  const action = verifyOnly ? 'Verifying' : 'Transforming';
1!
384

1✔
385
  const mdFileGlobs = getGlobs([posix.join(sourceDirGlob, '*.md')]);
1✔
386
  const mdFiles = await getFilesFromGlobs(mdFileGlobs);
1✔
387
  console.log(`${action} ${mdFiles.length} markdown files...`);
1✔
388
  mdFiles.forEach(transformMarkdown);
1✔
389

1✔
390
  for (const includedFile of includedFiles) {
1!
391
    rmSync(includedFile, { force: true });
×
392
    filesTransformed.delete(includedFile);
×
393
    console.log(`Removed ${includedFile} as it was used inside an @include block.`);
×
394
  }
×
395

1✔
396
  const htmlFileGlobs = getGlobs([posix.join(sourceDirGlob, '*.html')]);
1✔
397
  const htmlFiles = await getFilesFromGlobs(htmlFileGlobs);
1!
398
  console.log(`${action} ${htmlFiles.length} html files...`);
×
399
  htmlFiles.forEach(transformHtml);
×
400

×
401
  const otherFileGlobs = getGlobs([sourceDirGlob, '!**/*.md', '!**/*.html']);
×
402
  const otherFiles = await getFilesFromGlobs(otherFileGlobs);
×
403
  console.log(`${action} ${otherFiles.length} other files...`);
×
404
  otherFiles.forEach((file: string) => {
×
405
    copyTransformedContents(file, !verifyOnly); // no transformation
×
406
  });
×
407

×
408
  if (filesTransformed.size > 0) {
×
409
    if (verifyOnly) {
×
410
      console.log(WARN_DOCSDIR_DOESNT_MATCH);
×
411
      process.exit(1);
×
412
    }
×
413
    if (git) {
×
414
      console.log(`Adding changes in ${FINAL_DOCS_DIR} folder to git`);
×
415
      exec(`git add ${FINAL_DOCS_DIR}`);
×
416
    }
×
417
  }
×
418

×
419
  if (watch) {
×
420
    console.log(`Watching for changes in ${SOURCE_DOCS_DIR}`);
×
421

×
422
    const matcher = (globs: string[]) => (file: string) => mm.every(file, globs);
×
423
    const isMd = matcher(mdFileGlobs);
×
424
    const isHtml = matcher(htmlFileGlobs);
×
425
    const isOther = matcher(otherFileGlobs);
×
426

×
427
    chokidar
×
428
      .watch(SOURCE_DOCS_DIR)
×
429
      // Delete files from the final docs dir if they are deleted from the source dir
×
430
      .on('unlink', (file: string) => rmSync(changeToFinalDocDir(file)))
×
431
      .on('unlinkDir', (file: string) => rmdirSync(changeToFinalDocDir(file)))
×
432
      .on('all', (event, path) => {
×
433
        // Ignore other events.
×
434
        if (!['add', 'change'].includes(event)) {
×
435
          return;
×
436
        }
×
437
        if (isMd(path)) {
×
438
          transformMarkdown(path);
×
439
        } else if (isHtml(path)) {
×
440
          transformHtml(path);
×
441
        } else if (isOther(path)) {
×
442
          copyTransformedContents(path, true);
×
443
        }
×
444
      });
×
445
  }
×
446
};
1✔
447

1✔
448
void main();
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc