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 -&gt; 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)