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'
'alert-link'
true
[].freeze
DEFAULT_HEADING_ANCHOR =
false
DEFAULT_HEADING_ANCHOR_ICON =
'fa-solid fa-link'

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.nameObject



108
109
110
# File 'lib/jekyll-link-decorator.rb', line 108

def self.name
  'LinkDecorator'
end

Instance Method Details

#add_classes(link, classes_string) ⇒ Object

Add CSS classes to a link element



269
270
271
# File 'lib/jekyll-link-decorator.rb', line 269

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



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/jekyll-link-decorator.rb', line 351

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

  exclude_tags = config.fetch('external_link_icon_excluded_tags', DEFAULT_EXTERNAL_LINK_ICON_EXCLUDED_TAGS)
  return if exclude_tags.any? { |tag| link.css(tag).any? }

  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'
  icon['aria-hidden'] = 'true'
  link.add_child(icon)
end

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



274
275
276
277
278
279
280
281
282
283
# File 'lib/jekyll-link-decorator.rb', line 274

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



170
171
172
173
174
175
176
177
178
# File 'lib/jekyll-link-decorator.rb', line 170

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



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

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)



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/jekyll-link-decorator.rb', line 246

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_LINK_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|
    next if link.ancestors('p.alert').any?

    classes_to_add = if link['class'].to_s.match?(/\blink-underline-(?!opacity)\S+/)
                       default_classes.split.grep_v(/\Alink-underline-(?!opacity)/).join(' ')
                     else
                       default_classes
                     end
    add_classes(link, classes_to_add)
  end
end

#convert(content) ⇒ Object



209
210
211
212
# File 'lib/jekyll-link-decorator.rb', line 209

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)


159
160
161
162
163
164
165
166
# File 'lib/jekyll-link-decorator.rb', line 159

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



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/jekyll-link-decorator.rb', line 214

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)
  add_heading_anchors(doc)

  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”



114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/jekyll-link-decorator.rb', line 114

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



286
287
288
289
# File 'lib/jekyll-link-decorator.rb', line 286

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)


132
133
134
135
136
137
# File 'lib/jekyll-link-decorator.rb', line 132

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



201
202
203
# File 'lib/jekyll-link-decorator.rb', line 201

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

#output_ext(_ext) ⇒ Object



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

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)


237
238
239
240
241
242
243
# File 'lib/jekyll-link-decorator.rb', line 237

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