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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4bce3e5f4ec8d1f6c44a4e75243379e739253c84658dd9b059aa158ef35db38e
|
|
4
|
+
data.tar.gz: 3f32795f52745553508b8e24355bb2a478f66e1ae1d09c9e84b6f0819f57885f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|