diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js new file mode 100644 index 0000000000000000000000000000000000000000..d192b815092cc8624e10e6b41e989640077d61a8 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -0,0 +1,56 @@ +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 []; + }, +}); diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 5b637eee176f673ef82f413db3ed97a94bda960f..7ed62ee17fb8ceb5fe625a7215f18569072858b5 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -15,6 +15,7 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; +import Diagram from '../extensions/diagram'; import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; @@ -100,6 +101,7 @@ export const createContentEditor = ({ Details, DetailsContent, Document, + Diagram, Division, Dropcursor, Emoji, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index eaaf69c3068b2ad1f6c23f8bc173ef8590bae9cd..c2be7bc919533bdf04a165be7ab5d43950384cae 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import Division from '../extensions/division'; +import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; @@ -48,6 +49,7 @@ import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { isPlainURL, + renderCodeBlock, renderHardBreak, renderTable, renderTableCell, @@ -130,13 +132,8 @@ const defaultSerializerConfig = { } }, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, - [CodeBlockHighlight.name]: (state, node) => { - state.write(`\`\`\`${node.attrs.language || ''}\n`); - state.text(node.textContent, false); - state.ensureNewLine(); - state.write('```'); - state.closeBlock(node); - }, + [CodeBlockHighlight.name]: renderCodeBlock, + [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { if (node.attrs.className?.includes('js-markdown-code')) { state.renderInline(node); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 5fdd294aa96d4494e803f9d76109ab4b5f62fae5..3e48434c6f9e9d8464217d2726f708ce92dd2919 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -341,3 +341,11 @@ export function renderImage(state, node) { export function renderPlayable(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); +} diff --git a/lib/banzai/filter/base_sanitization_filter.rb b/lib/banzai/filter/base_sanitization_filter.rb index 4e350a59fa0e52f8e92dfa644aa9fd60015dce28..3b00d1a98245c6c021016f6b00a1311ddcc0ddfe 100644 --- a/lib/banzai/filter/base_sanitization_filter.rb +++ b/lib/banzai/filter/base_sanitization_filter.rb @@ -39,6 +39,9 @@ def allowlist allowlist[:attributes][:all].delete('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 # and then remove links with unsafe protocols allowlist[:protocols].delete('a') diff --git a/lib/banzai/filter/image_link_filter.rb b/lib/banzai/filter/image_link_filter.rb index 44acc7805b4a0aaac7b2f88e2085032d13255109..60881b5f5118821727cec2c1fa32ab00a8af16e0 100644 --- a/lib/banzai/filter/image_link_filter.rb +++ b/lib/banzai/filter/image_link_filter.rb @@ -27,6 +27,13 @@ def call # 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'] + 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 img['alt'] || img['data-src'] || img['src'] else diff --git a/lib/banzai/filter/kroki_filter.rb b/lib/banzai/filter/kroki_filter.rb index 3803302c324c3cbf4bb8bd1b7c6b5630a83c257b..75ccf998f60ce08f0919528525d0d5c9cb66eeb6 100644 --- a/lib/banzai/filter/kroki_filter.rb +++ b/lib/banzai/filter/kroki_filter.rb @@ -22,7 +22,14 @@ def call doc.xpath(xpath).each do |node| diagram_type = node.parent['lang'] 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 doc diff --git a/lib/banzai/filter/plantuml_filter.rb b/lib/banzai/filter/plantuml_filter.rb index 68a99702d6f2eebd25b645aea120f818ad8bbdb9..cbcd547120d271ac5c888af3a8f9da0b277f93b5 100644 --- a/lib/banzai/filter/plantuml_filter.rb +++ b/lib/banzai/filter/plantuml_filter.rb @@ -15,8 +15,14 @@ def call doc.xpath(lang_tag).each do |node| img_tag = Nokogiri::HTML::DocumentFragment.parse( - Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {})) - node.parent.replace(img_tag) + Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {})).css('img').first + + 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 doc diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index df8151b32969e2d13b87d4686e0826d9fe9fa11c..f4964f779ca9613804b6386f4f225bf81638df77 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -12,6 +12,7 @@ class GfmPipeline < BasePipeline def self.filters @filters ||= FilterArray[ Filter::PlantumlFilter, + Filter::KrokiFilter, # Must always be before the SanitizationFilter to prevent XSS attacks Filter::SpacedLinkFilter, Filter::SanitizationFilter, @@ -19,7 +20,6 @@ def self.filters Filter::SyntaxHighlightFilter, Filter::MathFilter, Filter::ColorFilter, - Filter::KrokiFilter, Filter::MermaidFilter, Filter::VideoLinkFilter, Filter::AudioLinkFilter, diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml index 8556811974dc5eb2f92f3a80d314e4b6b47bd085..70a23d09c623da0a8338264e424cbea68dd15a7a 100644 --- a/spec/fixtures/markdown/markdown_golden_master_examples.yml +++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml @@ -377,6 +377,34 @@ </ol> </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 markdown: |- <div>plain text</div> diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb index 238c3cdb9c161c1cb84dbeb21fd19e14ab281da4..6326d894b08785184b795553a96c183caf81658c 100644 --- a/spec/lib/banzai/filter/image_link_filter_spec.rb +++ b/spec/lib/banzai/filter/image_link_filter_spec.rb @@ -46,6 +46,16 @@ def image(path, alt: nil, data_src: nil) expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src'] 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 doc = filter(image(path), context) diff --git a/spec/lib/banzai/filter/kroki_filter_spec.rb b/spec/lib/banzai/filter/kroki_filter_spec.rb index 57caba1d4d74842e1c75554c48854fea82f860ff..d9e48754559fa3eba4b8e5e1274de897fdfa35b9 100644 --- a/spec/lib/banzai/filter/kroki_filter_spec.rb +++ b/spec/lib/banzai/filter/kroki_filter_spec.rb @@ -9,7 +9,7 @@ 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>") - 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 it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do @@ -19,7 +19,7 @@ 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>") - 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 it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index 2d1a01116e0f3eaff418db765a87588770898335..dcfeb2ce3ba1bb0eec73584fdf2fabe9abd1bf44 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -9,7 +9,7 @@ stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") 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) expect(doc.to_s).to eq output @@ -29,7 +29,7 @@ stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") 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 -> Sara : Hello</code></pre>' doc = filter(input) expect(doc.to_s).to eq output diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index f01c4075eebd259bde8779cc3118efed882affb9..1932f78506fa0ba0be1a470e16e8561f090bdbb9 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -270,7 +270,7 @@ def have_image(src) set_default_markdown_messages 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 diff --git a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb index d0915bbf1581dd99ae9fa24e1874139b23d1ee26..dea03af22484b0792a6862e15d0aadc7a123ed6c 100644 --- a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb +++ b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb @@ -64,6 +64,9 @@ let(:substitutions) { markdown_example.fetch(:substitutions, {}) } 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 normalized_example_html = normalize_html(example_html, substitutions)