Class: Jekyll::Converters::LinkDecorator

Inherits:
Markdown
  • Object
show all
Defined in:
lib/jekyll-link-decorator.rb

Overview

This custom converter applies specific CSS classes to <a> tags based on their context (e.g., inside an alert box) and excludes buttons, all done via Nokogiri’s powerful CSS selectors. It also adds external-link icons for cross-domain external links.

Constant Summary collapse

'link link-offset-3 link-offset-3-hover link-underline-primary ' \
'link-underline-opacity-0 link-underline-opacity-100-hover'
DEFAULT_ALERT_CLASSES =
'alert-link'
true

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.nameObject



75
76
77
# File 'lib/jekyll-link-decorator.rb', line 75

def self.name
  'LinkDecorator'
end

Instance Method Details

#add_classes(link, classes_string) ⇒ Object

Add CSS classes to a link element



228
229
230
# File 'lib/jekyll-link-decorator.rb', line 228

def add_classes(link, classes_string)
  classes_string.split.each { |cls| link.add_class(cls) }
end

#add_external_icon(link, site_domain, config, doc) ⇒ Object

Add external-link icon to cross-domain external links



251
252
253
254
255
256
257
258
259
260
# File 'lib/jekyll-link-decorator.rb', line 251

def add_external_icon(link, site_domain, config, doc)
  external_link_icon = config.fetch('external_link_icon', DEFAULT_EXTERNAL_LINK_ICON)

  return unless external_link_icon
  return unless is_external_different_domain_link?(link, site_domain)

  icon = Nokogiri::XML::Node.new('i', doc)
  icon['class'] = 'fa-solid fa-external-link ms-1'
  link.add_child(icon)
end

Add external link features: target=“_blank”, icons, and security attributes



233
234
235
236
237
238
239
240
241
242
# File 'lib/jekyll-link-decorator.rb', line 233

def add_external_link_features(doc, config)
  site_domain = extract_site_domain
  return if site_domain.empty?

  doc.css('a:not(.btn)').each do |link|
    add_external_target_blank(link, site_domain)
    add_external_icon(link, site_domain, config, doc)
    add_external_target_protection(link)
  end
end

#add_external_target_blank(link, site_domain) ⇒ Object

Add target=“_blank” to external cross-domain links Only adds if target is not already set



137
138
139
140
141
142
143
144
145
# File 'lib/jekyll-link-decorator.rb', line 137

def add_external_target_blank(link, site_domain)
  # Skip if link already has a target attribute
  return if link['target'].to_s.strip != ''

  # Add target="_blank" if this is a cross-domain external link
  return unless cross_domain_external_link?(link, site_domain)

  link['target'] = '_blank'
end

#add_external_target_protection(link) ⇒ Object

Add security attributes to links with target=“_blank” Adds rel=“noopener noreferrer” to prevent security vulnerabilities Preserves any existing rel attribute values



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/jekyll-link-decorator.rb', line 150

def add_external_target_protection(link)
  target = link['target'].to_s

  # Only add protection for target="_blank"
  return unless target == '_blank'

  # Get existing rel attribute (if any)
  existing_rel = link['rel'].to_s.strip
  rel_values = existing_rel.empty? ? [] : existing_rel.split(/\s+/)

  # Add required security values if not already present
  rel_values << 'noopener' unless rel_values.include?('noopener')
  rel_values << 'noreferrer' unless rel_values.include?('noreferrer')

  # Set the updated rel attribute
  link['rel'] = rel_values.join(' ')
end

Apply CSS classes to links based on their context (alert or default)



212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/jekyll-link-decorator.rb', line 212

def apply_link_styles(doc, config)
  default_classes = config.fetch('default_link_classes', DEFAULT_LINK_CLASSES)
  alert_classes   = config.fetch('alert_link_classes',   DEFAULT_ALERT_CLASSES)

  # Apply alert classes to links inside p.alert
  doc.css('p.alert a:not(.btn)').each do |link|
    add_classes(link, alert_classes)
  end

  # Apply default classes to all other non-alert links
  doc.css('a:not(.btn)').each do |link|
    add_classes(link, default_classes) unless link.ancestors('p.alert').any?
  end
end

#convert(content) ⇒ Object



176
177
178
179
# File 'lib/jekyll-link-decorator.rb', line 176

def convert(content)
  html = super
  decorate_html(html)
end

#cross_domain_external_link?(link, site_domain) ⇒ Boolean

Determine if a link points to an external domain Returns true if:

  • Link is absolute (starts with http:// or https://)

  • Link domain is different from site.url domain

Returns false if:

  • Link is relative (starts with /, ../)

  • Link domain matches site.url domain

Returns:

  • (Boolean)


126
127
128
129
130
131
132
133
# File 'lib/jekyll-link-decorator.rb', line 126

def cross_domain_external_link?(link, site_domain)
  href = link['href'].to_s

  # Relative URLs (starting with / or ../) are internal links
  return false if %r{^(/|\.\.)}.match?(href)

  absolute_different_domain?(link, site_domain)
end

#decorate_html(html) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/jekyll-link-decorator.rb', line 181

def decorate_html(html)
  enabled = @config.key?('with_link_decorator') ? @config['with_link_decorator'] : true
  config = @config['with_link_decorator_data'] || {}

  return html unless plugin_enabled?(enabled)

  doc = Nokogiri::HTML::DocumentFragment.parse(html)

  apply_link_styles(doc, config)
  add_external_link_features(doc, config)

  doc.to_html
rescue StandardError => e
  Jekyll.logger.error 'LinkDecorator Error:',
                      "An error occurred during link decoration: #{e.message}"
  Jekyll.logger.error 'Backtrace:', e.backtrace.join("\n")

  html
end

#extract_domain(url) ⇒ Object

Extract domain from a URL by removing protocol and path components Example: “example.com/path?query=1#anchor” -> “example.com”



81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/jekyll-link-decorator.rb', line 81

def extract_domain(url)
  return '' if url.nil? || url.empty?

  # Remove protocol (http:// or https://)
  domain = url.sub(%r{^https?://}, '')

  # Extract just the domain part (before /, ?, or #)
  domain = domain.split(%r{[/?#]}).first

  # Convert to lowercase for case-insensitive comparison
  domain.downcase
end

#extract_site_domainObject

Extract site domain from Jekyll configuration



245
246
247
248
# File 'lib/jekyll-link-decorator.rb', line 245

def extract_site_domain
  site_url = @config['url'] || @config['baseurl'] || ''
  extract_domain(site_url)
end

#is_external_different_domain_link?(link, site_domain) ⇒ Boolean

Check if a link is an external link pointing to a different domain Returns true if:

  • Link starts with http:// or https://

  • Link domain is different from site.url domain

  • Link doesn’t already have an external-link icon child

Returns:

  • (Boolean)


99
100
101
102
103
104
# File 'lib/jekyll-link-decorator.rb', line 99

def is_external_different_domain_link?(link, site_domain)
  return false unless absolute_different_domain?(link, site_domain)
  return false if EXISTING_ICON_SELECTORS.any? { |sel| link.css(sel).any? }

  true
end

#matches(ext) ⇒ Object



168
169
170
# File 'lib/jekyll-link-decorator.rb', line 168

def matches(ext)
  ext =~ /^\.md$/i
end

#output_ext(_ext) ⇒ Object



172
173
174
# File 'lib/jekyll-link-decorator.rb', line 172

def output_ext(_ext)
  '.html'
end

#plugin_enabled?(enabled) ⇒ Boolean

Check if the plugin is enabled in configuration Defaults to true if not explicitly configured

Returns:

  • (Boolean)


203
204
205
206
207
208
209
# File 'lib/jekyll-link-decorator.rb', line 203

def plugin_enabled?(enabled)
  unless enabled
    Jekyll.logger.info 'LinkDecorator:',
                       'Disabled via configuration. Skipping custom link modification.'
  end
  enabled
end