Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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']
Comment thread
julioalucero marked this conversation as resolved.
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake
3 changes: 3 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--require spec_helper
--color
--format documentation
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
23 changes: 20 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -74,6 +89,8 @@ PLATFORMS

DEPENDENCIES
jekyll-external-link-accessibility!
rake (~> 13.0)
rspec (~> 3.0)

BUNDLED WITH
2.1.4
2.4.22
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec)

task default: :spec
5 changes: 4 additions & 1 deletion jekyll-external-link-accessibility.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
Copilot marked this conversation as resolved.
end
29 changes: 29 additions & 0 deletions spec/hooks_spec.rb
Original file line number Diff line number Diff line change
@@ -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('<html><body></body></html>', '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
131 changes: 131 additions & 0 deletions spec/jekyll-external-link-accessibility_spec.rb
Original file line number Diff line number Diff line change
@@ -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 = "<html><body><div class='#{wrapper}'>#{inner_html}</div></body></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("<a href='https://other.com'>read</a>").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("<a href='https://other.com'>read</a>", wrapper: 'post-excerpt')
.at_css('.post-excerpt a')

expect(link['target']).to eq('_blank')
end

it 'leaves anchors outside those containers untouched' do
html = "<html><body><nav><a href='https://other.com'>home</a></nav></body></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("<a href='https://other.com'>x</a>").at_css('a')
internal = rewrite("<a href='https://example.com/blog'>x</a>").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)
<a>no href</a>
<a href=''>empty</a>
<a href='#top'>anchor</a>
<a href='https://other.com' data-no-external='true'>opted out</a>
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(
"<a href='https://other.com' target='_self' title='Keep me' rel='ugc'>x</a>"
).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("<a href='https://other.com'>x</a>", 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
28 changes: 28 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Loading