Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTML Pipeline filter to make all relative urls absolute #12

Closed
wants to merge 6 commits into from
Closed
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
2 changes: 2 additions & 0 deletions jekyll-relative-links.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Gem::Specification.new do |s|
s.license = "MIT"

s.add_dependency "jekyll", "~> 3.3"
s.add_dependency "html-pipeline"
s.add_dependency "addressable"
s.add_development_dependency "rubocop", "~> 0.43"
s.add_development_dependency "rspec", "~> 3.5"
end
92 changes: 92 additions & 0 deletions lib/html/pipeline/relative_link_filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
require "html/pipeline"
require "addressable"

module HTML
class Pipeline
# Relative link filter modifies relative links based on viewing location,
# making it so these links lead to the correct desination no matter where
# they are viewed from.
#
# Requires values passed in the context:
#
# base_url - path that all links should be relative to
# current_url - path where this content is being shown
# (e.g. "", "index.html", or "nested/dir")
class RelativeLinkFilter < Filter
def initialize(doc, context = nil, result = nil)
super

base = context[:base_url]
# Base should always end in /
base += "/" unless base.end_with?("/")
@base_url = Addressable::URI.parse(base)
end

def call
return doc unless should_process?

doc.search("a").each do |node|
apply_filter node, "href"
end
doc.search("img").each do |node|
apply_filter node, "src"
end
doc.search("script").each do |node|
apply_filter node, "src"
end
doc.search("link").each do |node|
apply_filter node, "href"
end

doc
end

def should_process?
context[:base_url] && context[:current_url]
end

def apply_filter(node, attribute)
attr = node.attributes[attribute]
if attr
new_url = make_relative(attr.value)
attr.value = new_url if new_url
end
end

def make_relative(url)
return unless url

# Explicit protocol, e.g. https://example.com/
return if url =~ %r!^[a-z][a-z0-9\+\.\-]+:!i
# Protocol relative, e.g. //example.com/
return if url.start_with?("//")
# Hash, e.g #foobar
return if url.start_with?("#")

corrected_link(url)
end

private

# Build a more absolute relative link
#
# link - original link
def corrected_link(link)
url = @base_url

url = if link.start_with?(url.path)
# Link already includes our base url
url.join(link)
elsif link.start_with?("/")
# Make link relative to base
url.join(link.sub("/", ""))
else
# Make link relative to base and current page
url.join(context[:current_url]).join(link)
end

url.to_s
end
end
end
end
36 changes: 36 additions & 0 deletions lib/jekyll-relative-links.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
require "jekyll"
require "html/pipeline/relative_link_filter"
require "jekyll-relative-links/generator"
require "jekyll-relative-links/context"

module JekyllRelativeLinks
class << self
def relativize(doc)
base_url = Addressable::URI.join(
doc.site.config["url"].to_s,
ensure_leading_slash(doc.site.config["baseurl"])
).normalize.to_s

doc.output = filter(base_url, doc.url).call(doc.output)[:output].to_s
end

def filter(base_url, current_url)
HTML::Pipeline.new(
[HTML::Pipeline::RelativeLinkFilter],
{ :base_url => base_url, :current_url => current_url }
)
end

# Public: Defines the conditions for a document to be relativizable.
#
# doc - the Jekyll::Document or Jekyll::Page
#
# Returns true if the doc is written & is HTML.
def relativizable?(doc)
(doc.is_a?(Jekyll::Page) || doc.write?) &&
doc.output_ext == ".html" || (doc.permalink && doc.permalink.end_with?("/"))
end

def ensure_leading_slash(url)
url[0] == "/" ? url : "/#{url}"
end
end
end

Jekyll::Hooks.register [:pages, :documents], :post_render do |doc|
JekyllRelativeLinks.relativize(doc) if JekyllRelativeLinks.relativizable?(doc)
end
188 changes: 188 additions & 0 deletions spec/html/pipeline/relative_link_filter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
RSpec.describe HTML::Pipeline::RelativeLinkFilter do
def filter_link(url)
content = %(<a href="#{url}">thing</a>)
result = HTML::Pipeline::RelativeLinkFilter.new(content, context, nil).call
result.search("a").first.attribute("href").value
end

describe "anchors" do
context "with a root of /root" do
let(:context) do
{ :current_url => "", :base_url => "/root" }
end

it "prefixes relative urls with root" do
expect(filter_link("relative")).to eql("/root/relative")
end

it "prefixes relative urls with root and current path" do
context[:current_url] = "current/page"
expect(filter_link("relative")).to eql("/root/current/relative")
end

it "prefixes relative urls with root and current path as a directory" do
context[:current_url] = "current/page/"
expect(filter_link("relative")).to eql("/root/current/page/relative")
end

it "makes absolute urls relative to root" do
context[:current_url] = "current/page"
expect(filter_link("/absolute")).to eql("/root/absolute")
end

it "does not duplicate root if it already exists" do
expect(filter_link("/root/foo")).to eql("/root/foo")
end

it "ignores external URLs" do
expect(filter_link("https://example.com")).to eql("https://example.com")
end

it "ignores hashes" do
expect(filter_link("#foobar")).to eql("#foobar")
end

it "ignores protocol relative urls" do
expect(filter_link("//example.com")).to eql("//example.com")
end

it "ignores anchors without an href" do
content = %(<a name="foo">thing</a>)
result = HTML::Pipeline::RelativeLinkFilter.new(content, context, nil).call
href = result.search("a").first.attribute("href")
expect(href).to be(nil)
end
end

context "with an empty root" do
let(:context) do
{ :current_url => "", :base_url => "" }
end

it "prefixes relative urls with root" do
expect(filter_link("relative")).to eql("/relative")
end

it "prefixes relative urls with root and current path" do
context[:current_url] = "current/page"
expect(filter_link("relative")).to eql("/current/relative")
end

it "prefixes relative urls with root and current path as a directory" do
context[:current_url] = "current/page/"
expect(filter_link("relative")).to eql("/current/page/relative")
end

it "makes absolute urls relative to root" do
context[:current_url] = "current/page"
expect(filter_link("/absolute")).to eql("/absolute")
end
end

context "with a fully-qualified base" do
let(:context) do
{ :current_url => "", :base_url => "http://example.com/foo" }
end

it "prefixes relative urls with root" do
expect(filter_link("relative")).to eql("http://example.com/foo/relative")
end

it "prefixes relative urls with root and current path" do
context[:current_url] = "current/page"
expect(filter_link("relative")).to eql("http://example.com/foo/current/relative")
end

it "prefixes relative urls with root and current path as a directory" do
context[:current_url] = "current/page/"
expect(filter_link("relative")).to eql(
"http://example.com/foo/current/page/relative"
)
end

it "makes absolute urls relative to root" do
context[:current_url] = "current/page"
expect(filter_link("/absolute")).to eql("http://example.com/foo/absolute")
end

it "does not duplicate root if it already exists" do
expect(filter_link("/foo/bar")).to eql("http://example.com/foo/bar")
end
end
end

describe "images" do
def filter_img(url)
content = %(<img src="#{url}">)
result = HTML::Pipeline::RelativeLinkFilter.new(content, context, nil).call
result.search("img").first.attribute("src").value
end

context "with a root of /root" do
let(:context) do
{ :current_url => "", :base_url => "/root" }
end

it "prefixes relative urls with root" do
expect(filter_img("relative.png")).to eql("/root/relative.png")
end

it "prefixes relative urls with root and current path" do
context[:current_url] = "current/page"
expect(filter_img("relative.png")).to eql("/root/current/relative.png")
end

it "prefixes relative urls with root and current path as a directory" do
context[:current_url] = "current/page/"
expect(filter_img("relative.png")).to eql("/root/current/page/relative.png")
end

it "makes absolute urls relative to root" do
context[:current_url] = "current/page"
expect(filter_img("/absolute.png")).to eql("/root/absolute.png")
end

it "ignores external URLs" do
expect(filter_img("https://example.com/foo.png")).to eql(
"https://example.com/foo.png"
)
end

it "ignores protocol relative urls" do
expect(filter_img("//example.com/foo.png")).to eql("//example.com/foo.png")
end

it "ignores images without a src" do
content = %(<img alt="yep">)
result = HTML::Pipeline::RelativeLinkFilter.new(content, context, nil).call
src = result.search("img").first.attribute("src")
expect(src).to be(nil)
end
end

context 'with a root of ""' do
let(:context) do
{ :current_url => "", :base_url => "" }
end

it "prefixes relative urls with root" do
expect(filter_img("relative")).to eql("/relative")
end

it "prefixes relative urls with root and current path" do
context[:current_url] = "current/page"
expect(filter_img("relative")).to eql("/current/relative")
end

it "prefixes relative urls with root and current path as a directory" do
context[:current_url] = "current/page/"
expect(filter_img("relative")).to eql("/current/page/relative")
end

it "makes absolute urls relative to root" do
context[:current_url] = "current/page"
expect(filter_img("/absolute")).to eql("/absolute")
end
end
end
end