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.
- checksums.yaml +4 -4
- data/README.md +4 -0
- data/lib/jekyll-link-decorator/configuration/schema.rb +127 -0
- data/lib/jekyll-link-decorator/configuration.rb +121 -0
- data/lib/jekyll-link-decorator/decorator.rb +348 -0
- data/lib/jekyll-link-decorator/instrumentation.rb +103 -28
- data/lib/jekyll-link-decorator/post_convert_hook.rb +31 -0
- data/lib/jekyll-link-decorator/version.rb +5 -4
- data/lib/jekyll-link-decorator.rb +19 -320
- metadata +5 -155
|
@@ -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.
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
38
|
-
|
|
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::
|
|
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',
|
|
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::
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
59
|
-
|
|
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
|
|
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
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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!, :
|
|
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
|
|
7
|
-
# or in CI), so it
|
|
8
|
-
#
|
|
9
|
-
|
|
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"
|
|
10
|
-
#
|
|
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
|
|
13
|
+
# It also injects a permalink icon into each heading (h1-h6) so every
|
|
13
14
|
# section gets a shareable URL.
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
# `
|
|
17
|
-
#
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
345
|
-
decorator.decorate_html(html)
|
|
44
|
+
Jekyll::LinkDecorator::Decorator.new(site.config).decorate_html(html)
|
|
346
45
|
end
|
|
347
46
|
end
|
|
348
47
|
end
|