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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff4ff4aab91fea08f0f9d818a35287da8ad337181e028a2a2da62245866b3420
4
- data.tar.gz: 81737c19fc41c5134c6741b0b6ebfb28746ca376f591c46a95a16631d56cfebf
3
+ metadata.gz: 4bce3e5f4ec8d1f6c44a4e75243379e739253c84658dd9b059aa158ef35db38e
4
+ data.tar.gz: 3f32795f52745553508b8e24355bb2a478f66e1ae1d09c9e84b6f0819f57885f
5
5
  SHA512:
6
- metadata.gz: e912b0fbdc82c9640912f96b5a4f436d378e79fd4f9099eaba218759d7dc6ca688285b6785aa34163d89c749e153b9e5de37623277e66c16f892abe23fe3be02
7
- data.tar.gz: 0ba21a73e60ad550d74415a22f06b2ada6bf83d8bef37521e778fcb546ca39bdb0695bdeefe1fb53c6fe65003608c2725f8446f3943f69664e105c4f0d704880
6
+ metadata.gz: 9b3487b5cc2e11eb15344e1ac14e90c1ef0c66b8087c0f056bfac275c645defcd6b8ebae83bc48d458652ffccefdfaaebfd9e4a2ddf2210c12596f70741ffcd1
7
+ data.tar.gz: a1728c3bea0a49441f358147c66c6e6fa2115f61783918b04e91e541d4ec5f3684ad4814af200af27d16a9a3ad012c7c9cfb06014097937773bed4c5eca12394
data/README.md CHANGED
@@ -11,6 +11,10 @@ intervention.
11
11
  For complete documentation, configuration options, and examples, visit the
12
12
  [documentation site](https://centos.gitlab.io/artwork/centos-web/jekyll-link-decorator/).
13
13
 
14
+ ## Release notes
15
+
16
+ See [GitLab Releases](https://gitlab.com/CentOS/artwork/centos-web/jekyll-link-decorator/-/releases).
17
+
14
18
  ## License
15
19
 
16
20
  MIT License - see [LICENSE](LICENSE) file for details.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'did_you_mean'
4
+
5
+ module Jekyll
6
+ module LinkDecorator
7
+ module Configuration
8
+ # The type of each setting under CONFIG_KEY, and how a value of the
9
+ # wrong type is converted or replaced by its default, with a warning.
10
+ # Configuration.resolve applies it after merging the site's settings.
11
+ module Schema
12
+ # The type of every leaf setting, by key path under CONFIG_KEY.
13
+ TYPES = {
14
+ %w[enabled] => :boolean,
15
+ %w[default_link_classes] => :class_list,
16
+ %w[alert_link_classes] => :class_list,
17
+ %w[external_link_icon] => :boolean,
18
+ %w[external_link_icon_excluded_tags] => :tag_list,
19
+ %w[heading_anchor enabled] => :boolean,
20
+ %w[heading_anchor icon] => :class_list,
21
+ %w[heading_anchor icon_size] => :class_list
22
+ }.freeze
23
+
24
+ # Per type: what the warning says to expect, which values pass as is,
25
+ # and how a near-miss converts (returns [converted], or nil when the
26
+ # value can't be converted and the default applies instead).
27
+ COERCERS = {
28
+ # "true"/"false" in any case, surrounding spaces ignored
29
+ boolean: {
30
+ expected: 'true or false',
31
+ accept: ->(value) { [true, false].include?(value) },
32
+ convert: lambda { |value|
33
+ word = value.strip.downcase if value.is_a?(String)
34
+ [word == 'true'] if %w[true false].include?(word)
35
+ }
36
+ },
37
+ # An empty string means no classes; nil means unset
38
+ class_list: {
39
+ expected: 'a space-separated string of classes',
40
+ accept: ->(value) { value.nil? || value.is_a?(String) },
41
+ convert: ->(value) { [value.join(' ')] if value.is_a?(Array) && value.all?(String) }
42
+ },
43
+ # A single string is not split on spaces: each entry is a CSS
44
+ # selector, so "a img" is one entry
45
+ tag_list: {
46
+ expected: 'a list of tag names',
47
+ accept: ->(value) { value.is_a?(Array) && value.all?(String) },
48
+ convert: ->(value) { [[value]] if value.is_a?(String) }
49
+ }
50
+ }.freeze
51
+
52
+ # Returns value if its type accepts it; otherwise its conversion,
53
+ # with a warning; otherwise the default, with a warning.
54
+ def self.coerce(value, path, type)
55
+ coercer = COERCERS.fetch(type)
56
+ return value if coercer[:accept].call(value)
57
+
58
+ key_path = [CONFIG_KEY, *path].join('.')
59
+ converted = coercer[:convert].call(value)
60
+ if converted
61
+ warn_invalid(key_path, value, coercer[:expected], "reading it as #{converted.first.inspect}")
62
+ return converted.first
63
+ end
64
+
65
+ warn_invalid(key_path, value, coercer[:expected])
66
+ DEFAULTS.dig(*path)
67
+ end
68
+
69
+ # Site-level keys removed in 2.0.0, mapped to where their settings
70
+ # live now.
71
+ LEGACY_KEYS = {
72
+ 'with_link_decorator' => "#{CONFIG_KEY}.enabled",
73
+ 'with_link_decorator_data' => "the keys directly under #{CONFIG_KEY}",
74
+ 'with_heading_anchor' => "#{CONFIG_KEY}.heading_anchor.enabled",
75
+ 'with_heading_anchor_data' => "the keys under #{CONFIG_KEY}.heading_anchor"
76
+ }.freeze
77
+
78
+ # Warns once per LEGACY_KEYS entry present in the site config, even
79
+ # with an empty value. The old keys stay ignored.
80
+ def self.warn_legacy_keys(site_config)
81
+ (LEGACY_KEYS.keys & site_config.keys).each do |old_key|
82
+ Jekyll.logger.warn 'LinkDecorator:',
83
+ "#{old_key} was removed in 2.0.0 and is ignored; " \
84
+ "use #{LEGACY_KEYS[old_key]} instead."
85
+ end
86
+ end
87
+
88
+ # Returns a copy of settings (the user's hash at `parents` under
89
+ # CONFIG_KEY) without the keys TYPES doesn't know, recursing into known
90
+ # nested mappings such as heading_anchor. Warns once per dropped key.
91
+ def self.without_unknown_keys(settings, parents = [])
92
+ known = known_keys(parents)
93
+ settings.each_with_object({}) do |(key, value), kept|
94
+ unless known.include?(key)
95
+ warn_unknown([CONFIG_KEY, *parents, key].join('.'), key, known)
96
+ next
97
+ end
98
+
99
+ nested = value.is_a?(Hash) && !known_keys([*parents, key]).empty?
100
+ kept[key] = nested ? without_unknown_keys(value, [*parents, key]) : value
101
+ end
102
+ end
103
+
104
+ # The keys allowed directly under `parents`, derived from TYPES' paths.
105
+ def self.known_keys(parents)
106
+ depth = parents.size
107
+ TYPES.keys.select { |path| path.size > depth && path.take(depth) == parents }
108
+ .map { |path| path[depth] }.uniq
109
+ end
110
+
111
+ def self.warn_unknown(key_path, key, known)
112
+ suggestion = DidYouMean::SpellChecker.new(dictionary: known).correct(key.to_s).first
113
+ hint = suggestion ? "; did you mean `#{suggestion}`?" : '.'
114
+ Jekyll.logger.warn 'LinkDecorator:', "#{key_path} is not a known setting and is ignored#{hint}"
115
+ end
116
+
117
+ # One warning per invalid value per build: resolve runs once per build
118
+ # (see Configuration.for), so this never repeats page by page.
119
+ def self.warn_invalid(key_path, value, expected, outcome = 'using the default')
120
+ Jekyll.logger.warn 'LinkDecorator:',
121
+ "#{key_path} should be #{expected}, got #{value.inspect}; #{outcome}."
122
+ end
123
+ private_class_method :known_keys, :warn_unknown
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module LinkDecorator
5
+ # Resolves the plugin's settings: the `jekyll_link_decorator` hash from
6
+ # _config.yml, deep-merged over DEFAULTS so every key has a value.
7
+ module Configuration
8
+ CONFIG_KEY = 'jekyll_link_decorator'
9
+ DISABLED_NOTICE = 'Disabled via configuration. Skipping custom link modification.'
10
+
11
+ DEFAULT_LINK_CLASSES = 'link link-offset-2 link-offset-2-hover link-underline ' \
12
+ 'link-underline-opacity-0 link-underline-opacity-100-hover focus-ring'
13
+ DEFAULT_ALERT_LINK_CLASSES = 'alert-link focus-ring'
14
+ DEFAULT_EXTERNAL_LINK_ICON = true
15
+ DEFAULT_EXTERNAL_LINK_ICON_EXCLUDED_TAGS = [].freeze
16
+ DEFAULT_HEADING_ANCHOR = true
17
+ DEFAULT_HEADING_ANCHOR_ICON = 'heading-anchor-hash'
18
+
19
+ DEFAULTS = {
20
+ 'enabled' => true,
21
+ 'default_link_classes' => DEFAULT_LINK_CLASSES,
22
+ 'alert_link_classes' => DEFAULT_ALERT_LINK_CLASSES,
23
+ 'external_link_icon' => DEFAULT_EXTERNAL_LINK_ICON,
24
+ 'external_link_icon_excluded_tags' => DEFAULT_EXTERNAL_LINK_ICON_EXCLUDED_TAGS,
25
+ 'heading_anchor' => {
26
+ 'enabled' => DEFAULT_HEADING_ANCHOR,
27
+ 'icon' => DEFAULT_HEADING_ANCHOR_ICON
28
+ }.freeze
29
+ }.freeze
30
+
31
+ # Returns a deep-frozen copy of DEFAULTS with the site's own
32
+ # jekyll_link_decorator settings merged over it. Memoized per
33
+ # site_config object, so a build resolves its settings once rather
34
+ # than once per page.
35
+ def self.for(site_config)
36
+ cache[site_config] ||= resolve(site_config)
37
+ end
38
+
39
+ # Drops every memoized result. Registered on :site, :after_reset below,
40
+ # so each build (including each `jekyll serve --watch` rebuild) starts
41
+ # from the site config as it is then.
42
+ def self.reset!
43
+ @cache = nil
44
+ end
45
+
46
+ # Keyed on object identity, and holds its results strongly: a weak map
47
+ # would let GC drop them mid-build and repeat every warning. reset!
48
+ # clears it at the start of each build, so it never outgrows one build.
49
+ def self.cache
50
+ @cache ||= {}.compare_by_identity
51
+ end
52
+
53
+ def self.resolve(site_config)
54
+ Schema.warn_legacy_keys(site_config)
55
+ user = Schema.without_unknown_keys(with_heading_anchor_mapping(user_settings(site_config[CONFIG_KEY])))
56
+ merged = Jekyll::Utils.deep_merge_hashes(DEFAULTS, user)
57
+ Schema::TYPES.each { |path, type| rewrite(merged, path) { |value| Schema.coerce(value, path, type) } }
58
+ # Here rather than in the Decorator, so a disabled build says so once.
59
+ Jekyll.logger.info 'LinkDecorator:', DISABLED_NOTICE unless merged['enabled']
60
+ frozen_copy(merged)
61
+ end
62
+
63
+ # Runs before the merge, because deep_merge_hashes would otherwise
64
+ # replace the whole heading_anchor defaults hash with a scalar. Accepts
65
+ # a bare true/false as a supported shorthand for `enabled`, like the
66
+ # top-level key does; warns about and drops any other non-mapping so the
67
+ # defaults apply. Returns a new hash; `user` is left untouched.
68
+ def self.with_heading_anchor_mapping(user)
69
+ value = user['heading_anchor']
70
+ return user if value.nil? || value.is_a?(Hash)
71
+ return user.merge('heading_anchor' => { 'enabled' => value }) if [true, false].include?(value)
72
+
73
+ Schema.warn_invalid("#{CONFIG_KEY}.heading_anchor", value, 'a mapping of settings')
74
+ user.except('heading_anchor')
75
+ end
76
+
77
+ # Replaces the value at path with the block's result, unless the key is
78
+ # absent. `merged` is safe to mutate: deep_merge_hashes returns fresh
79
+ # hashes at every level it merges.
80
+ def self.rewrite(merged, path)
81
+ *parents, key = path
82
+ parent = parents.empty? ? merged : merged.dig(*parents)
83
+ return unless parent.is_a?(Hash) && parent.key?(key)
84
+
85
+ parent[key] = yield(parent[key])
86
+ end
87
+
88
+ # Accepts the top-level key as a mapping, or as a bare true/false
89
+ # shorthand for `enabled`. Null means unset; anything else is warned
90
+ # about and ignored.
91
+ def self.user_settings(value)
92
+ case value
93
+ when Hash then value
94
+ when true, false then { 'enabled' => value }
95
+ when nil then {}
96
+ else
97
+ Schema.warn_invalid(CONFIG_KEY, value, 'a mapping of settings, or true/false')
98
+ {}
99
+ end
100
+ end
101
+
102
+ # Freezes copies, never the originals, so values shared by reference
103
+ # with site.config are left mutable for Jekyll and other plugins.
104
+ def self.frozen_copy(value)
105
+ case value
106
+ when Hash then value.to_h { |key, val| [key, frozen_copy(val)] }.freeze
107
+ when Array then value.map { |val| frozen_copy(val) }.freeze
108
+ when String then -value
109
+ else value
110
+ end
111
+ end
112
+ private_class_method :cache, :resolve, :user_settings, :with_heading_anchor_mapping, :rewrite,
113
+ :frozen_copy
114
+ end
115
+ end
116
+ end
117
+
118
+ # Schema's constants read CONFIG_KEY, defined above.
119
+ require_relative 'configuration/schema'
120
+
121
+ Jekyll::Hooks.register(:site, :after_reset) { Jekyll::LinkDecorator::Configuration.reset! }
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'set'
5
+
6
+ module Jekyll
7
+ module LinkDecorator
8
+ # Decorates already-converted HTML: applies Bootstrap classes to <a> tags
9
+ # based on context (e.g., inside an alert box) via Nokogiri CSS selectors,
10
+ # excluding buttons; adds target and an external-link icon to
11
+ # cross-domain links, and rel to every link that opens in a new tab; and
12
+ # injects heading anchors. Built from the site config; both the
13
+ # post_convert hook and the decorate_links filter use it.
14
+ class Decorator
15
+ # An absolute http(s) URL, or a protocol-relative one ("//host/path"),
16
+ # which names a host just the same. Schemes are case-insensitive, and
17
+ # matching starts at the beginning of the string, not of any line.
18
+ ABSOLUTE_URL = %r{\A(?:https?:)?//}i
19
+
20
+ # The links the plugin decorates. Buttons are left alone: their look
21
+ # and behavior belong to the author's own .btn styling.
22
+ DECORATED_LINKS = 'a:not(.btn)'
23
+
24
+ # The headings that get a permalink anchor.
25
+ HEADINGS = 'h1, h2, h3, h4, h5, h6'
26
+
27
+ EXISTING_ICON_SELECTORS = [
28
+ 'i[class*="fa-external-link"]',
29
+ 'i[class*="up-right-from-square"]',
30
+ 'svg[data-icon="external-link"]',
31
+ 'svg[data-icon="up-right-from-square"]',
32
+ 'svg[data-icon="arrow-up-right-from-square"]'
33
+ ].freeze
34
+
35
+ # Bootstrap 5 link-utility "families" whose defaults must not silently
36
+ # override an author's own explicit class from the same family — see
37
+ # classes_to_add_for.
38
+ #
39
+ # Each entry carries a single `pattern:` regex, anchored \A...\z and
40
+ # matched per class token (not against the whole class string), so a
41
+ # compound name like "social-link-danger" or "sidebar-link-offset-2" is
42
+ # never mistaken for an override.
43
+ #
44
+ # Enumerated families (offset, both opacity families) lock to Bootstrap's
45
+ # fixed value set, so an out-of-range value like "link-opacity-42" isn't
46
+ # recognized as an override either. Color and focus-ring families
47
+ # (link-{color}, link-underline-{color}, focus-ring(-{color})?) stay
48
+ # generic (\S+) since Bootstrap themes can define arbitrary custom color names.
49
+ LINK_CLASS_FAMILIES = [
50
+ { # link-{color}, e.g. link-primary, link-body-emphasis (bare colored-link class;
51
+ # excludes link-underline-*, link-offset-*, and link-opacity-* families)
52
+ pattern: /\Alink-(?!underline\b|offset-|opacity-)\S+\z/
53
+ },
54
+ { # link-underline and link-underline-{color}, e.g. link-underline-success
55
+ # (excludes -opacity-*); the bare class takes the link's own text color
56
+ pattern: /\Alink-underline(?:-(?!opacity)\S+)?\z/
57
+ },
58
+ { # link-offset-{1,2,3}, each with an optional -hover variant
59
+ pattern: /\Alink-offset-[123](-hover)?\z/
60
+ },
61
+ { # link-underline-opacity-{0,10,25,50,75,100}, each with an optional -hover variant
62
+ pattern: /\Alink-underline-opacity-(?:0|10|25|50|75|100)(-hover)?\z/
63
+ },
64
+ { # link-opacity-{10,25,50,75,100}, each with an optional -hover variant
65
+ # (controls link-text opacity, distinct from link-underline-opacity-*)
66
+ pattern: /\Alink-opacity-(?:10|25|50|75|100)(-hover)?\z/
67
+ },
68
+ { # focus-ring and focus-ring-{color}, e.g. focus-ring-primary, focus-ring-success
69
+ # Bootstrap 5.3 focus-ring has no hover, offset, or opacity variants — only bare
70
+ # and color forms; color stays generic (\S+) for custom theme colors
71
+ pattern: /\Afocus-ring(-\S+)?\z/
72
+ }
73
+ ].freeze
74
+
75
+ # site_config is the whole site config (site.config); the plugin's own
76
+ # settings are resolved from it by plugin_config.
77
+ def initialize(site_config)
78
+ @config = site_config
79
+ end
80
+
81
+ # Entry point for the post_convert hook and the decorate_links filter.
82
+ # Never raises: on any error it logs and returns html unchanged.
83
+ def decorate_html(html)
84
+ render(html)
85
+ rescue StandardError => e
86
+ Jekyll.logger.error 'LinkDecorator Error:',
87
+ "An error occurred during link decoration: #{e.message}"
88
+ Jekyll.logger.error 'Backtrace:', e.backtrace.join("\n")
89
+
90
+ html
91
+ end
92
+
93
+ # Resolves settings, parses html, and runs every phase. Raises on
94
+ # error; kept apart from decorate_html's rescue so a trace span around
95
+ # it records the failure.
96
+ def render(html)
97
+ config = plugin_config
98
+ return html unless config['enabled']
99
+
100
+ doc = Nokogiri::HTML::DocumentFragment.parse(html)
101
+
102
+ apply_link_styles(doc, config)
103
+ add_external_link_features(doc, config)
104
+ add_heading_anchors(doc, config)
105
+
106
+ doc.to_html
107
+ end
108
+ private :render
109
+
110
+ # The plugin's resolved settings: the `jekyll_link_decorator` hash from
111
+ # _config.yml merged over Configuration::DEFAULTS.
112
+ def plugin_config
113
+ Jekyll::LinkDecorator::Configuration.for(@config)
114
+ end
115
+
116
+ # Phase 1: link styling.
117
+
118
+ # Applies alert_classes to links inside a p.alert or blockquote.alert,
119
+ # and default_classes to every other link.
120
+ def apply_link_styles(doc, config)
121
+ default_classes = config['default_link_classes']
122
+ alert_classes = config['alert_link_classes']
123
+
124
+ # One search finds every alert link. Checking each link's ancestors
125
+ # instead would search the whole document once per link.
126
+ alert_links = doc.css("p.alert #{DECORATED_LINKS}, blockquote.alert #{DECORATED_LINKS}")
127
+ alert_links.each { |link| add_classes(link, alert_classes) }
128
+ alert_link_ids = alert_links.to_set(&:pointer_id)
129
+
130
+ doc.css(DECORATED_LINKS).each do |link|
131
+ next if alert_link_ids.include?(link.pointer_id)
132
+
133
+ add_classes(link, classes_to_add_for(link, default_classes))
134
+ end
135
+ end
136
+
137
+ # Drops any default class belonging to a family the link already customizes,
138
+ # so the author's explicit class wins instead of losing silently to a
139
+ # same-specificity Bootstrap !important rule declared later in the sheet.
140
+ # See LINK_CLASS_FAMILIES for how each family's pattern is matched.
141
+ def classes_to_add_for(link, default_classes)
142
+ link_classes = link['class'].to_s.split
143
+ LINK_CLASS_FAMILIES.reduce(default_classes) do |classes, family|
144
+ next classes unless link_classes.any? { |token| token.match?(family[:pattern]) }
145
+
146
+ classes.split.grep_v(family[:pattern]).join(' ')
147
+ end
148
+ end
149
+
150
+ def add_classes(link, classes_string)
151
+ classes_string.split.each { |cls| link.add_class(cls) }
152
+ end
153
+
154
+ # Phase 2: external-link features.
155
+
156
+ # Adds target="_blank" and the external-link icon to cross-domain
157
+ # links, then rel security attributes to every non-button link that
158
+ # opens in a new tab. rel needs no site domain, so a target="_blank"
159
+ # the author set is protected even when the site has no url.
160
+ def add_external_link_features(doc, config)
161
+ site_domain = extract_site_domain
162
+ external_link_candidates(doc, site_domain).each do |link|
163
+ add_external_target_blank(link, site_domain)
164
+ add_external_icon(link, site_domain, config, doc)
165
+ end
166
+ doc.css(DECORATED_LINKS).each { |link| add_external_target_protection(link) }
167
+ end
168
+
169
+ # The links add_external_link_features checks for target and icon:
170
+ # every non-button link, or none when the site has no url to compare
171
+ # them against. The link.external_link_count attribute counts from
172
+ # the same set.
173
+ def external_link_candidates(doc, site_domain)
174
+ return [] if site_domain.empty?
175
+
176
+ doc.css(DECORATED_LINKS)
177
+ end
178
+
179
+ # The site's own domain, from site.url. baseurl is a path, so it never
180
+ # says which links are cross-domain.
181
+ def extract_site_domain
182
+ extract_domain(@config['url'].to_s)
183
+ end
184
+
185
+ # Returns a URL's domain, stripped of scheme and path.
186
+ # "https://example.com/path?query=1#anchor" -> "example.com"
187
+ # A URL with no host, such as a bare "https://", gives "". Surrounding
188
+ # whitespace is ignored, as browsers ignore it.
189
+ def extract_domain(url)
190
+ domain = url.to_s.strip.sub(ABSOLUTE_URL, '')
191
+ domain.split(%r{[/?#]}).first.to_s.downcase
192
+ end
193
+
194
+ # Sets target="_blank" on a cross-domain link, unless it already has a target.
195
+ def add_external_target_blank(link, site_domain)
196
+ return if link['target'].to_s.strip != ''
197
+ return unless cross_domain_external_link?(link, site_domain)
198
+
199
+ link['target'] = '_blank'
200
+ end
201
+
202
+ # Appends the external-link icon to a cross-domain link. Skips it when
203
+ # the icon is disabled, when the link contains a tag listed in
204
+ # external_link_icon_excluded_tags, or when the link already has an
205
+ # icon (EXISTING_ICON_SELECTORS).
206
+ def add_external_icon(link, site_domain, config, doc)
207
+ return unless config['external_link_icon']
208
+ return if config['external_link_icon_excluded_tags'].any? { |tag| link.css(tag).any? }
209
+
210
+ return unless is_external_different_domain_link?(link, site_domain)
211
+
212
+ icon = Nokogiri::XML::Node.new('i', doc)
213
+ icon['class'] = 'fa-solid fa-external-link ms-1'
214
+ icon['aria-hidden'] = 'true'
215
+ link.add_child(icon)
216
+ end
217
+
218
+ # True when the link opens in a new tab. HTML matches the _blank
219
+ # keyword case-insensitively, so target="_BLANK" counts too.
220
+ def new_tab_link?(link)
221
+ link['target'].to_s.casecmp?('_blank')
222
+ end
223
+
224
+ # Adds rel="noopener noreferrer" to a new_tab_link?, keeping any rel
225
+ # values the author already set.
226
+ def add_external_target_protection(link)
227
+ return unless new_tab_link?(link)
228
+
229
+ existing_rel = link['rel'].to_s.strip
230
+ rel_values = existing_rel.empty? ? [] : existing_rel.split(/\s+/)
231
+ rel_values << 'noopener' unless rel_values.include?('noopener')
232
+ rel_values << 'noreferrer' unless rel_values.include?('noreferrer')
233
+
234
+ link['rel'] = rel_values.join(' ')
235
+ end
236
+
237
+ # True when href is an absolute http(s) or protocol-relative URL (see
238
+ # ABSOLUTE_URL) on a domain other than site_domain; such a link gets
239
+ # target="_blank". Relative URLs are always internal.
240
+ def cross_domain_external_link?(link, site_domain)
241
+ href = link['href'].to_s.strip
242
+ return false unless ABSOLUTE_URL.match?(href)
243
+
244
+ link_domain = extract_domain(href)
245
+ !link_domain.empty? && link_domain != site_domain
246
+ end
247
+
248
+ # True when this link needs the external-link icon: cross-domain and
249
+ # without an icon already.
250
+ def is_external_different_domain_link?(link, site_domain)
251
+ return false unless cross_domain_external_link?(link, site_domain)
252
+
253
+ EXISTING_ICON_SELECTORS.none? { |sel| link.css(sel).any? }
254
+ end
255
+
256
+ # Phase 3: heading anchors.
257
+
258
+ # Injects a permalink anchor into each heading (h1-h6). The anchor's
259
+ # icon classes come from jekyll_link_decorator.heading_anchor.icon, plus
260
+ # icon_size when set. The default class draws a plain "#" through
261
+ # jekyll-theme-centos-base's CSS; swap it for a Font Awesome icon or
262
+ # any other class list.
263
+ def add_heading_anchors(doc, config)
264
+ ha_data = config['heading_anchor']
265
+ return unless ha_data['enabled']
266
+
267
+ icon_classes = [ha_data['icon'], ha_data['icon_size']].compact.join(' ')
268
+ taken_ids = ids_in_use(doc)
269
+
270
+ doc.css(HEADINGS).each do |heading|
271
+ next if anchored_heading?(heading)
272
+
273
+ id = resolve_heading_id(heading, taken_ids)
274
+ heading.add_child(build_heading_anchor(doc, heading, id, icon_classes))
275
+ end
276
+ end
277
+ private :add_heading_anchors
278
+
279
+ # True when the heading already carries a permalink anchor, whether
280
+ # this phase or the author put it there.
281
+ def anchored_heading?(heading)
282
+ heading.css('.heading-anchor').any?
283
+ end
284
+
285
+ # Every id already on the page, so generated heading ids avoid them.
286
+ def ids_in_use(doc)
287
+ doc.css('[id]').to_set { |node| node['id'].strip }
288
+ end
289
+ private :ids_in_use
290
+
291
+ # Builds the heading's permalink: an <a> pointing at #id, labelled with
292
+ # the heading text for screen readers, wrapping the icon.
293
+ def build_heading_anchor(doc, heading, id, icon_classes)
294
+ anchor = Nokogiri::XML::Node.new('a', doc)
295
+ anchor['class'] = 'heading-anchor p-1 mx-1 focus-ring'
296
+ anchor['href'] = "##{id}"
297
+ anchor['data-copy-anchor'] = "##{id}"
298
+ anchor['aria-label'] = "Link to #{heading.text.strip}"
299
+ anchor.add_child(build_heading_anchor_icon(doc, icon_classes))
300
+ anchor
301
+ end
302
+ private :build_heading_anchor
303
+
304
+ # The anchor's decorative glyph, hidden from screen readers.
305
+ def build_heading_anchor_icon(doc, icon_classes)
306
+ icon = Nokogiri::XML::Node.new('i', doc)
307
+ icon['class'] = icon_classes
308
+ icon['aria-hidden'] = 'true'
309
+ icon
310
+ end
311
+ private :build_heading_anchor_icon
312
+
313
+ # Returns the heading's existing id, trimmed and written back so the
314
+ # anchor's href matches it, or generates one from its text, assigns
315
+ # it, and adds it to taken_ids. taken_ids starts with every id on the
316
+ # page, so a generated id gets the first free "-1", "-2", ... suffix
317
+ # instead of repeating one. Text with no slug falls back to "section",
318
+ # as kramdown's auto ids do.
319
+ def resolve_heading_id(heading, taken_ids)
320
+ existing = heading['id'].to_s.strip
321
+ return heading['id'] = existing unless existing.empty?
322
+
323
+ base = slugify(heading.text)
324
+ base = 'section' if base.empty?
325
+ id = base
326
+ n = 0
327
+ id = "#{base}-#{n += 1}" while taken_ids.include?(id)
328
+ taken_ids << id
329
+ heading['id'] = id
330
+ end
331
+ private :resolve_heading_id
332
+
333
+ # Converts heading text to a URL-safe slug, loosely mirroring the slug
334
+ # rules in jekyll-theme-centos-base's link.html.liquid (same characters
335
+ # stripped and replaced), plus collapsing repeated/edge hyphens so
336
+ # headings with unusual punctuation still get a clean id.
337
+ def slugify(text)
338
+ text.strip
339
+ .downcase
340
+ .tr(' ', '-')
341
+ .delete('"\'?&#/')
342
+ .gsub(/-{2,}/, '-')
343
+ .gsub(/\A-|-\z/, '')
344
+ end
345
+ private :slugify
346
+ end
347
+ end
348
+ end