diff --git a/Gemfile b/Gemfile index 5b4d46d6..5b6a046f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,24 @@ -source 'https://rubygems.org' +source "https://rubygems.org" # Specify your gem's dependencies in html-pipeline.gemspec gemspec group :development do - gem 'bundler' - gem 'rake' + gem "bundler" + gem "rake" +end + +group :test do + gem "rinku", "~> 1.7", :require => false + gem "gemoji", "~> 1.0", :require => false + gem "RedCloth", "~> 4.2.9", :require => false + gem "escape_utils", "~> 0.3", :require => false + gem "github-linguist", "~> 2.6.2", :require => false + gem "github-markdown", "~> 0.5", :require => false + + if RUBY_VERSION < "1.9.2" + gem "sanitize", ">= 2", "< 2.0.4", :require => false + else + gem "sanitize", "~> 2.0", :require => false + end end diff --git a/README.md b/README.md index 950b35db..dd651d5c 100644 --- a/README.md +++ b/README.md @@ -98,18 +98,29 @@ filter.call * `TextileFilter` - convert textile to html * `TableOfContentsFilter` - anchor headings with name attributes and generate Table of Contents html unordered list linking headings -## Syntax highlighting +## Dependencies +Filter gem dependencies are not bundled; you must bundle the filter's gem +dependencies. The below list details filters with dependencies. For example, `SyntaxHighlightFilter` uses [github-linguist](https://github.com/github/linguist) -to detect and highlight languages. It isn't included as a dependency by default -because it's a large dependency and -[a hassle to build on heroku](https://github.com/jch/html-pipeline/issues/33). -To use the filter, add the following to your Gemfile: +to detect and highlight languages. To use the `SyntaxHighlightFilter`, +add the following to your Gemfile: ```ruby gem 'github-linguist' ``` +* `AutolinkFilter` - `rinku` +* `EmailReplyFilter` - `escape_utils` +* `EmojiFilter` - `gemoji` +* `MarkdownFilter` - `github-markdown` +* `PlainTextInputFilter` - `escape_utils` +* `SanitizationFilter` - `sanitize` +* `SyntaxHighlightFilter` - `github-linguist` +* `TextileFilter` - `RedCloth` + +_Note:_ See [Gemfile](https://github.com/jch/html-pipeline/blob/master/Gemfile) `:test` block for version requirements. + ## Examples We define different pipelines for different parts of our app. Here are a few diff --git a/html-pipeline.gemspec b/html-pipeline.gemspec index 0fd38289..bf45e3c6 100644 --- a/html-pipeline.gemspec +++ b/html-pipeline.gemspec @@ -15,13 +15,17 @@ Gem::Specification.new do |gem| gem.test_files = gem.files.grep(%r{^test}) gem.require_paths = ["lib"] - gem.add_dependency "gemoji", "~> 1.0" - gem.add_dependency "nokogiri", RUBY_VERSION < "1.9.2" ? [">= 1.4", "< 1.6"] : "~> 1.4" - gem.add_dependency "github-markdown", "~> 0.5" - gem.add_dependency "sanitize", RUBY_VERSION < "1.9.2" ? [">= 2", "< 2.0.4"] : "~> 2.0" - gem.add_dependency "rinku", "~> 1.7" - gem.add_dependency "escape_utils", "~> 0.3" + gem.add_dependency "nokogiri", RUBY_VERSION < "1.9.2" ? [">= 1.4", "< 1.6"] : "~> 1.4" gem.add_development_dependency "activesupport", RUBY_VERSION < "1.9.3" ? [">= 2", "< 4"] : ">= 2" - gem.add_development_dependency "github-linguist", "~> 2.6.2" + + gem.post_install_message = < e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "rinku", e.backtrace +end module HTML class Pipeline diff --git a/lib/html/pipeline/email_reply_filter.rb b/lib/html/pipeline/email_reply_filter.rb index a63a09a2..6f64f658 100644 --- a/lib/html/pipeline/email_reply_filter.rb +++ b/lib/html/pipeline/email_reply_filter.rb @@ -1,3 +1,10 @@ +begin + require "escape_utils" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "escape_utils", e.backtrace +end + module HTML class Pipeline # HTML Filter that converts email reply text into an HTML DocumentFragment. @@ -53,4 +60,4 @@ def call end end end -end \ No newline at end of file +end diff --git a/lib/html/pipeline/emoji_filter.rb b/lib/html/pipeline/emoji_filter.rb index 87fb8828..c2336a86 100644 --- a/lib/html/pipeline/emoji_filter.rb +++ b/lib/html/pipeline/emoji_filter.rb @@ -1,4 +1,9 @@ -require 'emoji' +begin + require "gemoji" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "gemoji", e.backtrace +end module HTML class Pipeline @@ -51,4 +56,4 @@ def asset_root end end end -end \ No newline at end of file +end diff --git a/lib/html/pipeline/filter.rb b/lib/html/pipeline/filter.rb index 0fe5a831..6edcb0df 100644 --- a/lib/html/pipeline/filter.rb +++ b/lib/html/pipeline/filter.rb @@ -27,6 +27,23 @@ class Pipeline # Each filter may define additional options and output values. See the class # docs for more info. class Filter + # Public: Custom Exception raised when a Filter dependency is not installed. + # + # Examples + # + # begin + # require "rinku" + # rescue LoadError => e + # missing = HTML::Pipeline::Filter::MissingDependencyException + # raise missing, missing::MESSAGE % "rinku", e.backtrace + # end + class MissingDependencyException < StandardError + # Public: Format String for MissingDependencyException message. + MESSAGE = "Missing html-pipeline dependency: " + + "Please add `%s` to your Gemfile; " + + "see html-pipeline Gemfile for version." + end + class InvalidDocumentException < StandardError; end def initialize(doc, context = nil, result = nil) diff --git a/lib/html/pipeline/markdown_filter.rb b/lib/html/pipeline/markdown_filter.rb index 6c25494e..db61a7f0 100644 --- a/lib/html/pipeline/markdown_filter.rb +++ b/lib/html/pipeline/markdown_filter.rb @@ -1,4 +1,9 @@ -require 'github/markdown' +begin + require "github/markdown" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "github-markdown", e.backtrace +end module HTML class Pipeline @@ -26,4 +31,4 @@ def call end end end -end \ No newline at end of file +end diff --git a/lib/html/pipeline/plain_text_input_filter.rb b/lib/html/pipeline/plain_text_input_filter.rb index 3e776a75..db0876ef 100644 --- a/lib/html/pipeline/plain_text_input_filter.rb +++ b/lib/html/pipeline/plain_text_input_filter.rb @@ -1,3 +1,10 @@ +begin + require "escape_utils" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "escape_utils", e.backtrace +end + module HTML class Pipeline # Simple filter for plain text input. HTML escapes the text input and wraps it @@ -8,4 +15,4 @@ def call end end end -end \ No newline at end of file +end diff --git a/lib/html/pipeline/sanitization_filter.rb b/lib/html/pipeline/sanitization_filter.rb index b5d65cf7..6ee6dd59 100644 --- a/lib/html/pipeline/sanitization_filter.rb +++ b/lib/html/pipeline/sanitization_filter.rb @@ -1,4 +1,9 @@ -require 'sanitize' +begin + require "sanitize" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "sanitize", e.backtrace +end module HTML class Pipeline diff --git a/lib/html/pipeline/syntax_highlight_filter.rb b/lib/html/pipeline/syntax_highlight_filter.rb index 6660faff..98e1ccd2 100644 --- a/lib/html/pipeline/syntax_highlight_filter.rb +++ b/lib/html/pipeline/syntax_highlight_filter.rb @@ -1,7 +1,8 @@ begin - require 'linguist' -rescue LoadError - raise LoadError, "You need to install 'github-linguist' before using the SyntaxHighlightFilter. See README.md for details" + require "linguist" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "github-linguist", e.backtrace end module HTML @@ -30,4 +31,4 @@ def highlight_with_timeout_handling(lexer, text) end end end -end \ No newline at end of file +end diff --git a/lib/html/pipeline/textile_filter.rb b/lib/html/pipeline/textile_filter.rb index 31619087..b3bcd16b 100644 --- a/lib/html/pipeline/textile_filter.rb +++ b/lib/html/pipeline/textile_filter.rb @@ -1,3 +1,10 @@ +begin + require "redcloth" +rescue LoadError => e + missing = HTML::Pipeline::Filter::MissingDependencyException + raise missing, missing::MESSAGE % "RedCloth", e.backtrace +end + module HTML class Pipeline # HTML Filter that converts Textile text into HTML and converts into a @@ -18,4 +25,4 @@ def call end end end -end \ No newline at end of file +end diff --git a/test/helpers/testing_dependency.rb b/test/helpers/testing_dependency.rb new file mode 100644 index 00000000..234de1e4 --- /dev/null +++ b/test/helpers/testing_dependency.rb @@ -0,0 +1,92 @@ +# Public: Methods useful for testing Filter dependencies. All methods are class +# methods and should be called on the TestingDependency class. +# +# Examples +# +# TestingDependency.temporarily_remove_dependency_by gem_name do +# exception = assert_raise HTML::Pipeline::Filter::MissingDependencyException do +# load TestingDependency.filter_path_from filter_name +# end +# end +class TestingDependency + # Public: Use to safely test a Filter's gem dependency error handling. + # For a certain gem dependency, remove the gem's loaded paths and features. + # Once these are removed, yield to a block which can assert a specific + # exception. Once the block is finished, add back the gem's paths and + # features to the load path and loaded features, so other tests can assert + # Filter functionality. + # + # gem_name - The String of the gem's name. + # block - Required block which asserts gem dependency error handling. + # + # Examples + # + # TestingDependency.temporarily_remove_dependency_by gem_name do + # exception = assert_raise HTML::Pipeline::Filter::MissingDependencyException do + # load TestingDependency.filter_path_from filter_name + # end + # end + # + # Returns nothing. + def self.temporarily_remove_dependency_by(gem_name, &block) + paths = gem_load_paths_from gem_name + features = gem_loaded_features_from gem_name + + $LOAD_PATH.delete_if { |path| paths.include? path } + $LOADED_FEATURES.delete_if { |feature| features.include? feature } + + yield + + $LOAD_PATH.unshift(*paths) + $LOADED_FEATURES.unshift(*features) + end + + # Public: Find a Filter's load path. + # + # gem_name - The String of the gem's name. + # + # Examples + # + # filter_path_from("autolink_filter") + # # => "/Users/simeon/Projects/html-pipeline/test/helpers/../../lib/html/pipeline/autolink_filter.rb" + # + # Returns String of load path. + def self.filter_path_from(filter_name) + File.join(File.dirname(__FILE__), "..", "..", "lib", "html", "pipeline", "#{filter_name}.rb") + end + + private + # Internal: Find a gem's load paths. + # + # gem_name - The String of the gem's name. + # + # Examples + # + # gem_load_paths_from("rinku") + # # => ["/Users/simeon/.rbenv/versions/1.9.3-p429/lib/ruby/gems/1.9.1/gems/rinku-1.7.3/lib"] + # + # Returns Array of load paths. + def self.gem_load_paths_from(gem_name) + $LOAD_PATH.select{ |path| /#{gem_name}/i =~ path } + end + + # Internal: Find a gem's loaded features. + # + # gem_name - The String of the gem's name. + # + # Examples + # + # gem_loaded_features_from("rinku") + # # => ["/Users/simeon/.rbenv/versions/1.9.3-p429/lib/ruby/gems/1.9.1/gems/rinku-1.7.3/lib/rinku.bundle", + # "/Users/simeon/.rbenv/versions/1.9.3-p429/lib/ruby/gems/1.9.1/gems/rinku-1.7.3/lib/rinku.rb"] + # + # Returns Array of loaded features. + def self.gem_loaded_features_from(gem_name) + # gem github-markdown has a feature "github/markdown.rb". + # Replace gem name dashes and underscores with regexp + # range to match all features. + gem_name_regexp = gem_name.split(/[-_]/).join("[\/_-]") + + $LOADED_FEATURES.select{ |feature| /#{gem_name_regexp}/i =~ feature } + end +end diff --git a/test/html/pipeline/autolink_filter_test.rb b/test/html/pipeline/autolink_filter_test.rb index dfda5e56..10c837fb 100644 --- a/test/html/pipeline/autolink_filter_test.rb +++ b/test/html/pipeline/autolink_filter_test.rb @@ -3,6 +3,10 @@ AutolinkFilter = HTML::Pipeline::AutolinkFilter class HTML::Pipeline::AutolinkFilterTest < Test::Unit::TestCase + def test_dependency_management + assert_dependency_management_error "autolink_filter", "rinku" + end + def test_uses_rinku_for_autolinking # just try to parse a complicated piece of HTML # that Rails auto_link cannot handle diff --git a/test/html/pipeline/email_reply_filter_test.rb b/test/html/pipeline/email_reply_filter_test.rb new file mode 100644 index 00000000..f6744e7e --- /dev/null +++ b/test/html/pipeline/email_reply_filter_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HTML::Pipeline::EmailReplyFilterTest < Test::Unit::TestCase + def test_dependency_management + assert_dependency_management_error "email_reply_filter", "escape_utils" + end +end diff --git a/test/html/pipeline/emoji_filter_test.rb b/test/html/pipeline/emoji_filter_test.rb index 20518484..2683bb98 100644 --- a/test/html/pipeline/emoji_filter_test.rb +++ b/test/html/pipeline/emoji_filter_test.rb @@ -1,12 +1,16 @@ -require 'test_helper' +require "test_helper" class HTML::Pipeline::EmojiFilterTest < Test::Unit::TestCase EmojiFilter = HTML::Pipeline::EmojiFilter - + + def test_dependency_management + assert_dependency_management_error "emoji_filter", "gemoji" + end + def test_emojify - filter = EmojiFilter.new("

:shipit:

", {:asset_root => 'https://foo.com'}) + filter = EmojiFilter.new("

:shipit:

", {:asset_root => "https://foo.com"}) doc = filter.call - assert_match "https://foo.com/emoji/shipit.png", doc.search('img').attr('src').value + assert_match "https://foo.com/emoji/shipit.png", doc.search("img").attr("src").value end def test_required_context_validation @@ -15,4 +19,4 @@ def test_required_context_validation } assert_match /:asset_root/, exception.message end -end \ No newline at end of file +end diff --git a/test/html/pipeline/markdown_filter_test.rb b/test/html/pipeline/markdown_filter_test.rb index a0b2f732..dfc2442e 100644 --- a/test/html/pipeline/markdown_filter_test.rb +++ b/test/html/pipeline/markdown_filter_test.rb @@ -18,6 +18,10 @@ def setup "```" end + def test_dependency_management + assert_dependency_management_error "markdown_filter", "github-markdown" + end + def test_fails_when_given_a_documentfragment body = "

heyo

" doc = HTML::Pipeline.parse(body) @@ -27,26 +31,26 @@ def test_fails_when_given_a_documentfragment def test_gfm_enabled_by_default doc = MarkdownFilter.to_document(@haiku, {}) assert doc.kind_of?(HTML::Pipeline::DocumentFragment) - assert_equal 2, doc.search('br').size + assert_equal 2, doc.search("br").size end def test_disabling_gfm doc = MarkdownFilter.to_document(@haiku, :gfm => false) assert doc.kind_of?(HTML::Pipeline::DocumentFragment) - assert_equal 0, doc.search('br').size + assert_equal 0, doc.search("br").size end def test_fenced_code_blocks doc = MarkdownFilter.to_document(@code) assert doc.kind_of?(HTML::Pipeline::DocumentFragment) - assert_equal 1, doc.search('pre').size + assert_equal 1, doc.search("pre").size end def test_fenced_code_blocks_with_language doc = MarkdownFilter.to_document(@code.sub("```", "``` ruby")) assert doc.kind_of?(HTML::Pipeline::DocumentFragment) - assert_equal 1, doc.search('pre').size - assert_equal 'ruby', doc.search('pre').first['lang'] + assert_equal 1, doc.search("pre").size + assert_equal "ruby", doc.search("pre").first["lang"] end end diff --git a/test/html/pipeline/plain_text_input_filter_test.rb b/test/html/pipeline/plain_text_input_filter_test.rb index 22c419f4..8f2daddb 100644 --- a/test/html/pipeline/plain_text_input_filter_test.rb +++ b/test/html/pipeline/plain_text_input_filter_test.rb @@ -3,6 +3,10 @@ class HTML::Pipeline::PlainTextInputFilterTest < Test::Unit::TestCase PlainTextInputFilter = HTML::Pipeline::PlainTextInputFilter + def test_dependency_management + assert_dependency_management_error "plain_text_input_filter", "escape_utils" + end + def test_fails_when_given_a_documentfragment body = "

heyo

" doc = Nokogiri::HTML::DocumentFragment.parse(body) diff --git a/test/html/pipeline/sanitization_filter_test.rb b/test/html/pipeline/sanitization_filter_test.rb index db9c98df..c271ff83 100644 --- a/test/html/pipeline/sanitization_filter_test.rb +++ b/test/html/pipeline/sanitization_filter_test.rb @@ -3,6 +3,10 @@ class HTML::Pipeline::SanitizationFilterTest < Test::Unit::TestCase SanitizationFilter = HTML::Pipeline::SanitizationFilter + def test_dependency_management + assert_dependency_management_error "sanitization_filter", "sanitize" + end + def test_removing_script_tags orig = %(

) html = SanitizationFilter.call(orig).to_s diff --git a/test/html/pipeline/syntax_highlight_filter_test.rb b/test/html/pipeline/syntax_highlight_filter_test.rb new file mode 100644 index 00000000..426837e3 --- /dev/null +++ b/test/html/pipeline/syntax_highlight_filter_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HTML::Pipeline::SyntaxHighlightFilterTest < Test::Unit::TestCase + def test_dependency_management + assert_dependency_management_error "syntax_highlight_filter", "github-linguist" + end +end diff --git a/test/html/pipeline/textile_filter_test.rb b/test/html/pipeline/textile_filter_test.rb new file mode 100644 index 00000000..4d0b802c --- /dev/null +++ b/test/html/pipeline/textile_filter_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HTML::Pipeline::TextileFilterTest < Test::Unit::TestCase + def test_dependency_management + assert_dependency_management_error "textile_filter", "RedCloth" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index bce1cb33..b999d4ad 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,14 +1,15 @@ -require 'bundler/setup' -require 'html/pipeline' -require 'test/unit' +require "bundler/setup" +require "html/pipeline" +require "test/unit" +require "helpers/testing_dependency" -require 'active_support/core_ext/object/try' +require "active_support/core_ext/object/try" module TestHelpers # Asserts that `needle` is not a member of `haystack`, where # `haystack` is any object that responds to `include?`. def assert_doesnt_include(needle, haystack, message = nil) - error = ' included in ' + error = " included in " message = build_message(message, error, needle.to_s, Array(haystack).map(&:to_s)) assert_block message do @@ -19,7 +20,7 @@ def assert_doesnt_include(needle, haystack, message = nil) # Asserts that `needle` is a member of `haystack`, where # `haystack` is any object that responds to `include?`. def assert_includes(needle, haystack, message = nil) - error = ' not included in ' + error = " not included in " message = build_message(message, error, needle.to_s, Array(haystack).map(&:to_s)) assert_block message do @@ -33,6 +34,20 @@ def assert_equal_html(expected, actual) assert_equal Nokogiri::HTML::DocumentFragment.parse(expected).to_hash, Nokogiri::HTML::DocumentFragment.parse(actual).to_hash end + + # Asserts that when a Filter is loaded without its dependencies installed, + # a HTML::Pipeline::Filter::MissingDependencyException is raised with a + # message describing the problem and a fix. + def assert_dependency_management_error(filter_name, gem_name) + TestingDependency.temporarily_remove_dependency_by gem_name do + exception = assert_raise HTML::Pipeline::Filter::MissingDependencyException do + load TestingDependency.filter_path_from filter_name + end + + assert_equal exception.message, + "Missing html-pipeline dependency: Please add `#{gem_name}` to your Gemfile; see html-pipeline Gemfile for version." + end + end end -Test::Unit::TestCase.send(:include, TestHelpers) \ No newline at end of file +Test::Unit::TestCase.send(:include, TestHelpers)