Project

General

Profile

Patch #44621 ยป 0001-Hide-Mermaid-code-blocks-until-they-are-rendered-to-.patch

Mizuki ISHIKAWA, 2026-10-15 10:48

View differences:

app/assets/stylesheets/application.css
1991 1991
  position: relative;
1992 1992
}
1993 1993

  
1994
/* Hide Mermaid code blocks until they are rendered so that their source does not flash.
1995
   visibility keeps their space so that the content below does not jump twice.
1996
   The mermaid Stimulus controller adds mermaid-failed to show the source again on failure. */
1997
html.mermaid-available pre:has(> code[data-controller="mermaid"]):not(.mermaid-failed) {
1998
  visibility: hidden;
1999
}
2000

  
1994 2001
div.wiki pre {
1995 2002
  margin-block: 1em;
1996 2003
  margin-inline: 1.6em 1em;
app/helpers/application_helper.rb
1834 1834
    # several MB, so it is not part of every page load. When it is available,
1835 1835
    # its URL is exposed here and the mermaid Stimulus controller loads it
1836 1836
    # only when a Mermaid code block is actually present on the page.
1837
    # The mermaid-available class hides Mermaid code blocks from the first
1838
    # paint so that their source does not flash before the diagram replaces
1839
    # it. It is set by JavaScript so that the blocks stay visible without it.
1837 1840
    if Redmine::Mermaid.available?
1838 1841
      tags <<
1839 1842
        javascript_tag(
1840
          "window.MermaidAssetUrl = #{asset_path(Redmine::Mermaid::FILENAME).to_json};"
1843
          "window.MermaidAssetUrl = #{asset_path(Redmine::Mermaid::FILENAME).to_json};" \
1844
          "document.documentElement.classList.add('mermaid-available');"
1841 1845
        )
1842 1846
    end
1843 1847
    unless User.current.pref.warn_on_leaving_unsaved == '0'
app/javascript/controllers/mermaid_controller.js
56 56
      if (this.element.isConnected) this.render(mermaid);
57 57
    }).catch((error) => {
58 58
      console.error('Failed to load Mermaid.js:', error);
59
      // Show the source that the mermaid-available CSS rule hides.
60
      this.element.closest('pre')?.classList.add('mermaid-failed');
59 61
    });
60 62
  }
61 63

  
......
65 67
    this.errorElement?.remove();
66 68
    this.container = this.errorElement = null;
67 69
    const pre = this.element.closest('pre');
68
    if (pre) pre.style.display = '';
70
    if (pre) {
71
      pre.style.display = '';
72
      pre.classList.remove('mermaid-failed');
73
    }
69 74
  }
70 75

  
71 76
  render(mermaid) {
......
80 85
    const container = document.createElement('div');
81 86
    container.className = 'mermaid';
82 87
    container.textContent = this.element.textContent.trim();
88
    // Keep the source text in the container from flashing until Mermaid.js
89
    // replaces it with the diagram. Mermaid.js measures the diagram inside
90
    // the container, so it is made invisible rather than display: none, and
91
    // taken out of the flow so that only the hidden <pre> reserves space.
92
    container.style.visibility = 'hidden';
93
    container.style.position = 'absolute';
94
    container.style.insetInline = '0';
83 95
    pre.insertAdjacentElement('afterend', container);
84 96
    this.container = container;
85 97

  
......
87 99
    // visible, so that the invalid diagram can be inspected and copied.
88 100
    mermaid.run({ nodes: [container], suppressErrors: false }).then(() => {
89 101
      pre.style.display = 'none';
102
      container.style.visibility = container.style.position = container.style.insetInline = '';
90 103
    }).catch((error) => {
91 104
      container.remove();
92 105
      if (this.container === container) this.container = null;
106
      pre.classList.add('mermaid-failed');
93 107

  
94 108
      const errorElement = document.createElement('div');
95 109
      errorElement.className = 'flash error';
test/helpers/application_helper_test.rb
2220 2220
    assert_not_include 'MermaidAssetUrl', javascript_heads
2221 2221
  end
2222 2222

  
2223
  def test_javascript_heads_should_add_mermaid_available_class_when_mermaid_is_available
2224
    Redmine::Mermaid.stubs(:available?).returns(true)
2225
    assert_include "document.documentElement.classList.add('mermaid-available');", javascript_heads
2226
  end
2227

  
2223 2228
  def test_favicon_path
2224 2229
    assert_match %r{^/assets/favicon-\w+\.ico}, favicon_path
2225 2230
  end
test/system/mermaid_rendering_test.rb
88 88
    end
89 89
  end
90 90

  
91
  def test_should_show_mermaid_source_when_mermaid_js_fails_to_load
92
    skip unless mermaid_installed?
93

  
94
    log_user('jsmith', 'jsmith')
95
    visit '/projects/ecookbook/issues/new'
96
    # The source of Mermaid code blocks is hidden until rendered; it must be
97
    # shown again if Mermaid.js cannot be loaded.
98
    page.execute_script("window.MermaidAssetUrl = '/nonexistent-mermaid.js';")
99
    within('form#issue-form') do
100
      fill_in 'Subject', :with => 'mermaid load failure test'
101
      fill_in 'Description', :with => "```mermaid\ngraph TD;\nA-->B;\n```"
102
      click_link 'Preview'
103

  
104
      within('div.wiki-preview') do
105
        assert_selector 'pre.mermaid-failed', text: 'graph TD;'
106
        assert_no_selector 'div.mermaid'
107
      end
108
    end
109
  end
110

  
91 111
  def test_should_not_execute_script_from_malicious_mermaid_syntax
92 112
    skip unless mermaid_installed?
93 113

  
    (1-1/1)