jekyll-highlight-cards 2.0.0 → 2.1.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: 14f9f3f5b5374e35f95524afc77c6edfc8a09469154924fa17859089b5df71c8
4
- data.tar.gz: 896055006ef9a9ebe42e992c621ce0e666c487f8166be65b208ca7f173ef955d
3
+ metadata.gz: 2e7d4bb8027142ccb1dbf7e0327098460eb49fda5f10fa2619efeb9053fa1359
4
+ data.tar.gz: 9971cb5b3a34a9bfbfaf06539e973df20c5951fb5bac39649378c4909a03a759
5
5
  SHA512:
6
- metadata.gz: 1fe96dece9fb642150e35571f1b7e8887b1d91236a16ea367d434846b550141dd08780e4532642db2e9103607fd5af70ae484ff85a018e30e678105113c45ca3
7
- data.tar.gz: 224ec98ce539b1f7a00b9b8707b6552638c07ddacb8ff6ff87c988b5bed74c66c9c9bfd67dfa08ddf18ac072518b7418690c6b54a93dc71a83364045dce9d0c4
6
+ metadata.gz: d1183485eba6958f3d328b693207cfbccfd0dcef3da163511332c0ad16b39c30416b8c1bb41e767b9dc8ddee5d761f92ae26bb7b56f18ada1b5d1ef8a5bbfa21
7
+ data.tar.gz: 9d62f2b98e4923cdd73a75719718d92dd40b235dd0c61982e4222bc4a1f525b9593ddef3b10f3485ea56f30ad300e0be6310f8a6ccbfcc6ac50c1448092cd8d4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0](https://github.com/Texarkanine/jekyll-highlight-cards/compare/v2.0.0...v2.1.0) (2026-07-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * **test:** add Mutant mutation testing at 100% coverage ([#49](https://github.com/Texarkanine/jekyll-highlight-cards/issues/49)) ([e4e63dc](https://github.com/Texarkanine/jekyll-highlight-cards/commit/e4e63dc259ee767d0db31cb86e1b03e298f76f0d))
9
+
3
10
  ## [2.0.0](https://github.com/Texarkanine/jekyll-highlight-cards/compare/v1.1.2...v2.0.0) (2026-01-18)
4
11
 
5
12
 
@@ -40,12 +40,14 @@ Gem::Specification.new do |spec|
40
40
 
41
41
  # Development dependencies
42
42
  spec.add_development_dependency "bundler", "~> 2.0"
43
+ spec.add_development_dependency "mutant", "~> 0.16"
44
+ spec.add_development_dependency "mutant-rspec", "~> 0.16"
43
45
  spec.add_development_dependency "rake", "~> 13.0"
44
46
  spec.add_development_dependency "rspec", "~> 3.12"
45
47
  spec.add_development_dependency "rubocop", "~> 1.50"
46
48
  spec.add_development_dependency "rubocop-rake", "~> 0.6"
47
49
  spec.add_development_dependency "rubocop-rspec", "~> 3.8"
48
- spec.add_development_dependency "simplecov", "~> 0.22"
49
- spec.add_development_dependency "simplecov-cobertura", "~> 3.1"
50
+ spec.add_development_dependency "simplecov", "~> 1.0"
51
+ spec.add_development_dependency "simplecov-cobertura", "~> 4.0"
50
52
  spec.add_development_dependency "webmock", "~> 3.18"
51
53
  end
@@ -26,21 +26,12 @@ module JekyllHighlightCards
26
26
  # @return [String, nil] the archive URL, or nil if not found
27
27
  def archive_url_for(url)
28
28
  ArchiveHelper.archive_cache[url] ||= begin
29
- log_info("Looking up archive for #{url}")
30
29
  archive_url = lookup_archive(url)
31
- log_info("Archive URL: #{archive_url || ""}")
32
30
 
33
- if archive_save_enabled?
34
- log_info("Submitting to SavePageNow: #{url}")
35
- archive_url = submit_archive(url) || archive_url
36
- log_info("SavePageNow archived #{url} -> #{archive_url}")
37
- end
31
+ archive_url = submit_archive(url) || archive_url if archive_save_enabled?
38
32
 
39
- archive_url.to_s.empty? ? nil : archive_url
33
+ archive_url
40
34
  end
41
- rescue StandardError => e
42
- log_debug("Archive lookup failed for #{url}: #{e.message}")
43
- nil
44
35
  end
45
36
 
46
37
  # Check if archiving is enabled via environment variables
@@ -72,14 +63,10 @@ module JekyllHighlightCards
72
63
  # @param url [String] the URL to look up
73
64
  # @return [String, nil] the archive URL if found, nil otherwise
74
65
  def lookup_archive(url)
75
- log_debug("lookup_archive(#{url})")
76
-
77
66
  encoded_url = URI.encode_www_form_component(url)
78
67
  cdx_url_str = "https://web.archive.org/cdx/search/cdx?url=#{encoded_url}&output=json&filter=statuscode:200&limit=-1&fl=timestamp,original"
79
68
  cdx_url = URI.parse(cdx_url_str)
80
69
 
81
- log_debug("CDX lookup URL: #{cdx_url_str}")
82
-
83
70
  response = Net::HTTP.start(
84
71
  cdx_url.host,
85
72
  cdx_url.port,
@@ -90,25 +77,15 @@ module JekyllHighlightCards
90
77
  http.request(Net::HTTP::Get.new(cdx_url.request_uri))
91
78
  end
92
79
 
93
- unless response.is_a?(Net::HTTPSuccess)
94
- log_debug("CDX lookup failed: #{response.code} #{response.message}")
95
- return nil
96
- end
80
+ return nil unless response.is_a?(Net::HTTPSuccess)
97
81
 
98
- log_debug("CDX lookup found archived page...")
99
82
  rows = JSON.parse(response.body)
100
83
 
101
- # First row is header, so we need at least 2 rows
102
- return nil if rows.length <= 1
103
-
104
- latest = rows.last
105
- timestamp = latest[0]
106
- archive_url = "https://web.archive.org/web/#{timestamp}/#{url}"
84
+ return nil if rows.size < 2
107
85
 
108
- log_debug("CDX lookup found archived page: #{archive_url}")
109
- archive_url
110
- rescue StandardError => e
111
- log_debug("CDX lookup error for #{url}: #{e.message}")
86
+ timestamp, = rows.last
87
+ "https://web.archive.org/web/#{timestamp}/#{url}"
88
+ rescue StandardError
112
89
  nil
113
90
  end
114
91
 
@@ -117,35 +94,29 @@ module JekyllHighlightCards
117
94
  # @param url [String] the URL to submit for archiving
118
95
  # @return [String, nil] the archive URL if successful, nil otherwise
119
96
  def submit_archive(url)
120
- log_debug("submit_archive(#{url})")
121
-
122
97
  encoded_url = URI.encode_www_form_component(url)
123
98
  save_url = URI.parse("https://web.archive.org/save/#{encoded_url}")
124
99
 
125
- response = Net::HTTP.start(
126
- save_url.host,
127
- save_url.port,
128
- use_ssl: save_url.scheme == "https",
129
- open_timeout: 10,
130
- read_timeout: 30
131
- ) do |http|
132
- req = Net::HTTP::Get.new(save_url.request_uri, { "User-Agent" => archive_user_agent })
133
- http.request(req)
100
+ response = begin
101
+ Net::HTTP.start(
102
+ save_url.host,
103
+ save_url.port,
104
+ use_ssl: save_url.scheme == "https",
105
+ open_timeout: 10,
106
+ read_timeout: 30
107
+ ) do |http|
108
+ req = Net::HTTP::Get.new(save_url.request_uri, { "User-Agent" => archive_user_agent })
109
+ http.request(req)
110
+ end
111
+ rescue StandardError
112
+ nil
134
113
  end
114
+ return nil if response.nil?
135
115
 
136
116
  location = response["content-location"]
117
+ return nil if location.to_s.empty?
137
118
 
138
- if location && !location.empty?
139
- archive_url = "https://web.archive.org#{location}"
140
- log_info("SavePageNow archived #{url} -> #{archive_url}")
141
- archive_url
142
- else
143
- log_debug("Archive submission returned no location for #{url}")
144
- nil
145
- end
146
- rescue StandardError => e
147
- log_debug("Archive submission error for #{url}: #{e.message}")
148
- nil
119
+ "https://web.archive.org#{location}"
149
120
  end
150
121
  end
151
122
  end
@@ -11,8 +11,7 @@ module JekyllHighlightCards
11
11
  # - Units: Supports px, em, %, etc. (e.g., "400pxx300px")
12
12
  #
13
13
  # @example
14
- # include DimensionParser
15
- # width, height = parse_dimensions("300x200") #=> ["300", "200"]
14
+ # width, height = DimensionParser.parse_dimensions("300x200") #=> ["300", "200"]
16
15
  module DimensionParser
17
16
  # Parse dimension string into width and height components
18
17
  #
@@ -25,7 +24,7 @@ module JekyllHighlightCards
25
24
  # parse_dimensions("x200") #=> [nil, "200"]
26
25
  # parse_dimensions("300") #=> ["300", nil]
27
26
  # parse_dimensions("400px") #=> ["400px", nil]
28
- def parse_dimensions(dim_str)
27
+ def self.parse_dimensions(dim_str)
29
28
  return [nil, nil] if dim_str.nil? || dim_str.empty?
30
29
 
31
30
  # Determine the separator:
@@ -34,29 +33,22 @@ module JekyllHighlightCards
34
33
  # - Otherwise, check if there's an 'x' that's a separator (not part of a unit like "px")
35
34
  # An 'x' is a separator if it's at the end, followed by a digit, or at the start
36
35
  if dim_str.include?("xx")
37
- # Find the position of "xx"
38
36
  idx = dim_str.index("xx")
39
- # Split at the second 'x' (include first 'x' in width, skip both 'x's for height)
40
- width = dim_str[0..idx].empty? ? nil : dim_str[0..idx]
37
+ width = dim_str[...(idx + 1)]
41
38
  height = dim_str[(idx + 2)..]
42
- height = nil if height.nil? || height.empty?
39
+ height = nil if height.empty?
43
40
  [width, height]
44
- elsif dim_str =~ /x\d/ || dim_str =~ /(?<![a-z])x$/i || dim_str.start_with?("x")
45
- # Single 'x' separator in one of these cases:
46
- # 1. Followed by a digit: "300x200", "10emx20em"
47
- # 2. At end but NOT preceded by a letter: "300x"
48
- # 3. At start: "x200"
49
- # This matches "300x", "300x200", "x200", "10emx20em", but NOT "400px"
50
- parts = dim_str.split("x", 2)
51
- width = parts[0].empty? ? nil : parts[0]
52
- height = parts[1].nil? || parts[1].empty? ? nil : parts[1]
41
+ elsif dim_str.match?(/x\d|(?<![a-z])x\z/) || dim_str.start_with?("x")
42
+ width_str, height_str = dim_str.split("x", 2)
43
+ width = width_str
44
+ width = nil if width.empty?
45
+ height = height_str
46
+ height = nil if height.empty?
53
47
  [width, height]
54
48
  else
55
49
  # No separator found - treat as width only
56
50
  [dim_str, nil]
57
51
  end
58
52
  end
59
-
60
- module_function :parse_dimensions
61
53
  end
62
54
  end
@@ -19,14 +19,11 @@ module JekyllHighlightCards
19
19
  # @param allow_nil [Boolean] whether to allow nil results
20
20
  # @return [String, nil] evaluated value or nil if evaluation fails
21
21
  def evaluate_expression(token, context, allow_nil: true)
22
- return nil if token.nil?
23
- return "" if token.empty?
24
-
25
22
  # Strip outer quotes if present
26
23
  stripped = strip_outer_quotes(token)
27
24
 
28
25
  # If the original token was quoted, treat as literal string
29
- if stripped != token
26
+ if !token.nil? && quote_wrapped?(token)
30
27
  return stripped if allow_nil
31
28
 
32
29
  return stripped.empty? ? nil : stripped
@@ -40,20 +37,16 @@ module JekyllHighlightCards
40
37
  result = template.render(context)
41
38
  return result if allow_nil
42
39
 
43
- return result.to_s.empty? ? nil : result
44
- rescue Liquid::SyntaxError, StandardError => e
45
- log_debug("Failed to evaluate '#{token}' as Liquid expression: #{e.message}")
46
- # Fall back to literal string
47
- return token if allow_nil
40
+ return nil if result.empty?
48
41
 
49
- return token.empty? ? nil : token
42
+ result
43
+ rescue Liquid::SyntaxError, StandardError => e
44
+ log_debug("Failed to evaluate '#{token}' as Liquid expression: #{e.class}: #{e}")
45
+ token
50
46
  end
47
+ else
48
+ token
51
49
  end
52
-
53
- # Return as literal string
54
- return token if allow_nil
55
-
56
- token.empty? ? nil : token
57
50
  end
58
51
 
59
52
  # Check if an expression looks like a Liquid variable lookup
@@ -61,7 +54,7 @@ module JekyllHighlightCards
61
54
  # @param expression [String] the expression to check
62
55
  # @return [Boolean] true if expression contains Liquid syntax
63
56
  def variable_lookup?(expression)
64
- return false if expression.nil? || expression.empty?
57
+ return false if expression.nil?
65
58
 
66
59
  expression.include?("{{") || expression.include?("{%")
67
60
  end
@@ -71,17 +64,23 @@ module JekyllHighlightCards
71
64
  # @param value [String] the string to process
72
65
  # @return [String] string with outer quotes removed if present
73
66
  def strip_outer_quotes(value)
74
- return value if value.nil? || value.empty?
75
-
76
- # Check for matching outer quotes
77
- if ((value.start_with?('"') && value.end_with?('"')) ||
78
- (value.start_with?("'") && value.end_with?("'"))) && (value.length > 1)
79
- return value[1..-2]
80
- end
67
+ return nil if value.nil?
68
+ return value[1..-2] if quote_wrapped?(value)
81
69
 
82
70
  value
83
71
  end
84
72
 
73
+ # Whether value has matching outer quotes and more than one character
74
+ #
75
+ # @param value [String] the string to inspect
76
+ # @return [Boolean]
77
+ def quote_wrapped?(value)
78
+ return false unless value.length >= 2
79
+
80
+ (value.start_with?('"') && value.end_with?('"')) ||
81
+ (value.start_with?("'") && value.end_with?("'"))
82
+ end
83
+
85
84
  # Log debug message
86
85
  #
87
86
  # @param message [String] message to log
@@ -21,12 +21,27 @@ module JekyllHighlightCards
21
21
  # @note Images inside code fences and inline code are not processed
22
22
  # @note Sized images are automatically wrapped in <a> tags (if not already)
23
23
  module ImageSizingHooks
24
- extend DimensionParser
25
-
26
24
  # Process document content before rendering
27
25
  # Converts ![alt](src =WxH) to ![alt](src)<!-- IMG_SIZE:W:H -->
28
26
  #
29
27
  # @param document [Jekyll::Document] the document being processed
28
+ SIZED_MARKDOWN_PATTERN = /!\[([^\]]*)\]\(([^)]+)[ \t]+=([^)]+)\)/
29
+ SIZED_IMG_HTML_PATTERN = /(\s*)(<img[ \t]+[^>]*>)\s*<!--\s*IMG_SIZE:([^:]*):([^:]*)\s*-->/
30
+
31
+ # @param prefix [String] HTML preceding an image tag
32
+ # @return [Integer] unclosed anchor tags in +prefix+
33
+ def self.unclosed_anchor_count(prefix)
34
+ prefix.scan("<a ").length + prefix.scan("<a\t").length - prefix.scan("</a>").length
35
+ end
36
+
37
+ def self.markdown_inline_check_position(full_match)
38
+ full_match.begin(0)
39
+ end
40
+
41
+ def self.img_link_prefix(output, match)
42
+ output[0, match.begin(2)]
43
+ end
44
+
30
45
  def self.process_pre_render(document)
31
46
  content = document.content
32
47
  return unless content
@@ -42,23 +57,24 @@ module JekyllHighlightCards
42
57
  end
43
58
 
44
59
  # Process the line to convert sized images
45
- processed_line = line.dup
60
+ processed_line = line
46
61
 
47
62
  # Match ![alt](src =dimensions) pattern
48
63
  # Use gsub with block to check each match
49
- processed_line.gsub!(/!\[([^\]]*)\]\(([^)]+)\s+=([^)]+)\)/) do |match|
50
- match_start = Regexp.last_match.begin(0)
64
+ processed_line.gsub!(SIZED_MARKDOWN_PATTERN) do |match|
65
+ full_match = Regexp.last_match
66
+ match_start = markdown_inline_check_position(full_match)
51
67
 
52
68
  # Skip if inside inline code
53
69
  if in_inline_code?(line, match_start)
54
70
  match
55
71
  else
56
- alt = Regexp.last_match(1)
57
- src = Regexp.last_match(2).strip
58
- size = Regexp.last_match(3).strip
72
+ alt = full_match[1]
73
+ src = full_match[2].strip
74
+ size = full_match[3].strip
59
75
 
60
76
  # Parse dimensions using DimensionParser
61
- width, height = parse_dimensions(size)
77
+ width, height = DimensionParser.parse_dimensions(size)
62
78
 
63
79
  # Build marker comment
64
80
  "![#{alt}](#{src})<!-- IMG_SIZE:#{width}:#{height} -->"
@@ -83,82 +99,63 @@ module JekyllHighlightCards
83
99
  output = output.dup
84
100
 
85
101
  # Match <img><!-- IMG_SIZE:W:H --> patterns
86
- output.gsub!(/(<img\s+[^>]*>)\s*<!--\s*IMG_SIZE:([^:]*):([^:]*)\s*-->/) do
87
- img_tag = Regexp.last_match(1)
88
- width = Regexp.last_match(2).to_s.strip
89
- height = Regexp.last_match(3).to_s.strip
102
+ output.gsub!(SIZED_IMG_HTML_PATTERN) do
103
+ match = Regexp.last_match
104
+ img_tag = match[2]
105
+ width = match[3].strip
106
+ height = match[4].strip
90
107
 
91
- # Build attributes to add
92
108
  attrs = []
93
- attrs << %(width="#{width}") unless width.to_s.empty?
94
- attrs << %(height="#{height}") unless height.to_s.empty?
109
+ attrs << %(width="#{width}") unless width.empty?
110
+ attrs << %(height="#{height}") unless height.empty?
95
111
 
96
- # Add attributes to img tag
97
112
  modified_img = if attrs.any?
98
113
  img_tag.sub("<img", "<img #{attrs.join(" ")}")
99
114
  else
100
115
  img_tag
101
116
  end
102
117
 
103
- # Extract src for auto-linking
104
118
  src = img_tag[/src=["']([^"']+)["']/, 1]
119
+ next modified_img if src.nil?
105
120
 
106
- # Skip auto-linking if src is missing or malformed
107
- next modified_img if src.nil? || src.empty?
121
+ prefix = img_link_prefix(output, match)
108
122
 
109
- # Check if image is already in a link (look back in output)
110
- # Simple heuristic: check if there's an <a> tag before this image without a closing </a>
111
- img_position = Regexp.last_match.begin(0)
112
- prefix = output[0...img_position]
113
-
114
- # Count <a> and </a> tags before this image
115
- open_count = prefix.scan(/<a\s+/).length
116
- close_count = prefix.scan(%r{</a>}).length
117
- already_linked = (open_count > close_count)
123
+ already_linked = unclosed_anchor_count(prefix).positive?
118
124
 
119
125
  # Auto-link if not already linked
120
126
  if already_linked
121
127
  modified_img
122
128
  else
123
- %(<a href="#{CGI.escapeHTML(src)}">#{modified_img}</a>)
129
+ %(<a href="#{CGI.escapeHTML(CGI.unescapeHTML(src))}">#{modified_img}</a>)
124
130
  end
125
131
  end
126
132
 
127
133
  document.output = output
128
134
  end
129
135
 
136
+ def self.fence_count_before(lines, line_idx)
137
+ lines.first(line_idx).count { |line| line.start_with?("```", "~~~") }
138
+ end
139
+
130
140
  # Check if a line is inside a code fence
131
141
  #
132
142
  # @param lines [Array<String>] all lines in the document
133
143
  # @param line_idx [Integer] the current line index
134
144
  # @return [Boolean] true if inside code fence
135
145
  def self.in_code_fence?(lines, line_idx)
136
- # Check for fenced code blocks (backticks or tildes)
137
- fence_count = 0
138
- (0...line_idx).each do |i|
139
- # Match both backtick and tilde fences
140
- fence_count += 1 if lines[i] =~ /^(`{3,}|~{3,})/
141
- end
142
- # Odd count means we're inside a fence
143
- return true if fence_count.odd?
144
-
145
- # Check for indented code blocks
146
- # A line is in an indented code block if there's a contiguous run
147
- # of indented lines (4+ spaces or tab) leading up to it
148
- return false if line_idx.zero?
149
-
150
- # Check if current line and previous lines are indented
151
- idx = line_idx
152
- while idx.positive?
153
- line = lines[idx]
154
- # If line starts with 4+ spaces or tab, it's indented code
155
- break unless line =~ /^( |\t)/
156
-
157
- idx -= 1
158
- end
146
+ return true if fence_count_before(lines, line_idx).odd?
147
+ return false unless line_idx.positive?
159
148
 
160
- # If we found a contiguous run of indented lines reaching current line
161
- idx < line_idx
149
+ lines.slice(line_idx).start_with?(" ", "\t")
150
+ end
151
+
152
+ # Count backtick characters before a position in a line
153
+ #
154
+ # @param line [String] the line of text
155
+ # @param position [Integer] the position in the line
156
+ # @return [Integer] number of backticks before +position+
157
+ def self.backtick_count_before(line, position)
158
+ line[0, position].count("`")
162
159
  end
163
160
 
164
161
  # Check if text position is inside inline code
@@ -167,13 +164,7 @@ module JekyllHighlightCards
167
164
  # @param position [Integer] the position in the line
168
165
  # @return [Boolean] true if inside inline code
169
166
  def self.in_inline_code?(line, position)
170
- # Count backticks before the position
171
- backtick_count = 0
172
- line[0...position].each_char do |char|
173
- backtick_count += 1 if char == "`"
174
- end
175
- # Odd count means we're inside inline code
176
- backtick_count.odd?
167
+ backtick_count_before(line, position).odd?
177
168
  end
178
169
  end
179
170
  end
@@ -26,10 +26,6 @@ module JekyllHighlightCards
26
26
  # @param tag_name [String] the name of the tag
27
27
  # @param markup [String] the tag markup containing parameters
28
28
  # @param tokens [Array] parse tokens (unused)
29
- def initialize(tag_name, markup, tokens)
30
- super
31
- @markup = markup.strip
32
- end
33
29
 
34
30
  # Render the linkcard tag
35
31
  #
@@ -40,7 +36,7 @@ module JekyllHighlightCards
40
36
  parsed = split_markup(@markup)
41
37
 
42
38
  # Resolve URL (required)
43
- url = resolve_url(parsed[:url], context)
39
+ url = resolve_url(parsed.fetch(:url), context)
44
40
  raise ArgumentError, "linkcard tag requires a URL" if url.nil? || url.empty?
45
41
 
46
42
  # Resolve title (optional)
@@ -66,71 +62,60 @@ module JekyllHighlightCards
66
62
  # @param markup [String] the tag markup
67
63
  # @return [Hash] parsed components
68
64
  def split_markup(markup)
69
- # Split by whitespace, keeping quoted strings and Liquid expressions together
70
- # Handles escaped quotes (\") and backslashes (\\) within quoted strings
71
65
  tokens = []
72
66
  current = ""
73
67
  in_quotes = false
74
68
  quote_char = nil
75
- in_liquid = 0 # Track nested Liquid expressions
76
- escaped = false # Track if next character is escaped
69
+ in_liquid = 0
70
+ escaped = false
77
71
 
78
- markup.each_char do |char|
79
- # Handle escape sequences when in quotes
72
+ "#{markup} ".each_char do |char|
80
73
  if escaped
81
- current += char # Add the escaped character directly
74
+ current += char
82
75
  escaped = false
83
76
  next
84
77
  end
85
78
 
86
- # Check for escape character when in quotes
87
79
  if char == "\\" && in_quotes
88
80
  escaped = true
89
- next # Don't add backslash to output, it's just the escape marker
81
+ next
90
82
  end
91
83
 
92
- # Track Liquid expression boundaries
93
84
  if char == "{" && !in_quotes
94
85
  in_liquid += 1
95
86
  current += char
96
- elsif char == "}" && !in_quotes && in_liquid.positive?
87
+ elsif char == "}" && in_liquid.positive?
97
88
  in_liquid -= 1
98
89
  current += char
99
- # Track quote boundaries
100
- elsif ['"', "'"].include?(char) && !in_quotes && in_liquid.zero?
90
+ elsif char == '"' && !in_quotes
101
91
  in_quotes = true
102
92
  quote_char = char
103
93
  current += char
104
- elsif char == quote_char && in_quotes
94
+ elsif char == quote_char
105
95
  in_quotes = false
106
96
  current += char
107
97
  quote_char = nil
108
- # Split on whitespace only if not in quotes or Liquid expression
109
98
  elsif char.match?(/\s/) && !in_quotes && in_liquid.zero?
110
- tokens << current unless current.empty?
99
+ tokens << current
111
100
  current = ""
112
101
  else
113
102
  current += char
114
103
  end
115
104
  end
116
- tokens << current unless current.empty?
105
+ tokens.reject!(&:empty?)
117
106
 
118
- # First token is URL, remaining tokens may be title or archive parameter
119
- result = {
120
- url: tokens.shift,
121
- title: nil,
122
- archive: nil
123
- }
107
+ result = {}
108
+ result[:url] = tokens.shift
124
109
 
125
- # Process remaining tokens
110
+ title_tokens = []
126
111
  tokens.each do |token|
127
112
  if token.start_with?("archive:")
128
- result[:archive] = token.sub(/^archive:/, "")
113
+ result[:archive] = token.delete_prefix("archive:")
129
114
  else
130
- # Accumulate title tokens
131
- result[:title] = result[:title].nil? ? token : "#{result[:title]} #{token}"
115
+ title_tokens << token
132
116
  end
133
117
  end
118
+ result[:title] = title_tokens.join(" ") unless title_tokens.empty?
134
119
 
135
120
  result
136
121
  end
@@ -141,9 +126,7 @@ module JekyllHighlightCards
141
126
  # @param context [Liquid::Context] the Liquid context
142
127
  # @return [String] resolved URL
143
128
  def resolve_url(token, context)
144
- return nil if token.nil? || token.empty?
145
-
146
- evaluate_expression(token, context, allow_nil: false)
129
+ evaluate_expression(token, context)
147
130
  end
148
131
 
149
132
  # Resolve title from source (may be Liquid expression or literal)
@@ -152,9 +135,7 @@ module JekyllHighlightCards
152
135
  # @param context [Liquid::Context] the Liquid context
153
136
  # @return [String, nil] resolved title
154
137
  def resolve_title(source, context)
155
- return nil if source.nil? || source.empty?
156
-
157
- evaluate_expression(source, context, allow_nil: true)
138
+ evaluate_expression(source, context)
158
139
  end
159
140
 
160
141
  # Resolve archive URL (may be explicit, auto-lookup, or opt-out)
@@ -168,12 +149,9 @@ module JekyllHighlightCards
168
149
  return nil if source && source.downcase == "none"
169
150
 
170
151
  # Check for explicit archive URL
171
- return evaluate_expression(source, context, allow_nil: true) if source && !source.empty?
172
-
173
- # Auto-lookup if enabled
174
- return archive_url_for(url) if archive_enabled?
152
+ return evaluate_expression(source, context) if source && !source.empty?
175
153
 
176
- nil
154
+ archive_enabled? && archive_url_for(url)
177
155
  end
178
156
 
179
157
  # Build template variables hash for rendering
@@ -183,19 +161,17 @@ module JekyllHighlightCards
183
161
  # @param archive_url [String, nil] the archive URL
184
162
  # @return [Hash] template variables with raw and escaped versions
185
163
  def build_template_variables(url, title, archive_url)
186
- display_url = strip_protocol(url)
187
- display_url = display_url.sub(%r{/$}, "")
164
+ display_url = strip_protocol(url).delete_suffix("/")
188
165
 
189
- {
190
- "url" => url,
191
- "display_url" => display_url,
166
+ variables = {
192
167
  "title" => title,
193
168
  "archive_url" => archive_url,
194
169
  "escaped_url" => CGI.escapeHTML(url),
195
- "escaped_display_url" => CGI.escapeHTML(display_url),
196
- "escaped_title" => title ? CGI.escapeHTML(title) : nil,
197
- "escaped_archive_url" => archive_url ? CGI.escapeHTML(archive_url) : nil
170
+ "escaped_display_url" => CGI.escapeHTML(display_url)
198
171
  }
172
+ variables["escaped_title"] = CGI.escapeHTML(title) if title
173
+ variables["escaped_archive_url"] = CGI.escapeHTML(archive_url) if archive_url
174
+ variables
199
175
  end
200
176
 
201
177
  # Strip protocol from URL for display
@@ -203,7 +179,7 @@ module JekyllHighlightCards
203
179
  # @param url [String] the URL
204
180
  # @return [String] URL without protocol
205
181
  def strip_protocol(url)
206
- url.sub(%r{^https?://}, "")
182
+ url.delete_prefix("https://").delete_prefix("http://")
207
183
  end
208
184
  end
209
185
  end
@@ -33,53 +33,35 @@ module JekyllHighlightCards
33
33
  # {% polaroid /img.jpg link="https://example.com" image_link="https://other.com" %}
34
34
  class PolaroidTag < Liquid::Tag
35
35
  include ArchiveHelper
36
- include DimensionParser
37
36
  include ExpressionEvaluator
38
37
  include TemplateRenderer
39
38
 
40
- # Initialize the tag
41
- #
42
- # @param tag_name [String] the name of the tag
43
- # @param markup [String] the tag markup containing parameters
44
- # @param tokens [Array] parse tokens (unused)
45
- def initialize(tag_name, markup, tokens)
46
- super
47
- @markup = markup.strip
48
- end
49
-
50
39
  # Render the polaroid tag
51
40
  #
52
41
  # @param context [Liquid::Context] the Liquid rendering context
53
42
  # @return [String] rendered HTML
54
43
  def render(context)
55
- # Parse markup
56
44
  params = parse_markup(@markup, context)
57
45
 
58
- # Validate required image_url
59
- raise ArgumentError, "polaroid tag requires an image URL" if params[:image_url].nil? || params[:image_url].empty?
60
-
61
- # Parse size parameter if present
62
- width, height = parse_dimensions(params[:size]) if params[:size]
46
+ raise ArgumentError, "polaroid tag requires an image URL" if params.fetch(:image_url).empty?
63
47
 
64
- # Determine link URL (defaults to image URL)
65
- # Track if link was explicitly provided
66
- explicit_link = !params[:link].nil? && !params[:link].empty?
67
- link_url = params[:link] || params[:image_url]
48
+ width, height = DimensionParser.parse_dimensions(params[:size])
68
49
 
69
- # Determine image link URL (can be overridden by image_link parameter)
70
- # If image_link is provided, use it; otherwise use the standard link_url
71
- image_link_url = if params[:image_link] && !params[:image_link].empty?
72
- params[:image_link]
73
- else
50
+ explicit_link = !params[:link].to_s.empty?
51
+ link_url = if params[:link].to_s.empty?
52
+ params.fetch(:image_url)
53
+ else
54
+ params.fetch(:link)
55
+ end
56
+ image_link_url = if params[:image_link].to_s.empty?
74
57
  link_url
58
+ else
59
+ params.fetch(:image_link)
75
60
  end
61
+ archive_url = resolve_archive(params[:archive], link_url)
76
62
 
77
- # Resolve archive URL (archives the link URL, not the image)
78
- archive_url = resolve_archive(params[:archive], context, link_url)
79
-
80
- # Build template variables
81
63
  variables = build_template_variables(
82
- params[:image_url],
64
+ params.fetch(:image_url),
83
65
  width,
84
66
  height,
85
67
  params[:title],
@@ -90,11 +72,7 @@ module JekyllHighlightCards
90
72
  archive_url
91
73
  )
92
74
 
93
- # Get site from context
94
- site = context.registers[:site]
95
-
96
- # Render template
97
- render_template(site, "polaroid", variables)
75
+ render_template(context.registers[:site], "polaroid", variables)
98
76
  end
99
77
 
100
78
  private
@@ -105,8 +83,6 @@ module JekyllHighlightCards
105
83
  # @param context [Liquid::Context] the Liquid context
106
84
  # @return [Hash] parsed parameters
107
85
  def parse_markup(markup, context)
108
- # Split by whitespace, keeping quoted strings and Liquid expressions together
109
- # Handles escaped quotes (\") and backslashes (\\) within quoted strings
110
86
  tokens = []
111
87
  current = ""
112
88
  in_quotes = false
@@ -114,82 +90,67 @@ module JekyllHighlightCards
114
90
  in_liquid = 0
115
91
  escaped = false
116
92
 
117
- markup.each_char do |char|
118
- # Handle escape sequences when in quotes
93
+ # Trailing space flushes the final token through the whitespace branch
94
+ "#{markup} ".each_char do |char|
119
95
  if escaped
120
- current += char # Add the escaped character directly
96
+ current += char
121
97
  escaped = false
122
98
  next
123
99
  end
124
100
 
125
- # Check for escape character when in quotes
126
101
  if char == "\\" && in_quotes
127
102
  escaped = true
128
- next # Don't add backslash to output, it's just the escape marker
103
+ next
129
104
  end
130
105
 
131
- # Track Liquid expression boundaries
132
106
  if char == "{" && !in_quotes
133
107
  in_liquid += 1
134
108
  current += char
135
- elsif char == "}" && !in_quotes && in_liquid.positive?
109
+ elsif char == "}" && in_liquid.positive?
136
110
  in_liquid -= 1
137
111
  current += char
138
- # Track quote boundaries
139
- elsif ['"', "'"].include?(char) && !in_quotes && in_liquid.zero?
112
+ elsif ['"', "'"].include?(char) && !in_quotes
140
113
  in_quotes = true
141
114
  quote_char = char
142
115
  current += char
143
- elsif char == quote_char && in_quotes
116
+ elsif char == quote_char
144
117
  in_quotes = false
145
118
  current += char
146
119
  quote_char = nil
147
- # Split on whitespace only if not in quotes or Liquid expression
148
120
  elsif char.match?(/\s/) && !in_quotes && in_liquid.zero?
149
- tokens << current unless current.empty?
121
+ tokens << current
150
122
  current = ""
151
123
  else
152
124
  current += char
153
125
  end
154
126
  end
155
- tokens << current unless current.empty?
156
127
 
157
- # First token is image URL (required)
158
128
  image_url_token = tokens.shift
159
- image_url = evaluate_expression(image_url_token, context, allow_nil: false)
129
+ image_url = evaluate_expression(image_url_token, context)
160
130
 
161
- # Parse remaining tokens as key=value pairs
162
131
  result = { image_url: image_url }
163
132
  tokens.each do |token|
164
- next unless token =~ /^(\w+)=(.+)$/
133
+ next unless token =~ /\A(\w+)=(.+)\z/
165
134
 
166
135
  key = Regexp.last_match(1).to_sym
167
136
  value_token = Regexp.last_match(2)
168
-
169
- # Evaluate value as Liquid expression
170
- result[key] = evaluate_expression(value_token, context, allow_nil: true)
137
+ result[key] = evaluate_expression(value_token, context)
171
138
  end
172
139
 
173
140
  result
174
141
  end
175
142
 
176
- # Resolve archive URL (may be explicit, auto-lookup, or opt-out)
143
+ # Resolve archive URL (may be explicit, auto-lookup, or opt-out).
144
+ # Named archive params are already Liquid-evaluated in parse_markup.
177
145
  #
178
146
  # @param source [String, nil] the archive source
179
- # @param context [Liquid::Context] the Liquid context (unused but kept for consistency)
180
147
  # @param url [String] the target URL to archive
181
148
  # @return [String, nil] resolved archive URL
182
- def resolve_archive(source, context, url)
183
- # Check for explicit opt-out
184
- return nil if source&.downcase == "none"
185
-
186
- # Check for explicit archive URL
187
- return evaluate_expression(source, context, allow_nil: true) if source && !source.empty?
188
-
189
- # Auto-lookup if enabled (archives the link URL, not the image)
190
- return archive_url_for(url) if archive_enabled?
149
+ def resolve_archive(source, url)
150
+ return nil if source && source.downcase == "none"
151
+ return source unless source.to_s.empty?
191
152
 
192
- nil
153
+ archive_enabled? && archive_url_for(url)
193
154
  end
194
155
 
195
156
  # Build template variables hash for rendering
@@ -215,12 +176,12 @@ module JekyllHighlightCards
215
176
  explicit_link,
216
177
  archive_url
217
178
  )
218
- # Only set link_display if link was explicitly provided (not defaulted to image)
219
- link_display = explicit_link && link_url ? strip_protocol(link_url) : nil
179
+ link_display = strip_protocol(link_url) if explicit_link
180
+ escaped_title = CGI.escapeHTML(title.to_s)
181
+ escaped_title = "&nbsp;" if escaped_title.empty?
220
182
 
221
183
  {
222
184
  "image_url" => image_url,
223
- "link_url" => link_url,
224
185
  "image_link_url" => image_link_url,
225
186
  "title" => title,
226
187
  "alt" => alt,
@@ -229,12 +190,12 @@ module JekyllHighlightCards
229
190
  "width" => width,
230
191
  "height" => height,
231
192
  "escaped_image_url" => CGI.escapeHTML(image_url),
232
- "escaped_link_url" => link_url ? CGI.escapeHTML(link_url) : nil,
233
- "escaped_image_link_url" => image_link_url ? CGI.escapeHTML(image_link_url) : nil,
234
- "escaped_title" => title && !title.empty? ? CGI.escapeHTML(title) : "&nbsp;",
235
- "escaped_alt" => alt && !alt.empty? ? CGI.escapeHTML(alt) : "",
236
- "escaped_link_display" => link_display && !link_display.empty? ? CGI.escapeHTML(link_display) : "&nbsp;",
237
- "escaped_archive_url" => archive_url ? CGI.escapeHTML(archive_url) : nil
193
+ "escaped_link_url" => CGI.escapeHTML(link_url),
194
+ "escaped_image_link_url" => CGI.escapeHTML(image_link_url),
195
+ "escaped_title" => escaped_title,
196
+ "escaped_alt" => CGI.escapeHTML(alt.to_s),
197
+ "escaped_link_display" => link_display && CGI.escapeHTML(link_display),
198
+ "escaped_archive_url" => archive_url && CGI.escapeHTML(archive_url)
238
199
  }
239
200
  end
240
201
 
@@ -243,7 +204,7 @@ module JekyllHighlightCards
243
204
  # @param url [String] the URL
244
205
  # @return [String] URL without protocol
245
206
  def strip_protocol(url)
246
- url.sub(%r{^https?://}, "")
207
+ url.sub(%r{\Ahttps?://}, "")
247
208
  end
248
209
  end
249
210
  end
@@ -27,13 +27,14 @@ module JekyllHighlightCards
27
27
  template = Liquid::Template.parse(template_content)
28
28
  template.render(variables)
29
29
  rescue Errno::ENOENT, Errno::EACCES => e
30
- raise TemplateRenderError, "Failed to read template '#{template_name}' at #{template_path}: #{e.message}"
30
+ raise TemplateRenderError, "Failed to read template '#{template_name}' at #{template_path}: #{e.class}: #{e}"
31
31
  rescue Encoding::InvalidByteSequenceError => e
32
- raise TemplateRenderError, "Invalid encoding in template '#{template_name}' at #{template_path}: #{e.message}"
32
+ raise TemplateRenderError,
33
+ "Invalid encoding in template '#{template_name}' at #{template_path}: #{e.class}: #{e}"
33
34
  rescue Liquid::SyntaxError, Liquid::Error => e
34
- raise TemplateRenderError, "Liquid error in template '#{template_name}': #{e.message}"
35
+ raise TemplateRenderError, "Liquid error in template '#{template_name}': #{e.class}: #{e}"
35
36
  rescue StandardError => e
36
- raise TemplateRenderError, "Unexpected error rendering template '#{template_name}': #{e.message}"
37
+ raise TemplateRenderError, "Unexpected error rendering template '#{template_name}': #{e.class}: #{e}"
37
38
  end
38
39
  end
39
40
 
@@ -57,6 +58,19 @@ module JekyllHighlightCards
57
58
  find_gem_template(relative_path)
58
59
  end
59
60
 
61
+ # Verify template path is safe (within allowed directory)
62
+ #
63
+ # @param allowed_dir [String] the allowed base directory
64
+ # @param template_path [String] the template path to check
65
+ # @return [String, nil] template path if safe, nil otherwise
66
+ def safe_template_path(allowed_dir, template_path)
67
+ return nil unless File.exist?(template_path)
68
+
69
+ expanded_dir = File.expand_path(allowed_dir)
70
+ expanded_path = File.expand_path(template_path)
71
+ template_path if expanded_path.start_with?(expanded_dir)
72
+ end
73
+
60
74
  private
61
75
 
62
76
  # Validate template name to prevent path traversal attacks
@@ -76,9 +90,11 @@ module JekyllHighlightCards
76
90
  # @param relative_path [String] relative path to template
77
91
  # @return [String, nil] path to template or nil
78
92
  def find_user_template(site, relative_path)
79
- return nil unless site&.source
93
+ source = site&.source
94
+ # Empty source would File.join to "/_includes/..." — skip it explicitly.
95
+ return nil unless source.is_a?(String) && !source.empty?
80
96
 
81
- includes_dir = File.join(site.source, "_includes")
97
+ includes_dir = File.join(source, "_includes")
82
98
  template_path = File.join(includes_dir, relative_path)
83
99
 
84
100
  safe_template_path(includes_dir, template_path)
@@ -95,19 +111,5 @@ module JekyllHighlightCards
95
111
 
96
112
  safe_template_path(includes_dir, template_path)
97
113
  end
98
-
99
- # Verify template path is safe (within allowed directory)
100
- #
101
- # @param allowed_dir [String] the allowed base directory
102
- # @param template_path [String] the template path to check
103
- # @return [String, nil] template path if safe, nil otherwise
104
- def safe_template_path(allowed_dir, template_path)
105
- return nil unless File.exist?(template_path)
106
-
107
- expanded_dir = File.expand_path(allowed_dir)
108
- expanded_path = File.expand_path(template_path)
109
-
110
- expanded_path.start_with?(expanded_dir) ? template_path : nil
111
- end
112
114
  end
113
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllHighlightCards
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-highlight-cards
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Texarkanine
@@ -63,6 +63,34 @@ dependencies:
63
63
  - - "~>"
64
64
  - !ruby/object:Gem::Version
65
65
  version: '2.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: mutant
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '0.16'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '0.16'
80
+ - !ruby/object:Gem::Dependency
81
+ name: mutant-rspec
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '0.16'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '0.16'
66
94
  - !ruby/object:Gem::Dependency
67
95
  name: rake
68
96
  requirement: !ruby/object:Gem::Requirement
@@ -139,28 +167,28 @@ dependencies:
139
167
  requirements:
140
168
  - - "~>"
141
169
  - !ruby/object:Gem::Version
142
- version: '0.22'
170
+ version: '1.0'
143
171
  type: :development
144
172
  prerelease: false
145
173
  version_requirements: !ruby/object:Gem::Requirement
146
174
  requirements:
147
175
  - - "~>"
148
176
  - !ruby/object:Gem::Version
149
- version: '0.22'
177
+ version: '1.0'
150
178
  - !ruby/object:Gem::Dependency
151
179
  name: simplecov-cobertura
152
180
  requirement: !ruby/object:Gem::Requirement
153
181
  requirements:
154
182
  - - "~>"
155
183
  - !ruby/object:Gem::Version
156
- version: '3.1'
184
+ version: '4.0'
157
185
  type: :development
158
186
  prerelease: false
159
187
  version_requirements: !ruby/object:Gem::Requirement
160
188
  requirements:
161
189
  - - "~>"
162
190
  - !ruby/object:Gem::Version
163
- version: '3.1'
191
+ version: '4.0'
164
192
  - !ruby/object:Gem::Dependency
165
193
  name: webmock
166
194
  requirement: !ruby/object:Gem::Requirement