Feature #44425 » 0002-Render-code-blocks-highlighted-as-mermaid-into-diagr.patch
| .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-->B; |
|
| 92 |
</code></pre> |
|
| 93 |
HTML |
|
| 94 |
expected = <<~HTML |
|
| 95 |
<pre><code data-language="mermaid" data-controller="mermaid"> |
|
| 96 |
graph TD; |
|
| 97 |
A-->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-->B; |
|
| 45 |
</code></pre> |
|
| 46 |
HTML |
|
| 47 |
expected = <<~HTML |
|
| 48 |
<pre><code data-language="mermaid" data-controller="mermaid">graph TD; |
|
| 49 |
A-->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-->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 |
|