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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.nameObject



83
84
85
# File 'lib/jekyll-link-decorator.rb', line 83

def self.name
  'LinkDecorator'
end

Instance Method Details

#add_classes(link, classes_string) ⇒ Object

Add CSS classes to a link element



243
244
245
# File 'lib/jekyll-link-decorator.rb', line 243

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



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/jekyll-link-decorator.rb', line 266

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



248
249
250
251
252
253
254
255
256
257
# File 'lib/jekyll-link-decorator.rb', line 248

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



145
146
147
148
149
150
151
152
153
# File 'lib/jekyll-link-decorator.rb', line 145

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



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/jekyll-link-decorator.rb', line 158

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)



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/jekyll-link-decorator.rb', line 220

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



184
185
186
187
# File 'lib/jekyll-link-decorator.rb', line 184

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)


134
135
136
137
138
139
140
141
# File 'lib/jekyll-link-decorator.rb', line 134

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



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/jekyll-link-decorator.rb', line 189

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”



89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/jekyll-link-decorator.rb', line 89

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



260
261
262
263
# File 'lib/jekyll-link-decorator.rb', line 260

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)


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

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



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

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

#output_ext(_ext) ⇒ Object



180
181
182
# File 'lib/jekyll-link-decorator.rb', line 180

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)


211
212
213
214
215
216
217
# File 'lib/jekyll-link-decorator.rb', line 211

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