Commit 1d6f5ca0 authored by charlie ablett's avatar charlie ablett

Merge branch '332095-plantuml' into 'master'

Allow editing plantuml/kroki diagrams in content editor

See merge request gitlab-org/gitlab!77875
parents d821e3bc e45c5f1d
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
export default CodeBlockHighlight.extend({
name: 'diagram',
isolating: true,
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => {
return element.dataset.diagram;
},
},
};
},
parseHTML() {
return [
{
priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: '[data-diagram]',
getContent(element, schema) {
const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', ''));
const node = schema.node('paragraph', {}, [schema.text(source)]);
return node.content;
},
},
];
},
renderHTML({ HTMLAttributes: { language, ...HTMLAttributes } }) {
return [
'div',
[
'pre',
{
language,
class: `content-editor-code-block code highlight`,
...HTMLAttributes,
},
['code', {}, 0],
],
];
},
addCommands() {
return {};
},
addInputRules() {
return [];
},
});
...@@ -15,6 +15,7 @@ import DescriptionItem from '../extensions/description_item'; ...@@ -15,6 +15,7 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list'; import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details'; import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content'; import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
import Division from '../extensions/division'; import Division from '../extensions/division';
import Document from '../extensions/document'; import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor'; import Dropcursor from '../extensions/dropcursor';
...@@ -100,6 +101,7 @@ export const createContentEditor = ({ ...@@ -100,6 +101,7 @@ export const createContentEditor = ({
Details, Details,
DetailsContent, DetailsContent,
Document, Document,
Diagram,
Division, Division,
Dropcursor, Dropcursor,
Emoji, Emoji,
......
...@@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list'; ...@@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details'; import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content'; import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division'; import Division from '../extensions/division';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji'; import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure'; import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption'; import FigureCaption from '../extensions/figure_caption';
...@@ -48,6 +49,7 @@ import Video from '../extensions/video'; ...@@ -48,6 +49,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break'; import WordBreak from '../extensions/word_break';
import { import {
isPlainURL, isPlainURL,
renderCodeBlock,
renderHardBreak, renderHardBreak,
renderTable, renderTable,
renderTableCell, renderTableCell,
...@@ -130,13 +132,8 @@ const defaultSerializerConfig = { ...@@ -130,13 +132,8 @@ const defaultSerializerConfig = {
} }
}, },
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: (state, node) => { [CodeBlockHighlight.name]: renderCodeBlock,
state.write(`\`\`\`${node.attrs.language || ''}\n`); [Diagram.name]: renderCodeBlock,
state.text(node.textContent, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
},
[Division.name]: (state, node) => { [Division.name]: (state, node) => {
if (node.attrs.className?.includes('js-markdown-code')) { if (node.attrs.className?.includes('js-markdown-code')) {
state.renderInline(node); state.renderInline(node);
......
...@@ -341,3 +341,11 @@ export function renderImage(state, node) { ...@@ -341,3 +341,11 @@ export function renderImage(state, node) {
export function renderPlayable(state, node) { export function renderPlayable(state, node) {
renderImage(state, node); renderImage(state, node);
} }
export function renderCodeBlock(state, node) {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
}
...@@ -39,6 +39,9 @@ module Banzai ...@@ -39,6 +39,9 @@ module Banzai
allowlist[:attributes][:all].delete('name') allowlist[:attributes][:all].delete('name')
allowlist[:attributes]['a'].push('name') allowlist[:attributes]['a'].push('name')
allowlist[:attributes]['img'].push('data-diagram')
allowlist[:attributes]['img'].push('data-diagram-src')
# Allow any protocol in `a` elements # Allow any protocol in `a` elements
# and then remove links with unsafe protocols # and then remove links with unsafe protocols
allowlist[:protocols].delete('a') allowlist[:protocols].delete('a')
......
...@@ -27,6 +27,13 @@ module Banzai ...@@ -27,6 +27,13 @@ module Banzai
# make sure the original non-proxied src carries over to the link # make sure the original non-proxied src carries over to the link
link['data-canonical-src'] = img['data-canonical-src'] if img['data-canonical-src'] link['data-canonical-src'] = img['data-canonical-src'] if img['data-canonical-src']
if img['data-diagram'] && img['data-diagram-src']
link['data-diagram'] = img['data-diagram']
link['data-diagram-src'] = img['data-diagram-src']
img.remove_attribute('data-diagram')
img.remove_attribute('data-diagram-src')
end
link.children = if link_replaces_image link.children = if link_replaces_image
img['alt'] || img['data-src'] || img['src'] img['alt'] || img['data-src'] || img['src']
else else
......
...@@ -22,7 +22,14 @@ module Banzai ...@@ -22,7 +22,14 @@ module Banzai
doc.xpath(xpath).each do |node| doc.xpath(xpath).each do |node|
diagram_type = node.parent['lang'] diagram_type = node.parent['lang']
img_tag = Nokogiri::HTML::DocumentFragment.parse(%(<img src="#{create_image_src(diagram_type, diagram_format, node.content)}"/>)) img_tag = Nokogiri::HTML::DocumentFragment.parse(%(<img src="#{create_image_src(diagram_type, diagram_format, node.content)}"/>))
node.parent.replace(img_tag) img_tag = img_tag.children.first
unless img_tag.nil?
img_tag.set_attribute('data-diagram', node.parent['lang'])
img_tag.set_attribute('data-diagram-src', "data:text/plain;base64,#{Base64.strict_encode64(node.content)}")
node.parent.replace(img_tag)
end
end end
doc doc
......
...@@ -15,8 +15,14 @@ module Banzai ...@@ -15,8 +15,14 @@ module Banzai
doc.xpath(lang_tag).each do |node| doc.xpath(lang_tag).each do |node|
img_tag = Nokogiri::HTML::DocumentFragment.parse( img_tag = Nokogiri::HTML::DocumentFragment.parse(
Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {})) Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {})).css('img').first
node.parent.replace(img_tag)
unless img_tag.nil?
img_tag.set_attribute('data-diagram', 'plantuml')
img_tag.set_attribute('data-diagram-src', "data:text/plain;base64,#{Base64.strict_encode64(node.content)}")
node.parent.replace(img_tag)
end
end end
doc doc
......
...@@ -12,6 +12,7 @@ module Banzai ...@@ -12,6 +12,7 @@ module Banzai
def self.filters def self.filters
@filters ||= FilterArray[ @filters ||= FilterArray[
Filter::PlantumlFilter, Filter::PlantumlFilter,
Filter::KrokiFilter,
# Must always be before the SanitizationFilter to prevent XSS attacks # Must always be before the SanitizationFilter to prevent XSS attacks
Filter::SpacedLinkFilter, Filter::SpacedLinkFilter,
Filter::SanitizationFilter, Filter::SanitizationFilter,
...@@ -19,7 +20,6 @@ module Banzai ...@@ -19,7 +20,6 @@ module Banzai
Filter::SyntaxHighlightFilter, Filter::SyntaxHighlightFilter,
Filter::MathFilter, Filter::MathFilter,
Filter::ColorFilter, Filter::ColorFilter,
Filter::KrokiFilter,
Filter::MermaidFilter, Filter::MermaidFilter,
Filter::VideoLinkFilter, Filter::VideoLinkFilter,
Filter::AudioLinkFilter, Filter::AudioLinkFilter,
......
...@@ -377,6 +377,34 @@ ...@@ -377,6 +377,34 @@
</ol> </ol>
</details> </details>
- name: diagram_kroki_nomnoml
markdown: |-
```nomnoml
#stroke: #a86128
[<frame>Decorator pattern|
[<abstract>Component||+ operation()]
[Client] depends --> [Component]
[Decorator|- next: Component]
[Decorator] decorates -- [ConcreteComponent]
[Component] <:- [Decorator]
[Component] <:- [ConcreteComponent]
]
```
html: |-
<a class="no-attachment-icon" href="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA==" target="_blank" rel="noopener noreferrer" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,ICAjc3Ryb2tlOiAjYTg2MTI4CiAgWzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybnwKICAgIFs8YWJzdHJhY3Q+Q29tcG9uZW50fHwrIG9wZXJhdGlvbigpXQogICAgW0NsaWVudF0gZGVwZW5kcyAtLT4gW0NvbXBvbmVudF0KICAgIFtEZWNvcmF0b3J8LSBuZXh0OiBDb21wb25lbnRdCiAgICBbRGVjb3JhdG9yXSBkZWNvcmF0ZXMgLS0gW0NvbmNyZXRlQ29tcG9uZW50XQogICAgW0NvbXBvbmVudF0gPDotIFtEZWNvcmF0b3JdCiAgICBbQ29tcG9uZW50XSA8Oi0gW0NvbmNyZXRlQ29tcG9uZW50XQogIF0K"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="lazy" data-src="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA=="></a>
- name: diagram_plantuml
markdown: |-
```plantuml
Alice -> Bob: Authentication Request
Bob --> Alice: Authentication Response
Alice -> Bob: Another authentication Request
Alice <-- Bob: Another authentication Response
```
html: |-
<a class="no-attachment-icon" href="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00" target="_blank" rel="noopener noreferrer" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,ICBBbGljZSAtPiBCb2I6IEF1dGhlbnRpY2F0aW9uIFJlcXVlc3QKICBCb2IgLS0+IEFsaWNlOiBBdXRoZW50aWNhdGlvbiBSZXNwb25zZQoKICBBbGljZSAtPiBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVxdWVzdAogIEFsaWNlIDwtLSBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVzcG9uc2UK"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="lazy" data-src="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00"></a>
- name: div - name: div
markdown: |- markdown: |-
<div>plain text</div> <div>plain text</div>
......
...@@ -46,6 +46,16 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do ...@@ -46,6 +46,16 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do
expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src'] expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src']
end end
it 'moves the data-diagram* attributes' do
doc = filter(%q(<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">), context)
expect(doc.at_css('a')['data-diagram']).to eq "plantuml"
expect(doc.at_css('a')['data-diagram-src']).to eq "data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw=="
expect(doc.at_css('a img')['data-diagram']).to be_nil
expect(doc.at_css('a img')['data-diagram-src']).to be_nil
end
it 'adds no-attachment icon class to the link' do it 'adds no-attachment icon class to the link' do
doc = filter(image(path), context) doc = filter(image(path), context)
......
...@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do ...@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000") stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>") doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">' expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end end
it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do
...@@ -19,7 +19,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do ...@@ -19,7 +19,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do
plantuml_url: "http://localhost:8080") plantuml_url: "http://localhost:8080")
doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>") doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">' expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end end
it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do
......
...@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do ...@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do
stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080")
input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>' output = '<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">'
doc = filter(input) doc = filter(input)
expect(doc.to_s).to eq output expect(doc.to_s).to eq output
...@@ -29,7 +29,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do ...@@ -29,7 +29,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do
stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid")
input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' output = '<pre lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
doc = filter(input) doc = filter(input)
expect(doc.to_s).to eq output expect(doc.to_s).to eq output
......
...@@ -270,7 +270,7 @@ module MarkdownMatchers ...@@ -270,7 +270,7 @@ module MarkdownMatchers
set_default_markdown_messages set_default_markdown_messages
match do |actual| match do |actual|
expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==') expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjliuUCAE_tHdw=')
end end
end end
end end
......
...@@ -64,6 +64,9 @@ RSpec.shared_context 'API::Markdown Golden Master shared context' do |markdown_y ...@@ -64,6 +64,9 @@ RSpec.shared_context 'API::Markdown Golden Master shared context' do |markdown_y
let(:substitutions) { markdown_example.fetch(:substitutions, {}) } let(:substitutions) { markdown_example.fetch(:substitutions, {}) }
it "verifies conversion of GFM to HTML", :unlimited_max_formatted_output_length do it "verifies conversion of GFM to HTML", :unlimited_max_formatted_output_length do
stub_application_setting(plantuml_enabled: true, plantuml_url: 'http://localhost:8080')
stub_application_setting(kroki_enabled: true, kroki_url: 'http://localhost:8000')
pending pending_reason if pending_reason pending pending_reason if pending_reason
normalized_example_html = normalize_html(example_html, substitutions) normalized_example_html = normalize_html(example_html, substitutions)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment