diff --git a/Gemfile b/Gemfile index 6bb9fcb8..5649e4cd 100644 --- a/Gemfile +++ b/Gemfile @@ -11,4 +11,8 @@ gem "creole", "~>0.5.0" gem "wikicloth", "=0.8.3" gem "twitter-text", "~> 1.14" gem "asciidoctor", "~> 2.0.26" +# Optional local rendering of AsciiDoc diagram blocks. Rendering a diagram also +# needs the relevant toolchain (a JVM for PlantUML, Graphviz for dot, ...); the +# tests only need the extension classes, so CI does not install one. +gem "asciidoctor-diagram", "~> 3.2", :require => false gem "rexml" diff --git a/Gemfile.lock b/Gemfile.lock index 0d39fc8f..460180dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,9 @@ GEM tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) asciidoctor (2.0.26) + asciidoctor-diagram (3.2.1) + asciidoctor (>= 1.5.7, < 3.x) + rexml base64 (0.3.0) bigdecimal (4.1.2) builder (3.3.0) @@ -139,6 +142,7 @@ DEPENDENCIES RedCloth activesupport (~> 8.1.3) asciidoctor (~> 2.0.26) + asciidoctor-diagram (~> 3.2) commonmarker (~> 2.8.2) creole (~> 0.5.0) github-linguist (>= 7.1.3) diff --git a/README.md b/README.md index 12188dcb..ea329973 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ you wish to run the library. You can also run `script/bootstrap` to fetch them a * [.creole](http://wikicreole.org/) -- `gem install creole` (https://github.com/larsch/creole) * [.mediawiki, .wiki](http://www.mediawiki.org/wiki/Help:Formatting) -- `gem install wikicloth` (https://github.com/nricciar/wikicloth) * [.rst](http://docutils.sourceforge.net/rst.html) -- `pip install docutils` -* [.asciidoc, .adoc, .asc](http://asciidoc.org/) -- `gem install asciidoctor` (http://asciidoctor.org) +* [.asciidoc, .adoc, .asc](http://asciidoc.org/) -- `gem install asciidoctor` (http://asciidoctor.org). + Optionally `gem install asciidoctor-diagram` to render diagram blocks; see + [Diagrams in AsciiDoc](#diagrams-in-asciidoc). * [.pod](http://search.cpan.org/dist/perl/pod/perlpod.pod) -- `Pod::Simple::XHTML` comes with Perl >= 5.10. Lower versions should install Pod::Simple from CPAN. @@ -72,6 +74,79 @@ require 'github/markup' GitHub::Markup.render_s(GitHub::Markups::MARKUP_MARKDOWN, "* One\n* Two") ``` +Diagrams in AsciiDoc +-------------------- + +Diagram blocks in AsciiDoc files -- PlantUML, C4, Mermaid, Graphviz, D2, +Structurizr and friends -- are rendered as images when +[asciidoctor-diagram](https://github.com/asciidoctor/asciidoctor-diagram) is +installed: + +```asciidoc +[plantuml] +---- +@startuml +!include +Person(user, "User") +Container(api, "API", "Ruby") +Rel(user, api, "uses", "HTTPS") +@enduml +---- +``` + +The diagram is rendered locally and inlined as a data URI, so the output stays +self-contained and does not depend on where the generated file landed: + +```html +plantuml diagram +``` + +**A data URI only survives a sanitizer that allows the `data:` protocol on +`img/src`, and the stock html-pipeline config does not** -- it keeps the `` +but drops the `src`, leaving a broken image. If you sanitize the output of this +library (as [the pipeline described above](#github-markup) does), allow the +protocol: + +```ruby +config = HTMLPipeline::SanitizationFilter::DEFAULT_CONFIG +img = config[:protocols]["img"] +config = config.merge( + protocols: config[:protocols].merge( + "img" => img.merge("src" => img["src"] + ["data"]) + ) +) +``` + +Rendering a diagram needs the toolchain for its type -- a JVM for PlantUML (see +`asciidoctor-diagram-plantuml`, which bundles the JAR), Graphviz for `dot`, the +`d2` binary for D2, and so on. Install the gem alongside this one: + +``` +gem install asciidoctor-diagram asciidoctor-diagram-plantuml +``` + +When the gem is not installed, or the toolchain a diagram type needs is missing, +the block falls back to a source block that keeps the diagram language on the +`
`, so a client-side renderer can still pick it up:
+
+```html
+
@startuml ...
+``` + +Nothing is enabled by default: a plain install never depends on a diagram +toolchain, and always produces the tagged source block above. + +Choosing an output format (`svg` or `png`), per block: + +```asciidoc +[plantuml,format=png] +---- +@startuml +Alice -> Bob: hi +@enduml +---- +``` + Local Development ----------------- diff --git a/lib/github/markup/diagrams.rb b/lib/github/markup/diagrams.rb new file mode 100644 index 00000000..31f3e353 --- /dev/null +++ b/lib/github/markup/diagrams.rb @@ -0,0 +1,191 @@ +require "base64" +require "tmpdir" + +module GitHub + module Markup + # Renders AsciiDoc diagram blocks -- PlantUML, C4, Mermaid, Graphviz, ... -- + # as images, using the optional asciidoctor-diagram gem. + # + # When that gem is installed, a diagram block is rendered locally and inlined + # as a data URI. When it is not -- or when the toolchain a diagram type needs + # is missing -- the block falls back to a source block tagged with the diagram + # language (`
`), so a client-side renderer can still pick
+    # it up instead of degrading to an untagged 
.
+    #
+    # Nothing is enabled by default, so a plain install never depends on a diagram
+    # toolchain.
+    module Diagrams
+      # Diagram types recognised as AsciiDoc block styles, mapped to the
+      # asciidoctor-diagram extension that renders them.
+      LOCAL_EXTENSIONS = {
+        "bpmn"        => ["asciidoctor-diagram/bpmn/extension",        "BpmnBlockProcessor"],
+        "bytefield"   => ["asciidoctor-diagram/bytefield/extension",   "BytefieldBlockProcessor"],
+        "d2"          => ["asciidoctor-diagram/d2/extension",          "D2BlockProcessor"],
+        "dbml"        => ["asciidoctor-diagram/dbml/extension",        "DbmlBlockProcessor"],
+        "ditaa"       => ["asciidoctor-diagram/ditaa/extension",       "DitaaBlockProcessor"],
+        "erd"         => ["asciidoctor-diagram/erd/extension",         "ErdBlockProcessor"],
+        "graphviz"    => ["asciidoctor-diagram/graphviz/extension",    "GraphvizBlockProcessor"],
+        "mermaid"     => ["asciidoctor-diagram/mermaid/extension",     "MermaidBlockProcessor"],
+        "nomnoml"     => ["asciidoctor-diagram/nomnoml/extension",     "NomnomlBlockProcessor"],
+        "pikchr"      => ["asciidoctor-diagram/pikchr/extension",      "PikchrBlockProcessor"],
+        "plantuml"    => ["asciidoctor-diagram/plantuml/extension",    "PlantUmlBlockProcessor"],
+        "structurizr" => ["asciidoctor-diagram/structurizr/extension", "StructurizrBlockProcessor"],
+        "wavedrom"    => ["asciidoctor-diagram/wavedrom/extension",    "WavedromBlockProcessor"]
+      }.freeze
+
+      DIAGRAM_TYPES = LOCAL_EXTENSIONS.keys.freeze
+
+      DEFAULT_FORMAT = "svg".freeze
+
+      FORMATS = %w[svg png].freeze
+
+      MIME_TYPES = {"svg" => "image/svg+xml", "png" => "image/png"}.freeze
+
+      # Wraps Asciidoctor.convert, adding the diagram block extensions.
+      #
+      # When rendering is possible the conversion is handed a scratch directory as
+      # its base dir. asciidoctor-diagram writes generated images to disk, and
+      # under safe mode :secure Asciidoctor confines those writes to the base dir;
+      # pointing the base dir at a scratch directory keeps them out of the caller's
+      # working directory. The images are inlined as data URIs and the directory is
+      # thrown away, so rendering stays string-in, string-out.
+      def self.convert(content, options)
+        options = options.merge(:extension_registry => extension_registry)
+        return ::Asciidoctor.convert(content, options) unless local_rendering_installed?
+
+        Dir.mktmpdir("github-markup-diagram") do |dir|
+          ::Asciidoctor.convert(content, options.merge(
+            :base_dir => dir,
+            :attributes => options[:attributes].merge("imagesoutdir" => dir)
+          ))
+        end
+      end
+
+      # Whether the optional asciidoctor-diagram gem is available. Checked against
+      # the gem index rather than by requiring it, so that merely rendering an
+      # AsciiDoc file does not pay for loading it.
+      def self.local_rendering_installed?
+        return @local_rendering_installed if defined?(@local_rendering_installed)
+
+        @local_rendering_installed = begin
+          Gem::Specification.find_by_name("asciidoctor-diagram")
+          true
+        rescue Gem::LoadError
+          false
+        end
+      end
+
+      # An Asciidoctor extension registry holding a block processor for every
+      # supported diagram type.
+      #
+      # The registry is deliberately not registered globally. Asciidoctor's global
+      # extension registry is process-wide state, and this library must not change
+      # how unrelated code converts documents -- which is also why the individual
+      # `asciidoctor-diagram//extension` files are required below rather than
+      # `asciidoctor-diagram` itself, whose top level registers 37 extension groups
+      # globally as a side effect.
+      def self.extension_registry
+        ::Asciidoctor::Extensions.create do
+          DIAGRAM_TYPES.each { |type| block ::GitHub::Markup::Diagrams.block_processor, type }
+        end
+      end
+
+      def self.block_processor
+        @block_processor ||= build_block_processor
+      end
+
+      def self.local_processor_for(diagram_type)
+        return nil unless local_rendering_installed?
+
+        @local_processors ||= {}
+        return @local_processors[diagram_type] if @local_processors.key?(diagram_type)
+
+        @local_processors[diagram_type] = load_local_processor(diagram_type)
+      end
+
+      def self.load_local_processor(diagram_type)
+        path, class_name = LOCAL_EXTENSIONS.fetch(diagram_type)
+        require path
+        ::Asciidoctor::Diagram.const_get(class_name).new(diagram_type.to_sym, {})
+      rescue LoadError
+        nil
+      end
+
+      def self.build_block_processor
+        Class.new(::Asciidoctor::Extensions::BlockProcessor) do
+          use_dsl
+          on_contexts :listing, :literal, :open
+          name_positional_attributes "style", "format"
+
+          def process(parent, reader, attrs)
+            diagram_type = @name.to_s
+            lines = reader.readlines
+
+            image_block(parent, attrs, diagram_type, lines) ||
+              source_block(parent, attrs, diagram_type, lines)
+          end
+
+          private
+
+          # Renders with asciidoctor-diagram and inlines the generated image as a
+          # data URI, so the returned HTML does not depend on where the file
+          # landed. Returns nil when the diagram cannot be rendered -- the gem is
+          # absent, or its toolchain is missing -- leaving the caller to fall back.
+          #
+          # asciidoctor-diagram reports a failed render by handing back a listing
+          # block containing the error rather than by raising, hence the check on
+          # the returned block's context.
+          def image_block(parent, attrs, diagram_type, lines)
+            processor = Diagrams.local_processor_for(diagram_type)
+            return nil unless processor
+
+            format = format_for(attrs)
+            block = processor.process(
+              parent,
+              ::Asciidoctor::Reader.new(lines),
+              # 'data-uri' is passed per block, never as a document attribute:
+              # asciidoctor-diagram reads it to decide to report an absolute path,
+              # which is what we need to find the file, while setting it on the
+              # document would also make Asciidoctor try to inline every ordinary
+              # image:: in the file.
+              attrs.merge("data-uri" => "", "format" => format)
+            )
+            return nil unless block.context == :image
+
+            data_uri = data_uri_for(block.attr("target"), format)
+            return nil unless data_uri
+
+            create_image_block(parent, attrs.merge(
+              "style" => "image",
+              "target" => data_uri,
+              "alt" => attrs.fetch("alt", "#{diagram_type} diagram"),
+              "role" => [attrs["role"], "diagram", diagram_type].compact.join(" ")
+            ))
+          end
+
+          def data_uri_for(path, format)
+            return nil unless File.file?(path.to_s)
+
+            "data:#{Diagrams::MIME_TYPES.fetch(format)};base64," +
+              Base64.strict_encode64(File.binread(path))
+          end
+
+          # The fallback. Emitting the diagram source as a source block keeps the
+          # language in the HTML (`
`) so a client-side
+          # renderer can pick it up, instead of degrading to an untagged 
.
+          def source_block(parent, attrs, diagram_type, lines)
+            create_block(parent, :listing, lines, attrs.merge(
+              "style" => "source",
+              "language" => diagram_type
+            ), :content_model => :verbatim)
+          end
+
+          def format_for(attrs)
+            format = attrs["format"]
+            Diagrams::FORMATS.include?(format) ? format : Diagrams::DEFAULT_FORMAT
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/github/markups.rb b/lib/github/markups.rb
index 4b2f8e0e..9c427b70 100644
--- a/lib/github/markups.rb
+++ b/lib/github/markups.rb
@@ -1,3 +1,4 @@
+require "github/markup/diagrams"
 require "github/markup/markdown"
 require "github/markup/rdoc"
 require "shellwords"
@@ -44,7 +45,7 @@
     attributes['outfilesuffix'] = '.adoc'
   end
   Asciidoctor::Compliance.unique_id_start_index = 1
-  Asciidoctor.convert(content, :safe => :secure, :attributes => attributes)
+  ::GitHub::Markup::Diagrams.convert(content, :safe => :secure, :attributes => attributes)
 end
 
 GitHub::Markup.command(
diff --git a/test/markup_test.rb b/test/markup_test.rb
index baa1dec2..25401d0a 100644
--- a/test/markup_test.rb
+++ b/test/markup_test.rb
@@ -9,6 +9,8 @@
 require 'html_pipeline'
 require 'nokogiri'
 require 'nokogiri/diff'
+require 'base64'
+require 'tmpdir'
 
 def normalize_html(text)
   text.strip
@@ -124,6 +126,252 @@ def test_preserve_markup
     assert_equal content.encoding.name, GitHub::Markup.render('Foo.rst', content).encoding.name
   end
 
+  Diagrams = GitHub::Markup::Diagrams
+
+  PLANTUML_SOURCE = "@startuml\nAlice -> Bob: hi\n@enduml".freeze
+
+  # A stand-in for an asciidoctor-diagram block processor, so the local rendering
+  # path can be exercised without a JVM or any other diagram toolchain.
+  FakeProcessor = Struct.new(:context, :target) do
+    def process(parent, reader, attrs)
+      @seen_attrs = attrs
+      reader.read
+      block = Asciidoctor::Block.new(parent, context, :content_model => :empty)
+      block.set_attr("target", target)
+      block
+    end
+
+    attr_reader :seen_attrs
+  end
+
+  # minitest 6 dropped minitest/mock, so stubbing is done by hand. Swaps a
+  # singleton method for the duration of the block: a callable value is invoked,
+  # anything else is returned as-is.
+  def with_stub(target, name, value)
+    singleton = target.singleton_class
+    own = singleton.instance_methods(false).include?(name) ||
+          singleton.private_instance_methods(false).include?(name)
+    singleton.send(:alias_method, :__stubbed_original, name) if own
+    singleton.send(:define_method, name) do |*args, &blk|
+      value.respond_to?(:call) ? value.call(*args, &blk) : value
+    end
+    yield
+  ensure
+    singleton.send(:remove_method, name)
+    if own
+      singleton.send(:alias_method, name, :__stubbed_original)
+      singleton.send(:remove_method, :__stubbed_original)
+    end
+  end
+
+  def render_adoc(content, options: {})
+    GitHub::Markup.render("README.adoc", content, options: options)
+  end
+
+  def plantuml_block(style: "plantuml", attrs: nil)
+    "[#{[style, attrs].compact.join(",")}]\n----\n#{PLANTUML_SOURCE}\n----\n"
+  end
+
+  # Renders with the local backend swapped out for a fake, which is also what
+  # keeps these tests from depending on a diagram toolchain being installed.
+  def render_with_local(processor, content = plantuml_block, options: {})
+    with_stub(Diagrams, :local_processor_for, processor) { render_adoc(content, options: options) }
+  end
+
+  def img_in(html)
+    Nokogiri::HTML(html).at_css("img")
+  end
+
+  def pre_in(html)
+    Nokogiri::HTML(html).at_css("pre")
+  end
+
+  # --- Local rendering via asciidoctor-diagram ------------------------------
+
+  def test_local_rendering_inlines_the_generated_image_as_a_data_uri
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "hello")
+
+      html = render_with_local(FakeProcessor.new(:image, path))
+      assert_equal "data:image/svg+xml;base64,#{Base64.strict_encode64("hello")}",
+                   img_in(html)["src"]
+    end
+  end
+
+  def test_local_rendering_uses_the_mime_type_of_the_requested_format
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.png")
+      File.binwrite(path, "PNGDATA")
+
+      html = render_with_local(FakeProcessor.new(:image, path), plantuml_block(attrs: "format=png"))
+      assert img_in(html)["src"].start_with?("data:image/png;base64,")
+    end
+  end
+
+  # asciidoctor-diagram reports a failed render by handing back a listing block
+  # rather than by raising, so that is what has to be detected.
+  def test_local_rendering_falls_back_when_the_backend_reports_failure
+    html = render_with_local(FakeProcessor.new(:listing, "/nonexistent.svg"))
+    assert_nil img_in(html)
+    assert_equal "plantuml", pre_in(html)["lang"]
+  end
+
+  def test_local_rendering_falls_back_when_the_generated_file_is_missing
+    html = render_with_local(FakeProcessor.new(:image, "/nonexistent/diagram.svg"))
+    assert_nil img_in(html)
+    assert_equal "plantuml", pre_in(html)["lang"]
+  end
+
+  def test_local_rendering_falls_back_to_the_default_format_for_an_unsupported_one
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "")
+      processor = FakeProcessor.new(:image, path)
+      render_with_local(processor, plantuml_block(attrs: "format=exe"))
+
+      assert_equal "svg", processor.seen_attrs["format"]
+    end
+  end
+
+  def test_local_rendering_uses_an_explicit_alt_and_role_when_given
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "")
+
+      html = render_with_local(FakeProcessor.new(:image, path),
+                               plantuml_block(attrs: 'alt="my diagram",role="custom"'))
+      assert_equal "my diagram", img_in(html)["alt"]
+      assert_includes Nokogiri::HTML(html).at_css("div.imageblock")["class"], "custom"
+    end
+  end
+
+  # A data URI only survives a sanitizer that allows the data: protocol on
+  # img/src, which the stock html-pipeline config does not. Pinned here because
+  # it is the difference between a rendered diagram and a broken image for any
+  # consumer that sanitizes -- see the README.
+  def test_local_rendering_image_requires_data_uris_to_be_allowed_by_the_sanitizer
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "")
+      rendered = render_with_local(FakeProcessor.new(:image, path))
+
+      default = HTMLPipeline::SanitizationFilter::DEFAULT_CONFIG
+      stripped = HTMLPipeline::SanitizationFilter.call(rendered, default).to_s
+      assert_nil img_in(stripped)["src"], "expected the default config to drop the data: URI"
+
+      img_protocols = default[:protocols]["img"]
+      permissive = default.merge(
+        :protocols => default[:protocols].merge(
+          "img" => img_protocols.merge("src" => img_protocols["src"] + ["data"])
+        )
+      )
+      survived = HTMLPipeline::SanitizationFilter.call(rendered, permissive).to_s
+      assert img_in(survived)["src"].start_with?("data:image/svg+xml;base64,"),
+             "expected the data: URI to survive once the protocol is allowed"
+    end
+  end
+
+  def test_local_rendering_asks_the_backend_for_an_absolute_path
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "")
+      processor = FakeProcessor.new(:image, path)
+      render_with_local(processor)
+
+      # Passed per block, never as a document attribute, so that ordinary
+      # image:: macros in the same file are left alone.
+      assert_equal "", processor.seen_attrs["data-uri"]
+      assert_equal "svg", processor.seen_attrs["format"]
+    end
+  end
+
+  def test_ordinary_images_are_not_inlined_by_the_local_rendering_path
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "diagram.svg")
+      File.binwrite(path, "")
+
+      html = render_with_local(FakeProcessor.new(:image, path),
+                              "#{plantuml_block}\nimage::ordinary.png[]\n")
+      assert_equal ["ordinary.png"],
+                   Nokogiri::HTML(html).css("img").map { |i| i["src"] }.reject { |s| s.start_with?("data:") }
+    end
+  end
+
+  def test_local_processor_is_built_for_a_known_diagram_type
+    Diagrams.instance_variable_set(:@local_processors, nil)
+    processor = Diagrams.local_processor_for("plantuml")
+    assert_instance_of Asciidoctor::Diagram::PlantUmlBlockProcessor, processor
+
+    # Memoised, so a second lookup does not pay for the require again.
+    assert_same processor, Diagrams.local_processor_for("plantuml")
+  ensure
+    Diagrams.instance_variable_set(:@local_processors, nil)
+  end
+
+  def test_local_processor_is_nil_when_asciidoctor_diagram_is_absent
+    Diagrams.instance_variable_set(:@local_processors, nil)
+    with_stub(Diagrams, :require, ->(*) { raise LoadError }) do
+      assert_nil Diagrams.local_processor_for("plantuml")
+    end
+  ensure
+    Diagrams.instance_variable_set(:@local_processors, nil)
+  end
+
+  def test_local_rendering_is_skipped_entirely_when_the_gem_is_absent
+    with_stub(Diagrams, :local_rendering_installed?, false) do
+      html = render_adoc(plantuml_block)
+      assert_nil img_in(html)
+      assert_equal "plantuml", pre_in(html)["lang"]
+    end
+  end
+
+  def test_local_rendering_installed_is_detected_from_the_gem_index
+    Diagrams.remove_instance_variable(:@local_rendering_installed) if
+      Diagrams.instance_variable_defined?(:@local_rendering_installed)
+    assert_equal true, Diagrams.local_rendering_installed?
+    assert_equal true, Diagrams.local_rendering_installed?, "should be memoised"
+
+    Diagrams.remove_instance_variable(:@local_rendering_installed)
+    with_stub(Gem::Specification, :find_by_name, ->(*) { raise Gem::LoadError }) do
+      assert_equal false, Diagrams.local_rendering_installed?
+    end
+  ensure
+    Diagrams.remove_instance_variable(:@local_rendering_installed) if
+      Diagrams.instance_variable_defined?(:@local_rendering_installed)
+  end
+
+  # --- Fallback and isolation ----------------------------------------------
+
+  # With no backend at all, the diagram language is still carried on the 
 so
+  # a client-side renderer can pick it up.
+  def test_diagram_falls_back_to_a_tagged_source_block
+    html = render_with_local(nil)
+    assert_nil img_in(html)
+    assert_equal "plantuml", pre_in(html)["lang"]
+    assert_equal PLANTUML_SOURCE, pre_in(html).text.strip
+  end
+
+  def test_diagram_leaves_ordinary_source_blocks_alone
+    html = render_adoc("[source,mermaid]\n----\ngraph TD; A-->B;\n----\n")
+    assert_nil img_in(html)
+    assert_equal "mermaid", pre_in(html)["lang"]
+  end
+
+  def test_diagrams_do_not_register_extensions_globally
+    render_with_local(nil)
+    assert_empty Asciidoctor::Extensions.groups,
+                 "diagram blocks must not leak into Asciidoctor's global registry"
+  end
+
+  def test_every_diagram_type_maps_to_a_real_asciidoctor_diagram_processor
+    Diagrams::LOCAL_EXTENSIONS.each do |type, (path, class_name)|
+      require path
+      assert Asciidoctor::Diagram.const_defined?(class_name),
+             "#{type} maps to missing Asciidoctor::Diagram::#{class_name}"
+    end
+  end
+
   def test_commonmarker_options
     assert_equal "

hello world

\n", GitHub::Markup.render("test.md", "hello world") assert_equal "

hello world

\n", GitHub::Markup.render("test.md", "hello world", options: {commonmarker_opts: [:UNSAFE]})