From 8792bc6c7dc2ed6ec870f64ff505b48ea9abba25 Mon Sep 17 00:00:00 2001 From: Julio Lucero Date: Thu, 16 Jul 2026 09:36:46 -0300 Subject: [PATCH 1/6] Add RSpec test suite with Rake task and CI workflow --- .github/workflows/ci.yml | 21 +++ .rspec | 3 + Gemfile.lock | 21 ++- Rakefile | 7 + jekyll-external-link-accessibility.gemspec | 3 + spec/hooks_spec.rb | 30 ++++ ...jekyll-external-link-accessibility_spec.rb | 131 ++++++++++++++++++ spec/spec_helper.rb | 24 ++++ 8 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .rspec create mode 100644 Rakefile create mode 100644 spec/hooks_spec.rb create mode 100644 spec/jekyll-external-link-accessibility_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3232de8 --- /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'] + 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/Gemfile.lock b/Gemfile.lock index 28bc345..4c4cf6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ 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) @@ -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..d2dbf27 100644 --- a/jekyll-external-link-accessibility.gemspec +++ b/jekyll-external-link-accessibility.gemspec @@ -24,4 +24,7 @@ Gem::Specification.new do |spec| spec.add_dependency "jekyll", '~> 4.0', '>= 4.0.1' spec.add_dependency 'nokogiri', '~> 1.8', '>= 1.8.5' + + 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..ced7701 --- /dev/null +++ b/spec/hooks_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe 'pages :post_render hook' do + # Fires the registered hook for a page with the given extension and reports + # whether the rewriter ran. + 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..138aac0 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'jekyll-external-link-accessibility' + +RSpec.configure do |config| + 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 + +# 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 From 237973df3dad9b147914e7ba5a56fd4f5d09f90e Mon Sep 17 00:00:00 2001 From: Julio Lucero Date: Thu, 16 Jul 2026 12:48:14 -0300 Subject: [PATCH 2/6] Extend CI matrix through Ruby 4.0 and add changelog entry --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3232de8..9600052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['2.7', '3.0', '3.1', '3.2'] + 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 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) From 1e8cf42e0f8def12664a59ca0fa85d9121d43c33 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 14:00:31 -0400 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- spec/hooks_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/hooks_spec.rb b/spec/hooks_spec.rb index ced7701..0dae260 100644 --- a/spec/hooks_spec.rb +++ b/spec/hooks_spec.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true RSpec.describe 'pages :post_render hook' do - # Fires the registered hook for a page with the given extension and reports - # whether the rewriter ran. + # 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 } From 651624f02fc4e0fe3249f63ebd17365c06b7a9e6 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 14:01:08 -0400 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- spec/spec_helper.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 138aac0..c2c0630 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,7 +2,18 @@ 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 @@ -15,10 +26,3 @@ config.order = :random Kernel.srand config.seed end - -# 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 From 6eeb0b78692406082cd309d17fa9e67a4fe46fa4 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 14:01:35 -0400 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jekyll-external-link-accessibility.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jekyll-external-link-accessibility.gemspec b/jekyll-external-link-accessibility.gemspec index d2dbf27..71fa15e 100644 --- a/jekyll-external-link-accessibility.gemspec +++ b/jekyll-external-link-accessibility.gemspec @@ -23,7 +23,7 @@ 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' From 7427baad0207171c71ac3cb305c5272dedbe235f Mon Sep 17 00:00:00 2001 From: Julio Lucero Date: Thu, 16 Jul 2026 16:30:30 -0300 Subject: [PATCH 6/6] Update Gemfile.lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4c4cf6e..42c62e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ PATH specs: 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/