Project

General

Profile

Feature #44425 » 0002-Render-code-blocks-highlighted-as-mermaid-into-diagr.patch

Go MAEDA, 2026-09-30 08:00

View differences:

.github/workflows/tests.yml
86 86
      - name: Display Chrome version
87 87
        run: google-chrome --version
88
      - name: Install mermaid.js for the mermaid rendering tests
89
        run: bin/rails redmine:mermaid:install
90

  
88 91
      - name: Run system tests
89 92
        run: bin/rails test:system
90 93
        env:
app/helpers/application_helper.rb
1830 1830
      'tribute-5.1.3.min'
1831 1831
    )
1832 1832
    tags << javascript_include_tag('application-legacy', 'responsive')
1833
    # mermaid.js is installed by the redmine:mermaid:install task and is
1834
    # several MB, so it is not part of every page load. When it is available,
1835
    # its URL is exposed here and the mermaid Stimulus controller loads it
1836
    # only when a ```mermaid code block is actually present on the page.
1837
    if Redmine::Mermaid.available?
1838
      tags <<
1839
        javascript_tag(
1840
          "window.MermaidAssetUrl = #{asset_path(Redmine::Mermaid::FILENAME).to_json};"
1841
        )
1842
    end
1833 1843
    unless User.current.pref.warn_on_leaving_unsaved == '0'
1834 1844
      warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
1835 1845
      tags <<
app/javascript/controllers/mermaid_controller.js
1
/**
2
 * Redmine - project management software
3
 * Copyright (C) 2006-  Jean-Philippe Lang
4
 * This code is released under the GNU General Public License.
5
 */
6
import { Controller } from "@hotwired/stimulus"
7

  
8
let mermaidInitialized = false;
9
let mermaidLoadPromise = null;
10

  
11
// mermaid.js is not bundled with Redmine; administrators install it with
12
// `bin/rails redmine:mermaid:install`. When it is available, javascript_heads
13
// exposes its URL as window.MermaidAssetUrl. It is several MB, so it is only
14
// fetched (as a classic <script>) once a ```mermaid code block actually needs
15
// rendering.
16
function loadMermaid() {
17
  if (typeof mermaid !== 'undefined') return Promise.resolve(mermaid);
18
  if (mermaidLoadPromise) return mermaidLoadPromise;
19

  
20
  const loading = new Promise((resolve, reject) => {
21
    const script = document.createElement('script');
22
    script.src = window.MermaidAssetUrl;
23
    script.onload = () => {
24
      if (window.mermaid) {
25
        resolve(window.mermaid);
26
      } else {
27
        script.remove();
28
        reject(new Error('mermaid.js loaded but window.mermaid is not defined'));
29
      }
30
    };
31
    script.onerror = () => {
32
      script.remove();
33
      reject(new Error('Failed to load mermaid.js'));
34
    };
35
    document.head.appendChild(script);
36
  });
37
  // Keeping a rejected promise around would stop every later connect() in the
38
  // same page -- another block, or a repeated preview -- from trying again.
39
  loading.catch(() => {
40
    if (mermaidLoadPromise === loading) mermaidLoadPromise = null;
41
  });
42
  mermaidLoadPromise = loading;
43
  return loading;
44
}
45

  
46
// Connects to data-controller="mermaid"
47
// Renders ```mermaid code blocks (marked with this controller by
48
// Redmine::WikiFormatting::SyntaxHighlight#process) as diagrams.
49
export default class extends Controller {
50
  connect() {
51
    // mermaid.js is not installed: leave the code block as it is.
52
    if (!window.MermaidAssetUrl && typeof mermaid === 'undefined') return;
53

  
54
    loadMermaid().then((mermaid) => {
55
      // The block may have left the DOM while mermaid.js was loading.
56
      if (this.element.isConnected) this.render(mermaid);
57
    }).catch((error) => {
58
      console.error('Failed to load mermaid.js:', error);
59
    });
60
  }
61

  
62
  disconnect() {
63
    // Undo everything render() added so that a reconnect starts clean.
64
    this.container?.remove();
65
    this.errorElement?.remove();
66
    this.container = this.errorElement = null;
67
    const pre = this.element.closest('pre');
68
    if (pre) pre.style.display = '';
69
  }
70

  
71
  render(mermaid) {
72
    const pre = this.element.closest('pre');
73
    if (!pre) return;
74

  
75
    if (!mermaidInitialized) {
76
      mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
77
      mermaidInitialized = true;
78
    }
79

  
80
    const container = document.createElement('div');
81
    container.className = 'mermaid';
82
    container.textContent = this.element.textContent.trim();
83
    pre.insertAdjacentElement('afterend', container);
84
    this.container = container;
85

  
86
    // mermaid.js draws its own error diagram on failure; replace it with a
87
    // Redmine flash message while keeping the source block visible, so that
88
    // the invalid diagram can be inspected and copied.
89
    mermaid.run({ nodes: [container], suppressErrors: false }).then(() => {
90
      pre.style.display = 'none';
91
    }).catch((error) => {
92
      container.remove();
93
      if (this.container === container) this.container = null;
94

  
95
      const errorElement = document.createElement('div');
96
      errorElement.className = 'flash error';
97
      errorElement.innerHTML = '<p>Failed to render mermaid diagram:</p>';
98

  
99
      const message = document.createElement('p');
100
      message.textContent = error?.message || String(error);
101
      errorElement.appendChild(message);
102

  
103
      // CopypreScrubber wraps the <pre> in div.pre-wrapper; put the message above the whole block.
104
      const anchor = pre.closest('.pre-wrapper') || pre;
105
      anchor.insertAdjacentElement('beforebegin', errorElement);
106
      this.errorElement = errorElement;
107
    });
108
  }
109
}
lib/redmine/wiki_formatting/syntax_highlight.rb
34 34
          # unsupported language, remove the class attribute
35 35
          node.remove_attribute('class')
36 36
        end
37

  
38
        # Rouge has no mermaid lexer, so the branch above leaves the block as plain text.
39
        # This attribute is what the mermaid Stimulus controller looks for to replace the block
40
        # with a rendered diagram. Only block-level code is rendered; an inline <code class="mermaid">
41
        # written in Textile is left as is.
42
        # The attribute is added even when mermaid.js is not installed: formatted text may be cached
43
        # (see Setting.cache_formatted_text), and the controller does nothing unless mermaid.js is available.
44
        if lang.to_s.downcase == 'mermaid' && node.parent&.name == 'pre'
45
          node['data-controller'] = 'mermaid'
46
        end
37 47
      end
38 48
    end
39 49
  end
test/helpers/application_helper_test.rb
2215 2215
    assert_equal 'Bar - Foo - Redmine', html_title
2216 2216
  end
2217
  def test_javascript_heads_should_not_expose_mermaid_asset_url_when_mermaid_is_not_available
2218
    Redmine::Mermaid.stubs(:available?).returns(false)
2219
    assert_not_include 'MermaidAssetUrl', javascript_heads
2220
  end
2221

  
2217 2222
  def test_favicon_path
2218 2223
    assert_match %r{^/assets/favicon-\w+\.ico}, favicon_path
2219 2224
  end
test/system/mermaid_rendering_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../application_system_test_case'
21

  
22
class MermaidRenderingTest < ApplicationSystemTestCase
23
  fixtures :projects, :trackers, :issue_statuses, :issues,
24
           :enumerations, :users, :issue_categories,
25
           :projects_trackers, :roles, :member_roles, :members,
26
           :enabled_modules, :workflows, :journals, :journal_details
27

  
28
  def test_should_not_load_mermaid_js_on_pages_without_mermaid_blocks
29
    skip unless mermaid_installed?
30

  
31
    issue = Issue.find(1)
32
    issue.update_column(:description, "Unable to print recipes")
33

  
34
    log_user('jsmith', 'jsmith')
35
    visit "/issues/#{issue.id}"
36

  
37
    # mermaid.js is several MB; it must stay unloaded unless a ```mermaid
38
    # block is actually present, even though its URL is always exposed.
39
    assert_equal 'undefined', page.evaluate_script('typeof mermaid')
40
    assert page.evaluate_script('!!window.MermaidAssetUrl')
41
  end
42

  
43
  def test_should_render_mermaid_code_block_as_diagram
44
    skip unless mermaid_installed?
45

  
46
    issue = Issue.find(1)
47
    issue.update_column(:description, "```mermaid\ngraph TD;\nA-->B;\n```")
48

  
49
    log_user('jsmith', 'jsmith')
50
    visit "/issues/#{issue.id}"
51

  
52
    within('div.description') do
53
      assert_selector 'code[data-controller=mermaid]', visible: :all
54
      assert_selector 'div.mermaid svg'
55
      assert_no_selector 'svg .error-icon'
56
    end
57
  end
58

  
59
  def test_should_show_flash_message_for_invalid_mermaid_diagram
60
    skip unless mermaid_installed?
61

  
62
    issue = Issue.find(1)
63
    issue.update_column(:description, "```mermaid\nthis is not a valid mermaid diagram(((\n```")
64

  
65
    log_user('jsmith', 'jsmith')
66
    visit "/issues/#{issue.id}"
67

  
68
    within('div.description') do
69
      assert_selector '.flash.error', text: 'Failed to render mermaid diagram'
70
      assert_selector 'pre'
71
      assert_no_selector 'div.mermaid svg'
72
    end
73
  end
74

  
75
  def test_should_render_mermaid_in_description_preview
76
    skip unless mermaid_installed?
77

  
78
    log_user('jsmith', 'jsmith')
79
    visit '/projects/ecookbook/issues/new'
80
    within('form#issue-form') do
81
      fill_in 'Subject', :with => 'mermaid preview test'
82
      fill_in 'Description', :with => "```mermaid\ngraph TD;\nA-->B;\n```"
83
      click_link 'Preview'
84

  
85
      within('div.wiki-preview') do
86
        assert_selector 'div.mermaid svg'
87
      end
88
    end
89
  end
90

  
91
  def test_should_not_execute_script_from_malicious_mermaid_syntax
92
    skip unless mermaid_installed?
93

  
94
    issue = Issue.find(1)
95
    issue.update_column(
96
      :description,
97
      "```mermaid\ngraph TD;\nA[\"<img src=x onerror=alert(1)>\"]-->B;\nclick A call alert(2)\n```"
98
    )
99

  
100
    log_user('jsmith', 'jsmith')
101
    visit "/issues/#{issue.id}"
102
    assert_selector 'div.mermaid svg'
103

  
104
    # Any inline event handler that slipped through would fire on render;
105
    # if it did, alert() would block the driver instead of reaching here.
106
    assert_nil page.evaluate_script("document.querySelector('div.mermaid img')?.getAttribute('onerror')")
107
    assert_not page.evaluate_script("!!document.querySelector('div.mermaid script')")
108

  
109
    page.execute_script('window.__alertCalled = 0; window.alert = function(){ window.__alertCalled++; };')
110
    find('div.mermaid svg .node', match: :first).click
111
    assert_equal 0, page.evaluate_script('window.__alertCalled')
112
  end
113
end
test/unit/lib/redmine/wiki_formatting/common_mark/syntax_highlight_scrubber_test.rb
83 83
      HTML
84 84
      assert_equal input, filter(input)
85 85
    end
86

  
87
    def test_should_add_mermaid_controller_attribute_for_mermaid_language
88
      input = <<~HTML
89
        <pre><code class="language-mermaid">
90
        graph TD;
91
        A--&gt;B;
92
        </code></pre>
93
      HTML
94
      expected = <<~HTML
95
        <pre><code data-language="mermaid" data-controller="mermaid">
96
        graph TD;
97
        A--&gt;B;
98
        </code></pre>
99
      HTML
100
      assert_equal expected, filter(input)
101
    end
102

  
103
    def test_should_add_mermaid_controller_attribute_regardless_of_language_case
104
      input = <<~HTML
105
        <pre><code class="language-Mermaid">
106
        graph TD;
107
        </code></pre>
108
      HTML
109
      expected = <<~HTML
110
        <pre><code data-language="Mermaid" data-controller="mermaid">
111
        graph TD;
112
        </code></pre>
113
      HTML
114
      assert_equal expected, filter(input)
115
    end
116

  
117
    def test_should_not_add_mermaid_controller_attribute_for_other_languages
118
      input = <<~HTML
119
        <pre><code class="language-ruby">
120
        def foo
121
        end
122
        </code></pre>
123
      HTML
124
      assert_no_match(/data-controller/, filter(input))
125
    end
86 126
  end
87 127
end
test/unit/lib/redmine/wiki_formatting/textile/syntax_highlight_scrubber_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../../../test_helper'
21

  
22
class Redmine::WikiFormatting::Textile::SyntaxHighlightScrubberTest < ActiveSupport::TestCase
23
  def filter(html)
24
    fragment = Redmine::WikiFormatting::HtmlParser.parse(html)
25
    scrubber = Redmine::WikiFormatting::Textile::SyntaxHighlightScrubber.new
26
    fragment.scrub!(scrubber)
27
    fragment.to_s
28
  end
29

  
30
  def test_should_highlight_supported_language
31
    input = <<~HTML
32
      <pre><code class="ruby">
33
      def foo
34
      end
35
      </code></pre>
36
    HTML
37
    assert_match(/class="ruby syntaxhl" data-language="ruby"/, filter(input))
38
  end
39

  
40
  def test_should_add_mermaid_controller_attribute_for_mermaid_language
41
    input = <<~HTML
42
      <pre><code class="mermaid">
43
      graph TD;
44
      A--&gt;B;
45
      </code></pre>
46
    HTML
47
    expected = <<~HTML
48
      <pre><code data-language="mermaid" data-controller="mermaid">graph TD;
49
      A--&gt;B;
50
      </code></pre>
51
    HTML
52
    assert_equal expected, filter(input)
53
  end
54

  
55
  def test_should_add_mermaid_controller_attribute_regardless_of_language_case
56
    input = <<~HTML
57
      <pre><code class="Mermaid">
58
      graph TD;
59
      </code></pre>
60
    HTML
61
    assert_match(/data-language="Mermaid" data-controller="mermaid"/, filter(input))
62
  end
63

  
64
  def test_should_not_add_mermaid_controller_attribute_for_inline_code
65
    input = '<p>Inline <code class="mermaid">graph TD; A--&gt;B;</code> in a sentence.</p>'
66
    result = filter(input)
67
    assert_no_match(/data-controller/, result)
68
    assert_match(/data-language="mermaid"/, result)
69
  end
70

  
71
  def test_should_not_add_mermaid_controller_attribute_for_other_languages
72
    input = <<~HTML
73
      <pre><code class="ruby">
74
      def foo
75
      end
76
      </code></pre>
77
    HTML
78
    assert_no_match(/data-controller/, filter(input))
79
  end
80
end
(4-4/5)