jekyll-link-decorator 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,12 @@ module Jekyll
6
6
  #
7
7
  # All tracing is configured centrally in TRACED_METHODS — no span code lives
8
8
  # in business logic classes. To add a span: append one entry. To rename a
9
- # method: update the one entry. When a method is removed from its class the
10
- # stale entry raises NoMethodError in tests, signalling the entry to delete.
9
+ # method: update the one entry. A stale entry, naming a method its class
10
+ # no longer defines with that visibility, fails the instrumentation spec
11
+ # that checks every row.
11
12
  #
12
- # Requires opentelemetry-api at runtime; falls back to a no-op if absent.
13
+ # Uses OpenTelemetry once it is loaded (setup_sdk! loads it through
14
+ # opentelemetry-sdk); otherwise spans go to a no-op tracer.
13
15
  # Users opt in to real tracing by adding opentelemetry-sdk and
14
16
  # opentelemetry-exporter-otlp to their site Gemfile and exporting:
15
17
  #
@@ -18,6 +20,17 @@ module Jekyll
18
20
  module Instrumentation
19
21
  TRACER_NAME = 'jekyll-link-decorator'
20
22
 
23
+ # Secrets in a URL, each with its replacement: the user:password@
24
+ # part, after a scheme or at the start of the endpoint, and the query
25
+ # string. The OTLP exporter's invalid-url error echoes the whole
26
+ # endpoint, so setup_sdk! removes them before logging. The user:password
27
+ # match is greedy up to the last @ before the path, so a password
28
+ # containing @ is removed whole.
29
+ URL_SECRETS = [
30
+ [%r{(\A|//|\s)[^\s/]*@}, '\1[REDACTED]@'],
31
+ [/\?[^\s#]*/, '?[REDACTED]']
32
+ ].freeze
33
+
21
34
  # Central instrumentation registry.
22
35
  #
23
36
  # Each row: [class_name, method_type, method_name, span_name, attribute_proc]
@@ -29,38 +42,76 @@ module Jekyll
29
42
  #
30
43
  # attribute_proc: ->(span, this, args, result) or nil
31
44
  # span — OTel span (or NoopSpan); call span.set_attribute after super returns
32
- # this — receiver object (instance or nil for class methods)
45
+ # this — receiver: the instance, or the class/module for :class rows
33
46
  # args — positional args array as passed to the method
34
47
  # result — return value of the original method
48
+ # An error raised by the proc is recorded on the span, which is marked
49
+ # as failed; it is never re-raised.
35
50
  # :nocov: — attribute procs are configuration data; coverage comes from integration tests
36
51
  TRACED_METHODS = [
37
- # decorate_html is the converter's entry point — one span per page converted
38
- ['Jekyll::Converters::LinkDecorator', :instance, :decorate_html, 'link.decorate_page',
52
+ # The post_convert hook: one root span per page or document Jekyll
53
+ # converts, naming the page and, in link.decorated, whether it went
54
+ # through the decorator. A decoration failure leaves link.decorated
55
+ # true; the error shows on link.html_render instead.
56
+ ['Jekyll::LinkDecorator::PostConvertHook', :class, :call, 'link.page_decorate',
57
+ lambda { |span, _this, args, result|
58
+ doc = args[0]
59
+ span.set_attribute('link.page_path', doc.relative_path.to_s)
60
+ span.set_attribute('link.page_url', doc.url.to_s)
61
+ span.set_attribute('link.decorated', result == true)
62
+ }],
63
+
64
+ # decorate_html is the Decorator's entry point — a child of
65
+ # link.page_decorate for each decorated page, and a root span for
66
+ # each decorate_links filter call
67
+ ['Jekyll::LinkDecorator::Decorator', :instance, :decorate_html, 'link.html_decorate',
39
68
  lambda { |span, _this, args, result|
40
69
  span.set_attribute('link.html_size_bytes', args[0].to_s.bytesize)
41
70
  span.set_attribute('link.output_size_bytes', result.to_s.bytesize)
42
71
  }],
43
72
 
44
- ['Jekyll::Converters::LinkDecorator', :instance, :apply_link_styles, 'link.style_apply',
73
+ ['Jekyll::LinkDecorator::Decorator', :instance, :apply_link_styles, 'link.style_apply',
45
74
  lambda { |span, _this, args, _result|
46
75
  doc = args[0]
47
- span.set_attribute('link.styled_link_count', doc.respond_to?(:css) ? doc.css('a:not(.btn)').size : 0)
76
+ span.set_attribute('link.styled_link_count',
77
+ doc.respond_to?(:css) ? doc.css(Decorator::DECORATED_LINKS).size : 0)
48
78
  }],
49
79
 
50
- ['Jekyll::Converters::LinkDecorator', :instance, :add_external_link_features,
51
- 'link.external_link_features',
52
- lambda { |span, _this, args, _result|
80
+ ['Jekyll::LinkDecorator::Decorator', :instance, :add_external_link_features, 'link.external_link_apply',
81
+ # link.external_link_count: the links the phase treats as
82
+ # cross-domain, which get target and the icon. Authors may set
83
+ # target="_blank" on their own links too, so the rel pass protects
84
+ # more: link.new_tab_link_count counts those, with or without a
85
+ # site url.
86
+ lambda { |span, this, args, _result|
53
87
  doc = args[0]
88
+ site_domain = this.extract_site_domain
89
+ links = doc.respond_to?(:css) ? this.external_link_candidates(doc, site_domain) : []
54
90
  span.set_attribute('link.external_link_count',
55
- doc.respond_to?(:css) ? doc.css('a[target="_blank"]').size : 0)
91
+ links.count { |link| this.cross_domain_external_link?(link, site_domain) })
92
+ new_tab = doc.respond_to?(:css) ? doc.css(Decorator::DECORATED_LINKS) : []
93
+ span.set_attribute('link.new_tab_link_count',
94
+ new_tab.count { |link| this.new_tab_link?(link) })
56
95
  }],
57
96
 
58
- # add_heading_anchors is private on LinkDecorator; visibility is preserved on the wrapper
59
- ['Jekyll::Converters::LinkDecorator', :private, :add_heading_anchors, 'link.heading_anchors', nil]
97
+ # render is decorate_html's body, outside its rescue, so this span
98
+ # records failures in settings, parsing, or any phase
99
+ ['Jekyll::LinkDecorator::Decorator', :private, :render, 'link.html_render', nil],
100
+
101
+ # add_heading_anchors is private on Decorator; visibility is preserved on the wrapper.
102
+ # Counts the headings carrying an anchor after the phase, including
103
+ # any the author wrote, which the phase leaves as they are; with
104
+ # heading anchors off the phase adds none.
105
+ ['Jekyll::LinkDecorator::Decorator', :private, :add_heading_anchors, 'link.heading_anchor_add',
106
+ lambda { |span, this, args, _result|
107
+ doc = args[0]
108
+ headings = doc.respond_to?(:css) ? doc.css(Decorator::HEADINGS) : []
109
+ span.set_attribute('link.heading_anchor_count', headings.count { |heading| this.anchored_heading?(heading) })
110
+ }]
60
111
  ].freeze
61
112
  # :nocov:
62
113
 
63
- # Returns the active OTel tracer, or a no-op tracer if opentelemetry-api is absent.
114
+ # Returns the active OTel tracer, or a no-op tracer if OpenTelemetry is not loaded.
64
115
  def self.tracer
65
116
  @tracer ||=
66
117
  if defined?(OpenTelemetry)
@@ -90,10 +141,10 @@ module Jekyll
90
141
 
91
142
  # Returns true when OTel is requested via standard environment variables.
92
143
  #
93
- # install! guards on this so the prepend wrappers are only applied when
94
- # a real exporter is configured. In CI and local tests (no OTel env vars)
95
- # business logic classes are untouched, keeping allow_any_instance_of stubs
96
- # and other RSpec mechanics fully functional.
144
+ # The plugin calls install! at load time only when this returns true,
145
+ # so without OTel env vars (as in CI and local tests) business logic
146
+ # classes stay unwrapped, keeping allow_any_instance_of stubs and other
147
+ # RSpec mechanics fully functional.
97
148
  def self.enabled?
98
149
  ENV.key?('OTEL_EXPORTER_OTLP_ENDPOINT') || ENV.key?('OTEL_SERVICE_NAME')
99
150
  end
@@ -117,16 +168,21 @@ module Jekyll
117
168
  #
118
169
  # Called from install! so that spans are exported to the configured backend
119
170
  # without requiring sites to add a separate initializer. Silently no-ops when
120
- # opentelemetry-sdk or opentelemetry-exporter-otlp is not installed.
171
+ # opentelemetry-sdk or opentelemetry-exporter-otlp is not installed. Any
172
+ # other setup error, such as the exporter rejecting a malformed
173
+ # OTEL_EXPORTER_OTLP_* variable, logs one warning, with any URL
174
+ # secrets redacted (URL_SECRETS), and leaves spans unexported:
175
+ # install! runs at plugin load, so raising would fail the build.
121
176
  #
122
177
  # SimpleSpanProcessor is used instead of the SDK default (BatchSpanProcessor)
123
178
  # because Jekyll is a short-lived CLI process. BatchSpanProcessor exports on a
124
- # 5-second schedule; outer spans that close last (decorate_page) are silently
179
+ # 5-second schedule; outer spans that close last (link.page_decorate) are silently
125
180
  # dropped when the process exits before the next flush. Simple exports each
126
181
  # span synchronously the moment it closes, guaranteeing no span loss.
127
182
  #
128
183
  # jekyll-theme-centos loads several instrumented plugins in one process
129
- # (jekyll-l10n uses the 'l10n.' tracer, jekyll-minify uses 'minify.'), and
184
+ # (jekyll-l10n and jekyll-minify each use their own tracer, with 'l10n.'
185
+ # and 'minify.' span names), and
130
186
  # OpenTelemetry::SDK.configure unconditionally overwrites the global
131
187
  # tracer_provider on every call — so only the first plugin to reach this
132
188
  # method (see sdk_already_configured?) actually configures the SDK; every
@@ -148,6 +204,15 @@ module Jekyll
148
204
  rescue LoadError
149
205
  nil
150
206
  # :nocov:
207
+ rescue StandardError => e
208
+ warn_sdk_setup_failed(e)
209
+ end
210
+
211
+ # Logs why setup_sdk! failed, with any URL secrets redacted.
212
+ def self.warn_sdk_setup_failed(error)
213
+ reason = URL_SECRETS.reduce(error.message) { |message, (pattern, mask)| message.gsub(pattern, mask) }
214
+ Jekyll.logger.warn 'LinkDecorator:', "OpenTelemetry SDK setup failed, spans will not be exported: #{reason}"
215
+ nil
151
216
  end
152
217
 
153
218
  # True when some plugin (this one or another) already installed a real
@@ -157,7 +222,7 @@ module Jekyll
157
222
  OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
158
223
  end
159
224
 
160
- # Resolves a dot-separated class name to a constant; returns nil on NameError.
225
+ # Resolves a ::-separated class name to a constant; returns nil on NameError.
161
226
  def self.resolve_class(name)
162
227
  name.split('::').reduce(Object) { |m, c| m.const_get(c) }
163
228
  rescue NameError
@@ -174,17 +239,26 @@ module Jekyll
174
239
  end
175
240
 
176
241
  # Builds an anonymous module that wraps each listed method in a span.
242
+ #
243
+ # An error raised by the method propagates unchanged. The wrapper does
244
+ # not record it: opentelemetry-api's Tracer#in_span already records it
245
+ # and sets the span's error status, so recording here too would add a
246
+ # second exception event.
177
247
  def self.build_wrapper_module(entries)
178
248
  Module.new do
179
249
  entries.each do |_class_name, method_type, method_name, span_name, attr_proc|
180
250
  define_method(method_name) do |*args, **kwargs, &blk|
181
251
  Instrumentation.instrument(span_name) do |span|
182
252
  result = super(*args, **kwargs, &blk)
183
- attr_proc&.call(span, self, args, result)
253
+ begin
254
+ attr_proc&.call(span, self, args, result)
255
+ rescue StandardError => e
256
+ # Tracing must never fail a build: keep the result, report the bug on the span.
257
+ # in_span never sees this error, so mark the span failed here.
258
+ span.record_exception(e)
259
+ span.status = OpenTelemetry::Trace::Status.error(e.message) if defined?(OpenTelemetry::Trace::Status)
260
+ end
184
261
  result
185
- rescue StandardError => e
186
- span.record_exception(e)
187
- raise
188
262
  end
189
263
  end
190
264
  private method_name if method_type == :private
@@ -192,7 +266,8 @@ module Jekyll
192
266
  end
193
267
  end
194
268
 
195
- private_class_method :build_wrapper_module, :prepend_wrappers, :setup_sdk!, :sdk_already_configured?
269
+ private_class_method :build_wrapper_module, :prepend_wrappers, :setup_sdk!, :warn_sdk_setup_failed,
270
+ :sdk_already_configured?
196
271
 
197
272
  # No-op tracer used when opentelemetry-api is not loaded.
198
273
  class NoopTracer
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module LinkDecorator
5
+ # Decorates each page and document right after Jekyll converts it and
6
+ # before layouts apply, which is where :post_convert fires. Only content
7
+ # the stock Markdown converter handled is decorated, so every extension
8
+ # listed in the site's markdown_ext qualifies, not just `.md`.
9
+ module PostConvertHook
10
+ # Returns true when it handed doc to the decorator, false when it left
11
+ # doc alone (not Markdown, or the plugin is disabled). The trace span
12
+ # records it as link.decorated. true does not mean the page came out
13
+ # decorated: decorate_html never raises, so a failure leaves doc as it
14
+ # was, logs a LinkDecorator Error:, and shows as an error on the
15
+ # link.html_render span.
16
+ def self.call(doc)
17
+ return false unless markdown?(doc) && Configuration.for(doc.site.config)['enabled']
18
+
19
+ doc.content = Decorator.new(doc.site.config).decorate_html(doc.content)
20
+ true
21
+ end
22
+
23
+ def self.markdown?(doc)
24
+ doc.site.find_converter_instance(Jekyll::Converters::Markdown).matches(doc.extname)
25
+ end
26
+ private_class_method :markdown?
27
+ end
28
+ end
29
+ end
30
+
31
+ Jekyll::Hooks.register(%i[pages documents], :post_convert) { |doc| Jekyll::LinkDecorator::PostConvertHook.call(doc) }
@@ -3,9 +3,10 @@
3
3
  module Jekyll
4
4
  module LinkDecorator
5
5
  # Autogenerated by jekyll-link-decorator.gemspec.rb — do not edit by hand.
6
- # Regenerated alongside the gemspec on every `make gemspec` run (locally
7
- # or in CI), so it always matches spec.version in the built gem. Any
8
- # hand edit is overwritten on the next run.
9
- VERSION = '1.7.0'
6
+ # Regenerated alongside the gemspec on every
7
+ # `make jekyll-link-decorator.gemspec` run (locally or in CI), so it
8
+ # always matches spec.version in the built gem. Any hand edit is
9
+ # overwritten on the next run.
10
+ VERSION = '2.0.0'
10
11
  end
11
12
  end
@@ -6,334 +6,34 @@
6
6
  # default classes; links carrying a `.btn` class are left untouched.
7
7
  #
8
8
  # For links to a different domain than the site's own (site.url), it adds
9
- # target="_blank", the rel="noopener noreferrer" security attributes, and an
10
- # external-link icon at the end of the link text.
9
+ # target="_blank" and an external-link icon at the end of the link text.
10
+ # Every link that opens in a new tab, including one the author marked
11
+ # target="_blank", gets rel="noopener noreferrer".
11
12
  #
12
- # It can also inject a permalink icon into each heading (h1-h6) so every
13
+ # It also injects a permalink icon into each heading (h1-h6) so every
13
14
  # section gets a shareable URL.
14
15
  #
15
- # Turn these on and customize them from `_config.yml` with the
16
- # `with_link_decorator`, `with_link_decorator_data`, `with_heading_anchor`,
17
- # and `with_heading_anchor_data` keys. See docs/configuration.md for the
18
- # full key reference, defaults, and worked `_config.yml` examples.
16
+ # Every feature is on by default. Turn features off or customize them from
17
+ # `_config.yml` under the `jekyll_link_decorator` key (heading anchors
18
+ # under its nested `heading_anchor` key). See docs/configuration.md for
19
+ # the full key reference, defaults, and worked `_config.yml` examples.
19
20
  #
20
21
  # A link that already carries its own Bootstrap link-utility class (see
21
- # LINK_CLASS_FAMILIES below) keeps that class instead of getting the
22
- # plugin's default for the same family.
22
+ # LINK_CLASS_FAMILIES in jekyll-link-decorator/decorator.rb) keeps that
23
+ # class instead of getting the plugin's default for the same family.
24
+ #
25
+ # Pages and documents are decorated by a :post_convert hook (see
26
+ # jekyll-link-decorator/post_convert_hook.rb) after Jekyll's own Markdown
27
+ # conversion; the decorate_links Liquid filter below covers HTML built
28
+ # elsewhere, such as markdownified data fields.
23
29
 
24
- require 'nokogiri'
25
30
  require_relative 'jekyll-link-decorator/version'
26
31
  require_relative 'jekyll-link-decorator/instrumentation'
27
-
28
- EXISTING_ICON_SELECTORS = [
29
- 'i[class*="fa-external-link"]',
30
- 'i[class*="up-right-from-square"]',
31
- 'svg[data-icon="external-link"]',
32
- 'svg[data-icon="up-right-from-square"]',
33
- 'svg[data-icon="arrow-up-right-from-square"]'
34
- ].freeze
32
+ require_relative 'jekyll-link-decorator/configuration'
33
+ require_relative 'jekyll-link-decorator/decorator'
34
+ require_relative 'jekyll-link-decorator/post_convert_hook'
35
35
 
36
36
  module Jekyll
37
- module Converters
38
- # Applies CSS classes to <a> tags based on context (e.g., inside an alert
39
- # box) via Nokogiri CSS selectors, excluding buttons. Also adds
40
- # external-link icons for cross-domain links.
41
- class LinkDecorator < Jekyll::Converters::Markdown
42
- DEFAULT_LINK_CLASSES = 'link link-offset-3 link-offset-3-hover link-underline-primary ' \
43
- 'link-underline-opacity-0 link-underline-opacity-100-hover focus-ring'
44
- DEFAULT_ALERT_LINK_CLASSES = 'alert-link focus-ring'
45
-
46
- # Bootstrap 5 link-utility "families" whose defaults must not silently
47
- # override an author's own explicit class from the same family — see
48
- # classes_to_add_for.
49
- #
50
- # Each entry carries a single `pattern:` regex, anchored \A...\z and
51
- # matched per class token (not against the whole class string), so a
52
- # compound name like "social-link-danger" or "sidebar-link-offset-2" is
53
- # never mistaken for an override.
54
- #
55
- # Enumerated families (offset, both opacity families) lock to Bootstrap's
56
- # fixed value set, so an out-of-range value like "link-opacity-42" isn't
57
- # recognized as an override either. Color and focus-ring families
58
- # (link-{color}, link-underline-{color}, focus-ring(-{color})?) stay
59
- # generic (\S+) since Bootstrap themes can define arbitrary custom color names.
60
- LINK_CLASS_FAMILIES = [
61
- { # link-{color}, e.g. link-primary, link-body-emphasis (bare colored-link class;
62
- # excludes link-underline-*, link-offset-*, and link-opacity-* families)
63
- pattern: /\Alink-(?!underline\b|offset-|opacity-)\S+\z/
64
- },
65
- { # link-underline-{color}, e.g. link-underline-success (excludes -opacity-*)
66
- pattern: /\Alink-underline-(?!opacity)\S+\z/
67
- },
68
- { # link-offset-{1,2,3}, each with an optional -hover variant
69
- pattern: /\Alink-offset-[123](-hover)?\z/
70
- },
71
- { # link-underline-opacity-{0,10,25,50,75,100}, each with an optional -hover variant
72
- pattern: /\Alink-underline-opacity-(?:0|10|25|50|75|100)(-hover)?\z/
73
- },
74
- { # link-opacity-{10,25,50,75,100}, each with an optional -hover variant
75
- # (controls link-text opacity, distinct from link-underline-opacity-*)
76
- pattern: /\Alink-opacity-(?:10|25|50|75|100)(-hover)?\z/
77
- },
78
- { # focus-ring and focus-ring-{color}, e.g. focus-ring-primary, focus-ring-success
79
- # Bootstrap 5.3 focus-ring has no hover, offset, or opacity variants — only bare
80
- # and color forms; color stays generic (\S+) for custom theme colors
81
- pattern: /\Afocus-ring(-\S+)?\z/
82
- }
83
- ].freeze
84
- DEFAULT_EXTERNAL_LINK_ICON = true
85
- DEFAULT_EXTERNAL_LINK_ICON_EXCLUDED_TAGS = [].freeze
86
- DEFAULT_HEADING_ANCHOR = false
87
- DEFAULT_HEADING_ANCHOR_ICON = 'heading-anchor-hash'
88
-
89
- def self.name
90
- 'LinkDecorator'
91
- end
92
-
93
- # Returns a URL's domain, stripped of scheme and path.
94
- # "https://example.com/path?query=1#anchor" -> "example.com"
95
- def extract_domain(url)
96
- return '' if url.nil? || url.empty?
97
-
98
- domain = url.sub(%r{^https?://}, '')
99
- domain = domain.split(%r{[/?#]}).first
100
- domain.downcase
101
- end
102
-
103
- # True when this link needs the external-link icon: an absolute,
104
- # cross-domain link that doesn't already have one.
105
- def is_external_different_domain_link?(link, site_domain)
106
- return false unless absolute_different_domain?(link, site_domain)
107
- return false if EXISTING_ICON_SELECTORS.any? { |sel| link.css(sel).any? }
108
-
109
- true
110
- end
111
-
112
- # True when href is an absolute http(s) URL on a domain other than
113
- # site_domain. Shared by is_external_different_domain_link? and
114
- # cross_domain_external_link? below.
115
- def absolute_different_domain?(link, site_domain)
116
- href = link['href'].to_s
117
- return false unless %r{^https?://}.match?(href)
118
-
119
- link_domain = extract_domain(href)
120
- return false if link_domain == site_domain || link_domain.empty?
121
-
122
- true
123
- end
124
- private :absolute_different_domain?
125
-
126
- # True when this link needs target="_blank": absolute and cross-domain.
127
- # Relative URLs (/, ../) are always treated as internal.
128
- def cross_domain_external_link?(link, site_domain)
129
- href = link['href'].to_s
130
- return false if %r{^(/|\.\.)}.match?(href)
131
-
132
- absolute_different_domain?(link, site_domain)
133
- end
134
-
135
- # Sets target="_blank" on a cross-domain link, unless it already has a target.
136
- def add_external_target_blank(link, site_domain)
137
- return if link['target'].to_s.strip != ''
138
- return unless cross_domain_external_link?(link, site_domain)
139
-
140
- link['target'] = '_blank'
141
- end
142
-
143
- # Adds rel="noopener noreferrer" to a target="_blank" link, keeping any
144
- # rel values the author already set.
145
- def add_external_target_protection(link)
146
- target = link['target'].to_s
147
- return unless target == '_blank'
148
-
149
- existing_rel = link['rel'].to_s.strip
150
- rel_values = existing_rel.empty? ? [] : existing_rel.split(/\s+/)
151
- rel_values << 'noopener' unless rel_values.include?('noopener')
152
- rel_values << 'noreferrer' unless rel_values.include?('noreferrer')
153
-
154
- link['rel'] = rel_values.join(' ')
155
- end
156
-
157
- def matches(ext)
158
- ext =~ /^\.md$/i
159
- end
160
-
161
- def output_ext(_ext)
162
- '.html'
163
- end
164
-
165
- def convert(content)
166
- html = super
167
- decorate_html(html)
168
- end
169
-
170
- def decorate_html(html)
171
- enabled = @config.key?('with_link_decorator') ? @config['with_link_decorator'] : true
172
- config = @config['with_link_decorator_data'] || {}
173
-
174
- return html unless plugin_enabled?(enabled)
175
-
176
- doc = Nokogiri::HTML::DocumentFragment.parse(html)
177
-
178
- apply_link_styles(doc, config)
179
- add_external_link_features(doc, config)
180
- add_heading_anchors(doc)
181
-
182
- doc.to_html
183
- rescue StandardError => e
184
- Jekyll.logger.error 'LinkDecorator Error:',
185
- "An error occurred during link decoration: #{e.message}"
186
- Jekyll.logger.error 'Backtrace:', e.backtrace.join("\n")
187
-
188
- html
189
- end
190
-
191
- # Logs a notice when disabled; returns the given enabled flag unchanged.
192
- # The caller (decorate_html) decides what "enabled" defaults to.
193
- def plugin_enabled?(enabled)
194
- unless enabled
195
- Jekyll.logger.info 'LinkDecorator:',
196
- 'Disabled via configuration. Skipping custom link modification.'
197
- end
198
- enabled
199
- end
200
-
201
- # Applies alert_classes to links inside an .alert box, default_classes
202
- # to every other link.
203
- def apply_link_styles(doc, config)
204
- default_classes = config.fetch('default_link_classes', DEFAULT_LINK_CLASSES)
205
- alert_classes = config.fetch('alert_link_classes', DEFAULT_ALERT_LINK_CLASSES)
206
-
207
- doc.css('p.alert a:not(.btn), blockquote.alert a:not(.btn)').each do |link|
208
- add_classes(link, alert_classes)
209
- end
210
-
211
- doc.css('a:not(.btn)').each do |link|
212
- next if link.ancestors('p.alert, blockquote.alert').any?
213
-
214
- add_classes(link, classes_to_add_for(link, default_classes))
215
- end
216
- end
217
-
218
- # Drops any default class belonging to a family the link already customizes,
219
- # so the author's explicit class wins instead of losing silently to a
220
- # same-specificity Bootstrap !important rule declared later in the sheet.
221
- # See LINK_CLASS_FAMILIES for how each family's pattern is matched.
222
- def classes_to_add_for(link, default_classes)
223
- link_classes = link['class'].to_s.split
224
- LINK_CLASS_FAMILIES.reduce(default_classes) do |classes, family|
225
- next classes unless link_classes.any? { |token| token.match?(family[:pattern]) }
226
-
227
- classes.split.grep_v(family[:pattern]).join(' ')
228
- end
229
- end
230
-
231
- def add_classes(link, classes_string)
232
- classes_string.split.each { |cls| link.add_class(cls) }
233
- end
234
-
235
- # Adds target="_blank", the external-link icon, and rel security
236
- # attributes to every link on the page, in that order.
237
- def add_external_link_features(doc, config)
238
- site_domain = extract_site_domain
239
- return if site_domain.empty?
240
-
241
- doc.css('a:not(.btn)').each do |link|
242
- add_external_target_blank(link, site_domain)
243
- add_external_icon(link, site_domain, config, doc)
244
- add_external_target_protection(link)
245
- end
246
- end
247
-
248
- def extract_site_domain
249
- site_url = @config['url'] || @config['baseurl'] || ''
250
- extract_domain(site_url)
251
- end
252
-
253
- # Injects a permalink anchor into each heading (h1-h6). The anchor's
254
- # glyph defaults to a plain "#"; with_heading_anchor_data can swap it
255
- # for a Font Awesome icon or any other class list.
256
- def add_heading_anchors(doc)
257
- enabled = @config.key?('with_heading_anchor') ? @config['with_heading_anchor'] : DEFAULT_HEADING_ANCHOR
258
- return unless enabled
259
-
260
- ha_data = @config['with_heading_anchor_data'] || {}
261
- icon_class = ha_data.fetch('icon', DEFAULT_HEADING_ANCHOR_ICON)
262
- icon_size = ha_data['icon_size']
263
- full_icon = [icon_class, icon_size].compact.join(' ')
264
- id_counts = {}
265
-
266
- doc.css('h1, h2, h3, h4, h5, h6').each do |heading|
267
- next if heading.css('.heading-anchor').any?
268
-
269
- id = resolve_heading_id(heading, id_counts)
270
-
271
- anchor = Nokogiri::XML::Node.new('a', doc)
272
- anchor['class'] = 'heading-anchor p-1 mx-1 focus-ring'
273
- anchor['href'] = "##{id}"
274
- anchor['data-copy-anchor'] = "##{id}"
275
- anchor['aria-label'] = "Link to #{heading.text.strip}"
276
-
277
- icon = Nokogiri::XML::Node.new('i', doc)
278
- icon['class'] = full_icon
279
- icon['aria-hidden'] = 'true'
280
- anchor.add_child(icon)
281
-
282
- heading.add_child(anchor)
283
- end
284
- end
285
-
286
- # Returns the heading's existing id, or generates one from its text and
287
- # assigns it. id_counts tracks slugs already used, so repeated headings
288
- # get unique "-1", "-2", ... suffixes.
289
- def resolve_heading_id(heading, id_counts)
290
- existing = heading['id'].to_s.strip
291
- return existing unless existing.empty?
292
-
293
- base = slugify(heading.text)
294
- n = id_counts[base].to_i
295
- id_counts[base] = n + 1
296
- final_id = n.zero? ? base : "#{base}-#{n}"
297
- heading['id'] = final_id
298
- final_id
299
- end
300
-
301
- # Converts heading text to a URL-safe slug, loosely mirroring the slug
302
- # rules in jekyll-theme-centos-base's link.html.liquid (same characters
303
- # stripped and replaced), plus collapsing repeated/edge hyphens so
304
- # headings with unusual punctuation still get a clean id.
305
- def slugify(text)
306
- text.strip
307
- .downcase
308
- .tr(' ', '-')
309
- .delete('"\'?&#/')
310
- .gsub(/-{2,}/, '-')
311
- .gsub(/\A-|-\z/, '')
312
- end
313
-
314
- private :add_heading_anchors, :resolve_heading_id, :slugify
315
-
316
- # Appends the external-link icon to a cross-domain link, unless the
317
- # icon is disabled, the link already has an icon-bearing tag excluded
318
- # via external_link_icon_excluded_tags, or one is already present.
319
- def add_external_icon(link, site_domain, config, doc)
320
- external_link_icon = config.fetch('external_link_icon', DEFAULT_EXTERNAL_LINK_ICON)
321
-
322
- return unless external_link_icon
323
-
324
- exclude_tags = config.fetch('external_link_icon_excluded_tags', DEFAULT_EXTERNAL_LINK_ICON_EXCLUDED_TAGS)
325
- return if exclude_tags.any? { |tag| link.css(tag).any? }
326
-
327
- return unless is_external_different_domain_link?(link, site_domain)
328
-
329
- icon = Nokogiri::XML::Node.new('i', doc)
330
- icon['class'] = 'fa-solid fa-external-link ms-1'
331
- icon['aria-hidden'] = 'true'
332
- link.add_child(icon)
333
- end
334
- end
335
- end
336
-
337
37
  module Filters
338
38
  # Liquid filter that applies link decoration to already-converted HTML.
339
39
  # Use after the markdownify filter to decorate links in YAML data fields:
@@ -341,8 +41,7 @@ module Jekyll
341
41
  module LinkDecoratorFilter
342
42
  def decorate_links(html)
343
43
  site = @context.registers[:site]
344
- decorator = Jekyll::Converters::LinkDecorator.new(site.config)
345
- decorator.decorate_html(html)
44
+ Jekyll::LinkDecorator::Decorator.new(site.config).decorate_html(html)
346
45
  end
347
46
  end
348
47
  end