Post

Make external links open in a new tab — a safe Jekyll plugin

Add a Jekyll post-render hook that marks external links with target="_blank" and rel="noopener noreferrer" without breaking images, TOC or mailto links.

Make external links open in a new tab — a safe Jekyll plugin

A small, safe Jekyll plugin can automatically add target="_blank" and rel="noopener noreferrer" to external links at build time. This keeps runtime JavaScript out of the page and enforces security best practices for links that open in a new tab — while avoiding common breakage (image wrappers, hash links, mailto:/tel: links, internal links).

🔎 What the plugin does

  • Runs as a post_render hook for pages, posts and documents.
  • Parses full HTML with Nokogiri (preserves scripts, TOC and wrapper elements).
  • Only modifies absolute http(s) and protocol-relative links.
  • Skips internal links (same hostname), anchors, mailto: and tel: links.
  • Skips anchors that wrap <img> (prevents lightbox/gallery breakage).
  • Preserves existing target/rel attributes.

⚙️ Install the plugin

  1. Create a plugin file at _plugins/modify-links.rb and paste the code below. This variant is careful to avoid breaking image links and TOC anchors.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
require 'nokogiri'
require 'uri'

Jekyll::Hooks.register [:pages, :documents, :posts], :post_render do |doc|
  next unless doc.output_ext == '.html'
  site_url = (doc.site.config['url'] || '').strip
  site_host = begin
    URI.parse(site_url).host if site_url != ''
  rescue
    nil
  end

  # Use full document parse to avoid stripping important wrapper elements
  html = Nokogiri::HTML::Document.parse(doc.output)

  html.css('a[href]').each do |a|
    href = a['href'].to_s.strip
    next if href.empty?
    # skip internal anchors and non-http links
    next if href.start_with?('#') || href.start_with?('mailto:') || href.start_with?('tel:')
    begin
      uri = URI.parse(href)
    rescue
      next
    end

    # Only handle absolute http/https URLs and protocol-relative URLs
    is_http = uri.scheme == 'http' || uri.scheme == 'https' || href.start_with?('//')
    next unless is_http

    # determine host for protocol-relative URLs
    href_host = uri.host
    if href_host.nil? && href.start_with?('//')
      href_host = href.sub(%r{^//}, '').split('/').first
    end

    # don't touch links pointing to the same host (internal)
    if site_host && href_host && href_host == site_host
      next
    end

    # skip anchors that wrap images (prevents breaking image lightboxes / thumbnails)
    next if a.at_css('img')

    # set attributes only if not already present (preserve custom attributes)
    a.set_attribute('target', '_blank') unless a['target']
    rel_vals = (a['rel'] || '').split(/\s+/)
    %w[noopener noreferrer].each { |v| rel_vals << v unless rel_vals.include?(v) }
    a.set_attribute('rel', rel_vals.join(' ').strip)
  end

  doc.output = html.to_html
end
  1. Ensure nokogiri is available in your build environment. If you use Bundler, add it to your Gemfile:
1
gem 'nokogiri'

Then run:

1
bundle install
  1. Build your site locally to test:
1
bundle exec jekyll build

✅ Quick verification

  • Find external links that received the attributes:
1
grep -R --include="*.html" -n 'target="_blank"' _site | head -n 50
  • Ensure no images are wrapped in anchors with target:
1
grep -R --include="*.html" -nP '<a[^>]+target="_blank"[^>]*>\s*<img' _site || echo "No image-wrapping anchors with target found"
  • Spot-check a page with TOC or a gallery and inspect anchors in DevTools.

🔁 Client-side fallback (GitHub Pages / no plugin support)

If you cannot use plugins (for example on GitHub Pages without a custom builder), add a tiny JS snippet that sets the attributes at runtime.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
document.addEventListener('DOMContentLoaded', function () {
  try {
    var host = location.hostname;
    document.querySelectorAll('a[href^="http"]').forEach(function (a) {
      try {
        var u = new URL(a.href);
        if (u.hostname !== host) {
          if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');
          var rel = (a.getAttribute('rel') || '').split(/\s+/);
          if (rel.indexOf('noopener') === -1) rel.push('noopener');
          if (rel.indexOf('noreferrer') === -1) rel.push('noreferrer');
          a.setAttribute('rel', rel.join(' ').trim());
        }
      } catch (e) { /* ignore invalid URLs */ }
    });
  } catch (e) {}
});

Include it in your base layout near </body>:

1
<script src="/assets/js/external-links.js" defer></script>

⚠️ Caveats & recommendations

  • GitHub Pages (default) does not allow arbitrary plugins — use the client-side JS there or build with GitHub Actions and push the generated _site.
  • The plugin relies on site.url in _config.yml to detect internal links. If site.url is missing, some internal links may be treated as external. Set site.url to your canonical URL to be safe.
  • The plugin preserves existing attributes; if you intentionally want a link to open same-tab, set target on that anchor explicitly in your Markdown/HTML.

🧠 Final Thoughts

This build-time approach gives you a clean, fast site with security best practices applied to outbound links while avoiding the most common regressions (broken image links, TOC issues). Use the client-side JS only where plugins are not possible.

This post is licensed under CC BY 4.0 by the author.