From b4ee425c80542c1377edff7f38b0de44ab40b8a1 Mon Sep 17 00:00:00 2001
From: Peter Leitzen <pleitzen@gitlab.com>
Date: Mon, 19 Feb 2024 07:25:31 +0000
Subject: [PATCH] Document mermaidlint in documentation guidelines

Run this script in lefthook and static analysis on CI.
---
 doc/development/documentation/testing.md | 13 ++++
 lefthook.yml                             |  5 ++
 scripts/lint/check_mermaid.mjs           | 77 ++++++++++++++++++++++++
 scripts/static-analysis                  |  1 +
 4 files changed, 96 insertions(+)
 create mode 100755 scripts/lint/check_mermaid.mjs

diff --git a/doc/development/documentation/testing.md b/doc/development/documentation/testing.md
index 279793563e74..ce03aef664b4 100644
--- a/doc/development/documentation/testing.md
+++ b/doc/development/documentation/testing.md
@@ -308,6 +308,19 @@ included in backticks. For example:
   - `git clone` is a command, so it must be lowercase, while Git is the product,
     so it must have a capital G.
 
+### Mermaid
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/144328) in GitLab 16.10.
+
+[Mermaid](https://mermaid.js.org/) builds charts and diagrams from code.
+
+The `mermaidlint` job runs on merge requests that contain changes to Markdown files.
+The script (`scripts/lint/check_mermaid.mjs`) returns an error if any Markdown
+files return a Mermaid syntax error.
+
+To help debug your Mermaid charts, use the
+[Mermaid Live Editor](https://mermaid-js.github.io/mermaid-live-editor/edit).
+
 ### Vale
 
 [Vale](https://vale.sh/) is a grammar, style, and word usage linter for the
diff --git a/lefthook.yml b/lefthook.yml
index f5f2597e5bbb..a7147b559902 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -33,6 +33,11 @@ pre-push:
       files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
       glob: '*.{yml,yaml}{,.*}'
       run: scripts/lint-yaml.sh {files}
+    mermaidlint:
+      tags: documentation style,backend style,frontend style
+      files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
+      glob: '{app,lib,ee,spec,doc,scripts}/**/*.md'
+      run: scripts/lint/check_mermaid.mjs {files}
     stylelint:
       tags: stylesheet css style
       files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
diff --git a/scripts/lint/check_mermaid.mjs b/scripts/lint/check_mermaid.mjs
new file mode 100755
index 000000000000..253cf58cc1f1
--- /dev/null
+++ b/scripts/lint/check_mermaid.mjs
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+
+// Lint mermaid code in markdown files.
+// Usage: scripts/lint/check_mermaid.mjs [files ...]
+
+import fs from 'node:fs';
+import glob from 'glob';
+import mermaid from 'mermaid';
+import DOMPurify from 'dompurify';
+import { JSDOM } from 'jsdom';
+
+const jsdom = new JSDOM('...', {
+  pretendToBeVisual: true,
+});
+global.document = jsdom;
+global.window = jsdom.window;
+global.Option = window.Option;
+
+// Workaround to make DOMPurify not fail.
+// See https://github.com/mermaid-js/mermaid/issues/5204
+DOMPurify.addHook = () => {};
+DOMPurify.sanitize = (x) => x;
+
+const defaultGlob = "{app,lib,ee,spec,doc,scripts}/**/*.md";
+const mermaidMatch = /```mermaid(.*?)```/gms;
+
+const argv = process.argv.length > 2 ? process.argv.slice(2) : [defaultGlob];
+const mdFiles = argv.flatMap((arg) => glob.sync(arg))
+
+console.log(`Checking ${mdFiles.length} markdown files...`);
+
+// Mimicking app/assets/javascripts/lib/mermaid.js
+mermaid.initialize({
+  // mermaid core options
+  mermaid: {
+    startOnLoad: false,
+  },
+  // mermaidAPI options
+  theme: 'neutral',
+  flowchart: {
+    useMaxWidth: true,
+    htmlLabels: true,
+  },
+  secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'],
+  securityLevel: 'strict',
+});
+
+let errors = 0;
+
+await Promise.all(
+  mdFiles.map((path) => {
+    const data = fs.readFileSync(path, 'utf8');
+
+    const matched = [...data.matchAll(mermaidMatch)];
+
+    return Promise.all(
+      matched.map((match) => {
+        const matchIndex = match.index;
+        const mermaidText = match[1];
+
+        return mermaid.parse(mermaidText).catch((error) => {
+          const lineNumber = data.slice(0, matchIndex).split('\n').length;
+
+          console.log(`${path}:${lineNumber}: Mermaid syntax error\nError: ${error}\n`);
+          errors += 1;
+        });
+      }),
+    );
+  }),
+);
+
+if (errors > 0) {
+  console.log(`Total errors: ${errors}`);
+  // eslint-disable-next-line no-restricted-syntax
+  console.log(`To fix these errors, see https://docs.gitlab.com/ee/development/documentation/testing.html#mermaid.`);
+  process.exit(1);
+}
diff --git a/scripts/static-analysis b/scripts/static-analysis
index 0c42bd38a3ca..185897b0133f 100755
--- a/scripts/static-analysis
+++ b/scripts/static-analysis
@@ -46,6 +46,7 @@ class StaticAnalysis
     Task.new(%w[bin/rake config_lint], 10),
     Task.new(%w[bin/rake gitlab:sidekiq:all_queues_yml:check], 15),
     (Gitlab.ee? ? Task.new(%w[bin/rake gitlab:sidekiq:sidekiq_queues_yml:check], 11) : nil),
+    Task.new(%w[scripts/lint/check_mermaid.mjs], 10),
     Task.new(%w[yarn run internal:stylelint], 8),
     Task.new(%w[scripts/lint-conflicts.sh], 1),
     Task.new(%w[yarn run block-dependencies], 1),
-- 
GitLab