Patch #44621 ยป 0001-Hide-Mermaid-code-blocks-until-they-are-rendered-to-.patch
| 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 | |