diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9600052 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '3.5', '4.0'] + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..4e33a32 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--color +--format documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index 3721763..2e35976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # main [(unreleased)](https://github.com/fastruby/jekyll-external-link-accessibility/compare/v0.2.0...main) -- Your changes/patches go here. +- [CHORE: Add an RSpec test suite and CI matrix running Ruby 2.7 through 4.0](https://github.com/fastruby/jekyll-external-link-accessibility/pull/6) # v0.2.0 / 2026-07-15 [(commits)](https://github.com/fastruby/jekyll-external-link-accessibility/compare/v0.1.0...v0.2.0) diff --git a/Gemfile.lock b/Gemfile.lock index 28bc345..42c62e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ PATH remote: . specs: - jekyll-external-link-accessibility (0.1.0) + jekyll-external-link-accessibility (0.2.0) jekyll (~> 4.0, >= 4.0.1) - nokogiri (~> 1.8, >= 1.8.5) + nokogiri (>= 1.12.0, < 2.0) GEM remote: https://rubygems.org/ @@ -12,6 +12,7 @@ GEM public_suffix (>= 2.0.2, < 6.0) colorator (1.1.0) concurrent-ruby (1.1.9) + diff-lcs (1.6.2) em-websocket (0.5.2) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) @@ -57,11 +58,25 @@ GEM forwardable-extended (~> 2.6) public_suffix (5.0.1) racc (1.6.2) + rake (13.4.2) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) rexml (3.2.5) rouge (3.26.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) safe_yaml (1.0.5) sassc (2.4.0) ffi (~> 1.9) @@ -74,6 +89,8 @@ PLATFORMS DEPENDENCIES jekyll-external-link-accessibility! + rake (~> 13.0) + rspec (~> 3.0) BUNDLED WITH - 2.1.4 + 2.4.22 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cffdd09 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/jekyll-external-link-accessibility.gemspec b/jekyll-external-link-accessibility.gemspec index 5f9e09b..71fa15e 100644 --- a/jekyll-external-link-accessibility.gemspec +++ b/jekyll-external-link-accessibility.gemspec @@ -23,5 +23,8 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.add_dependency "jekyll", '~> 4.0', '>= 4.0.1' - spec.add_dependency 'nokogiri', '~> 1.8', '>= 1.8.5' + spec.add_dependency 'nokogiri', '>= 1.12.0', '< 2.0' + + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rake', '~> 13.0' end diff --git a/spec/hooks_spec.rb b/spec/hooks_spec.rb new file mode 100644 index 0000000..0dae260 --- /dev/null +++ b/spec/hooks_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe 'pages :post_render hook' do + # Fires the registered hook for a page with the given extension. + def trigger(extname) + page = fake_page('', 'url' => 'https://example.com') + page.define_singleton_method(:extname) { extname } + Jekyll::Hooks.trigger(:pages, :post_render, page) + end + + before do + allow(Jekyll::ExternalLinkAccessibility).to receive(:modify_links) + end + + it 'rewrites html pages' do + trigger('.html') + trigger('.htm') + + expect(Jekyll::ExternalLinkAccessibility).to have_received(:modify_links).twice + end + + it 'skips non-html pages so xml/json/txt are not corrupted' do + trigger('.xml') + trigger('.json') + trigger('.txt') + + expect(Jekyll::ExternalLinkAccessibility).not_to have_received(:modify_links) + end +end diff --git a/spec/jekyll-external-link-accessibility_spec.rb b/spec/jekyll-external-link-accessibility_spec.rb new file mode 100644 index 0000000..2ae8beb --- /dev/null +++ b/spec/jekyll-external-link-accessibility_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +RSpec.describe Jekyll::ExternalLinkAccessibility do + # Runs modify_links over a snippet wrapped in .post-content and hands back a + # parsed document so specs can assert on the rewritten markup. + def rewrite(inner_html, config: { 'url' => 'https://example.com' }, wrapper: 'post-content') + html = "
#{inner_html}
" + page = fake_page(html, config) + described_class.modify_links(page) + Nokogiri::HTML5(page.output) + end + + describe '.host_for' do + it 'downcases the host' do + expect(described_class.host_for('https://Example.COM/path')).to eq('example.com') + end + + it 'strips a leading www.' do + expect(described_class.host_for('https://www.example.com')).to eq('example.com') + end + + it 'returns nil for a blank or nil url' do + expect(described_class.host_for(nil)).to be_nil + expect(described_class.host_for('')).to be_nil + end + + it 'returns nil on an invalid uri' do + expect(described_class.host_for('http://[invalid')).to be_nil + end + end + + describe '.external_link?' do + let(:site_host) { 'example.com' } + + it 'is true for another host over http, https or protocol-relative' do + expect(described_class.external_link?('http://other.com', site_host)).to be(true) + expect(described_class.external_link?('https://other.com', site_host)).to be(true) + expect(described_class.external_link?('//other.com/page', site_host)).to be(true) + end + + it 'is false for the same host, including the www variant' do + expect(described_class.external_link?('https://example.com/blog', site_host)).to be(false) + expect(described_class.external_link?('https://www.example.com/blog', site_host)).to be(false) + end + + it 'is false for relative and anchor links' do + expect(described_class.external_link?('/blog/post', site_host)).to be(false) + expect(described_class.external_link?('#section', site_host)).to be(false) + end + + it 'is true when the scheme matches but the host cannot be parsed' do + expect(described_class.external_link?('http://[invalid', site_host)).to be(true) + end + end + + describe '.modify_links' do + it 'rewrites anchors inside .post-content' do + link = rewrite("read").at_css('.post-content a') + + expect(link['target']).to eq('_blank') + expect(link['title']).to eq('Opens a new window') + expect(link.at_css('i.icon-external-link')).not_to be_nil + expect(link.text).to include('opens a new window') + end + + it 'rewrites anchors inside .post-excerpt' do + link = rewrite("read", wrapper: 'post-excerpt') + .at_css('.post-excerpt a') + + expect(link['target']).to eq('_blank') + end + + it 'leaves anchors outside those containers untouched' do + html = "" + page = fake_page(html, 'url' => 'https://example.com') + described_class.modify_links(page) + link = Nokogiri::HTML5(page.output).at_css('nav a') + + expect(link['target']).to be_nil + expect(link.at_css('i.icon-external-link')).to be_nil + end + + it 'adds rel only to external links' do + external = rewrite("x").at_css('a') + internal = rewrite("x").at_css('a') + + expect(external['rel']).to eq('external nofollow noopener noreferrer') + expect(internal['rel']).to be_nil + end + + it 'skips links with no href, empty href, anchors or data-no-external' do + doc = rewrite(<<~HTML) + no href + empty + anchor + opted out + HTML + + doc.css('.post-content a').each do |a| + expect(a['target']).to be_nil + expect(a.at_css('i.icon-external-link')).to be_nil + end + end + + it 'does not overwrite an existing target, title or rel' do + link = rewrite( + "x" + ).at_css('a') + + expect(link['target']).to eq('_self') + expect(link['title']).to eq('Keep me') + expect(link['rel']).to eq('ugc') + end + + it 'honors config overrides for rel, target and title' do + config = { + 'url' => 'https://example.com', + 'external_links' => { + 'rel' => 'nofollow', + 'target' => '_top', + 'title' => 'External site' + } + } + link = rewrite("x", config: config).at_css('a') + + expect(link['rel']).to eq('nofollow') + expect(link['target']).to eq('_top') + expect(link['title']).to eq('External site') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..c2c0630 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'jekyll-external-link-accessibility' + +module SpecHelpers + # The gem only touches page.output, page.output= and page.site.config, so a + # couple of Structs are enough to stand in for a real Jekyll page. + def fake_page(html, config = {}) + site = Struct.new(:config).new(config) + Struct.new(:output, :site).new(html, site) + end +end + +RSpec.configure do |config| + config.include SpecHelpers + + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end