jekyll-highlight-cards 2.0.0 → 2.2.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: 48a4ccd9a729f6d5db3c458bdf9311abe63b595b370cb6577994e7c0da53d763
4
+ data.tar.gz: 4805a92c237413bcc17ddd0c2d1166bd5c7e606a9c2287b8f6e99939d42d6639
5
5
  SHA512:
6
- metadata.gz: 1fe96dece9fb642150e35571f1b7e8887b1d91236a16ea367d434846b550141dd08780e4532642db2e9103607fd5af70ae484ff85a018e30e678105113c45ca3
7
- data.tar.gz: 224ec98ce539b1f7a00b9b8707b6552638c07ddacb8ff6ff87c988b5bed74c66c9c9bfd67dfa08ddf18ac072518b7418690c6b54a93dc71a83364045dce9d0c4
6
+ metadata.gz: 3c01b9b3dc7adb48b1e20a5dce6af56f0ba2e0e1db4e490c7024c066a8ee8cf3dc9da0e3f65637aec448ecd3bd67b39169f4719536f0fd27352ab46a76303a95
7
+ data.tar.gz: 4d88a809394b5b4d77a73f385358d05c532ee38f0c22f26dada841b2c5aff61915fc4486bb61ab83ffc5c282b2b1226b7fde8c75c4654260f280e6e83d76193a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0](https://github.com/Texarkanine/jekyll-highlight-cards/compare/v2.1.0...v2.2.0) (2026-08-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * freeze-archives command and highlight_cards.noarchive skips ([#56](https://github.com/Texarkanine/jekyll-highlight-cards/issues/56)) ([a50d109](https://github.com/Texarkanine/jekyll-highlight-cards/commit/a50d109807a0709c47aeebab021b5b49bbd35d8f))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **archive:** skip non-archivable URLs [[#53](https://github.com/Texarkanine/jekyll-highlight-cards/issues/53)] ([#54](https://github.com/Texarkanine/jekyll-highlight-cards/issues/54)) ([928e6d8](https://github.com/Texarkanine/jekyll-highlight-cards/commit/928e6d8268318dbca85ec5d8143b1a1b67f6275a))
14
+
15
+ ## [2.1.0](https://github.com/Texarkanine/jekyll-highlight-cards/compare/v2.0.0...v2.1.0) (2026-07-19)
16
+
17
+
18
+ ### Features
19
+
20
+ * **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))
21
+
3
22
  ## [2.0.0](https://github.com/Texarkanine/jekyll-highlight-cards/compare/v1.1.2...v2.0.0) (2026-01-18)
4
23
 
5
24
 
data/README.md CHANGED
@@ -11,6 +11,7 @@ A Jekyll plugin providing styled card components for links and images with Inter
11
11
  - **`{% polaroid %}`** - Polaroid-style image cards with titles, links, and archive support
12
12
  - **Markdown Image Sizing** - Extended syntax for image dimensions: `![alt](image.jpg =300x200)`
13
13
  - **Internet Archive Integration** - Automatic lookup and archival for both tags
14
+ - **`jekyll freeze-archives`** - Opt-in command to freeze archive URLs into source
14
15
  - **Customizable** - Override HTML templates and CSS styles
15
16
 
16
17
  ## Installation
@@ -148,6 +149,41 @@ Or in your shell config:
148
149
  export JEKYLL_HIGHLIGHT_CARDS_ARCHIVE=1
149
150
  ```
150
151
 
152
+ | Flag | Description |
153
+ |------|-------------|
154
+ | `JEKYLL_HIGHLIGHT_CARDS_ARCHIVE` | Enable build-time lookup to see if there is an IA snapshot available |
155
+ | `JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE` | Also submit the URL to SavePageNow (runs even when CDX already found a snapshot) |
156
+
157
+ You can selectively skip archive attempts for URLs that match site-configured regexes. Patterns are matched against the full URL string; use anchors when needed.
158
+
159
+ ```yaml
160
+ highlight_cards:
161
+ noarchive:
162
+ - ^https?://x\.com/.*
163
+ - \Ahttps://something\.example\.com/.*
164
+ ```
165
+
166
+ ### Freeze Archives Into Source
167
+
168
+ To look up Internet Archive snapshots *once* (instead of on each build) and write them into your source tags, run the `freeze-archives` Jekyll subcommand:
169
+
170
+ ```bash
171
+ # Rehearse first — reports planned edits, writes nothing
172
+ bundle exec jekyll freeze-archives --dry-run
173
+
174
+ # Write archive: / archive= into eligible tags
175
+ bundle exec jekyll freeze-archives
176
+ ```
177
+
178
+ Review the diff, then commit. The command does **not** commit for you, and it does **not** run as part of `jekyll build`.
179
+
180
+ **How it differs from build-time lookup**
181
+
182
+ - Invoking `freeze-archives` performs CDX lookup — you do **not** need `JEKYLL_HIGHLIGHT_CARDS_ARCHIVE=1`.
183
+ - SavePageNow stays gated: set `JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE=1` or pass `--save`.
184
+ - Only **literal** http(s) URLs are frozen. Liquid targets like `{{ page.url }}` are skipped; build-time auto-lookup still covers those when enabled.
185
+ - Tags that already have `archive:` / `archive=` / `archive:none` are left alone.
186
+
151
187
  ### CSS Styles
152
188
 
153
189
  **Default usage:**
@@ -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
@@ -17,30 +17,24 @@ module JekyllHighlightCards
17
17
  @archive_cache = {}
18
18
 
19
19
  class << self
20
- attr_accessor :archive_cache
20
+ attr_accessor :archive_cache, :noarchive_regexp_cache
21
21
  end
22
22
 
23
23
  # Get archive URL for a given URL, with caching and optional submission
24
24
  #
25
25
  # @param url [String] the original URL to archive
26
+ # @param site [Jekyll::Site, nil] optional site for +highlight_cards.noarchive+ patterns
26
27
  # @return [String, nil] the archive URL, or nil if not found
27
- def archive_url_for(url)
28
+ def archive_url_for(url, site: nil)
29
+ return nil unless archiveable_url?(url, site: site)
30
+
28
31
  ArchiveHelper.archive_cache[url] ||= begin
29
- log_info("Looking up archive for #{url}")
30
32
  archive_url = lookup_archive(url)
31
- log_info("Archive URL: #{archive_url || ""}")
32
33
 
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
34
+ archive_url = submit_archive(url) || archive_url if archive_save_enabled?
38
35
 
39
- archive_url.to_s.empty? ? nil : archive_url
36
+ archive_url
40
37
  end
41
- rescue StandardError => e
42
- log_debug("Archive lookup failed for #{url}: #{e.message}")
43
- nil
44
38
  end
45
39
 
46
40
  # Check if archiving is enabled via environment variables
@@ -67,19 +61,60 @@ module JekyllHighlightCards
67
61
 
68
62
  private
69
63
 
64
+ # Whether +url+ is eligible for CDX lookup / SavePageNow submission.
65
+ #
66
+ # Requires an absolute http(s) URI with a host. Rejects Internet Archive
67
+ # hosts so Wayback / archive.org URLs are not re-submitted. Also rejects
68
+ # URLs matching any +highlight_cards.noarchive+ regex on +site+ config.
69
+ #
70
+ # @param url [String] candidate URL
71
+ # @param site [Jekyll::Site, nil] optional site for +highlight_cards.noarchive+
72
+ # @return [Boolean] true if the URL may be archived
73
+ def archiveable_url?(url, site: nil)
74
+ uri = URI.parse(url.to_s)
75
+ return false unless uri.is_a?(URI::HTTP) && uri.host
76
+ return false if %w[archive.org web.archive.org].include?(uri.host.downcase)
77
+ return false if noarchive_match?(url, site)
78
+
79
+ true
80
+ rescue URI::InvalidURIError
81
+ false
82
+ end
83
+
84
+ # Whether +url+ matches any +highlight_cards.noarchive+ pattern on +site+.
85
+ #
86
+ # @param url [String] candidate URL (full string matched)
87
+ # @param site [Jekyll::Site, nil]
88
+ # @return [Boolean]
89
+ # @raise [RegexpError] when a configured pattern is invalid
90
+ def noarchive_match?(url, site)
91
+ noarchive_patterns_for(site).any? { |pattern| pattern.match?(url.to_s) }
92
+ end
93
+
94
+ # Compile +highlight_cards.noarchive+ patterns from site config (memoized per site).
95
+ #
96
+ # @param site [Jekyll::Site, nil]
97
+ # @return [Array<Regexp>]
98
+ def noarchive_patterns_for(site)
99
+ return [] unless site
100
+
101
+ raw = site.config.dig("highlight_cards", "noarchive")
102
+ return [] if raw.nil? || Array(raw).empty?
103
+
104
+ cache = (ArchiveHelper.noarchive_regexp_cache ||= {})
105
+ cache_key = [site.object_id, raw]
106
+ cache[cache_key] ||= Array(raw).map { |pattern| Regexp.new(pattern.to_s) }
107
+ end
108
+
70
109
  # Look up the latest archived snapshot for a URL via Internet Archive CDX API
71
110
  #
72
111
  # @param url [String] the URL to look up
73
112
  # @return [String, nil] the archive URL if found, nil otherwise
74
113
  def lookup_archive(url)
75
- log_debug("lookup_archive(#{url})")
76
-
77
114
  encoded_url = URI.encode_www_form_component(url)
78
115
  cdx_url_str = "https://web.archive.org/cdx/search/cdx?url=#{encoded_url}&output=json&filter=statuscode:200&limit=-1&fl=timestamp,original"
79
116
  cdx_url = URI.parse(cdx_url_str)
80
117
 
81
- log_debug("CDX lookup URL: #{cdx_url_str}")
82
-
83
118
  response = Net::HTTP.start(
84
119
  cdx_url.host,
85
120
  cdx_url.port,
@@ -90,25 +125,15 @@ module JekyllHighlightCards
90
125
  http.request(Net::HTTP::Get.new(cdx_url.request_uri))
91
126
  end
92
127
 
93
- unless response.is_a?(Net::HTTPSuccess)
94
- log_debug("CDX lookup failed: #{response.code} #{response.message}")
95
- return nil
96
- end
128
+ return nil unless response.is_a?(Net::HTTPSuccess)
97
129
 
98
- log_debug("CDX lookup found archived page...")
99
130
  rows = JSON.parse(response.body)
100
131
 
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}"
132
+ return nil if rows.size < 2
107
133
 
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}")
134
+ timestamp, = rows.last
135
+ "https://web.archive.org/web/#{timestamp}/#{url}"
136
+ rescue StandardError
112
137
  nil
113
138
  end
114
139
 
@@ -117,35 +142,29 @@ module JekyllHighlightCards
117
142
  # @param url [String] the URL to submit for archiving
118
143
  # @return [String, nil] the archive URL if successful, nil otherwise
119
144
  def submit_archive(url)
120
- log_debug("submit_archive(#{url})")
121
-
122
145
  encoded_url = URI.encode_www_form_component(url)
123
146
  save_url = URI.parse("https://web.archive.org/save/#{encoded_url}")
124
147
 
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)
148
+ response = begin
149
+ Net::HTTP.start(
150
+ save_url.host,
151
+ save_url.port,
152
+ use_ssl: save_url.scheme == "https",
153
+ open_timeout: 10,
154
+ read_timeout: 30
155
+ ) do |http|
156
+ req = Net::HTTP::Get.new(save_url.request_uri, { "User-Agent" => archive_user_agent })
157
+ http.request(req)
158
+ end
159
+ rescue StandardError
160
+ nil
134
161
  end
162
+ return nil if response.nil?
135
163
 
136
164
  location = response["content-location"]
165
+ return nil if location.to_s.empty?
137
166
 
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
167
+ "https://web.archive.org#{location}"
149
168
  end
150
169
  end
151
170
  end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllHighlightCards
4
+ module Commands
5
+ # Opt-in Jekyll subcommand: +jekyll freeze-archives+
6
+ #
7
+ # Scans site source for archive-eligible +linkcard+ / +polaroid+ tags that
8
+ # lack an encoded archive, looks up Internet Archive, and surgically inserts
9
+ # successful hits into source. Does not run during +jekyll build+.
10
+ #
11
+ # @see https://jekyllrb.com/docs/plugins/commands/
12
+ class FreezeArchives < Jekyll::Command
13
+ class << self
14
+ # Register the +freeze-archives+ Mercenary subcommand
15
+ #
16
+ # @param prog [Mercenary::Program] the Jekyll CLI program
17
+ # @return [void]
18
+ def init_with_program(prog)
19
+ prog.command(:"freeze-archives") do |c|
20
+ c.syntax "freeze-archives [options]"
21
+ c.description "Freeze Internet Archive URLs into highlight-card source tags"
22
+
23
+ c.option "dry_run", "--dry-run", "Report planned edits without writing files"
24
+ c.option "save", "--save", "Enable SavePageNow when CDX has no snapshot"
25
+ c.option "config", "--config CONFIG_FILE[,CONFIG_FILE2,...]", Array,
26
+ "Custom configuration file"
27
+ c.option "source", "-s", "--source SOURCE", "Custom source directory"
28
+
29
+ c.action do |_args, options|
30
+ process(options)
31
+ end
32
+ end
33
+ end
34
+
35
+ # Run freeze-archives against a configured site
36
+ #
37
+ # @param options [Hash] Mercenary/Jekyll options (+dry_run+, +save+, +source+, …)
38
+ # @return [Hash] summary counts +:frozen+, +:skipped+, +:miss+, +:written+
39
+ def process(options)
40
+ previous_save = ENV.fetch("JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE", nil)
41
+ previous_log_level = Jekyll.logger.level
42
+ ENV["JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE"] = "1" if options["save"]
43
+
44
+ site = Jekyll::Site.new(configuration_from_options(options))
45
+ # Hand-run tool: keep progress visible even if the site sets quiet: true
46
+ Jekyll.logger.log_level = :info unless options["quiet"]
47
+ site.read
48
+
49
+ summary = Runner.new(
50
+ dry_run: options["dry_run"],
51
+ logger: Jekyll.logger
52
+ ).run(site)
53
+
54
+ Jekyll.logger.info "freeze-archives:",
55
+ "frozen=#{summary[:frozen]} skipped=#{summary[:skipped]} " \
56
+ "miss=#{summary[:miss]} written=#{summary[:written]}"
57
+ summary
58
+ ensure
59
+ restore_save_env(previous_save, options)
60
+ Jekyll.logger.log_level = previous_log_level if previous_log_level
61
+ end
62
+
63
+ private
64
+
65
+ def restore_save_env(previous_save, options)
66
+ return unless options && options["save"]
67
+
68
+ if previous_save.nil?
69
+ ENV.delete("JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE")
70
+ else
71
+ ENV["JEKYLL_HIGHLIGHT_CARDS_ARCHIVE_SAVE"] = previous_save
72
+ end
73
+ end
74
+ end
75
+
76
+ # Orchestrates locate → analyze → lookup → insert for one site
77
+ class Runner
78
+ include ArchiveHelper
79
+
80
+ def initialize(dry_run:, logger:)
81
+ @dry_run = dry_run
82
+ @logger = logger
83
+ @site = nil
84
+ @site_source = nil
85
+ @locator = JekyllHighlightCards::FreezeArchives::TagLocator.new
86
+ @inserter = JekyllHighlightCards::FreezeArchives::ArchiveInserter.new
87
+ end
88
+
89
+ # @param site [Jekyll::Site]
90
+ # @return [Hash] summary counts
91
+ def run(site)
92
+ @site = site
93
+ @site_source = site.source
94
+ @analyzer = JekyllHighlightCards::FreezeArchives::MarkupAnalyzer.new(site: site)
95
+ summary = { frozen: 0, skipped: 0, miss: 0, written: 0 }
96
+
97
+ source_files(site).each do |path|
98
+ process_file(path, summary)
99
+ end
100
+
101
+ summary
102
+ end
103
+
104
+ private
105
+
106
+ def source_files(site)
107
+ paths = site.pages.map { |page| resolve_source_path(site, page.path) }
108
+ site.collections.each_value do |collection|
109
+ paths.concat(collection.docs.map { |doc| resolve_source_path(site, doc.path) })
110
+ end
111
+
112
+ paths.compact.uniq.select do |path|
113
+ File.file?(path) && path.start_with?(site.source)
114
+ end
115
+ end
116
+
117
+ # Jekyll::Page#path is source-relative; Document#path is absolute.
118
+ def resolve_source_path(site, path)
119
+ return nil if path.to_s.empty?
120
+ return path if File.absolute_path?(path)
121
+
122
+ site.in_source_dir(path)
123
+ end
124
+
125
+ def process_file(path, summary)
126
+ content = File.read(path)
127
+ spans = @locator.locate(content)
128
+ return if spans.empty?
129
+
130
+ edits = []
131
+ spans.each do |span|
132
+ candidate = @analyzer.analyze(span[:tag], span[:markup])
133
+ unless candidate
134
+ summary[:skipped] += 1
135
+ next
136
+ end
137
+
138
+ target = candidate[:target_url]
139
+ @logger.info "freeze-archives:", "Looking up archive for #{target}"
140
+ archive_url = archive_url_for(target, site: @site)
141
+ unless archive_url
142
+ summary[:miss] += 1
143
+ @logger.info "freeze-archives:", "no archive for #{target}"
144
+ next
145
+ end
146
+
147
+ summary[:frozen] += 1
148
+ verb = @dry_run ? "would freeze" : "frozen"
149
+ @logger.info "freeze-archives:",
150
+ "#{verb}: #{relative_source_path(path)} ← #{archive_url}"
151
+ edits << { span: span, archive_url: archive_url }
152
+ end
153
+
154
+ return if edits.empty? || @dry_run
155
+
156
+ # Apply from end of file so earlier ranges stay valid
157
+ updated = content
158
+ edits.sort_by { |e| -e[:span][:range].begin }.each do |edit|
159
+ updated = @inserter.insert(updated, edit[:span], edit[:archive_url])
160
+ end
161
+
162
+ return if updated == content
163
+
164
+ File.write(path, updated)
165
+ summary[:written] += 1
166
+ end
167
+
168
+ def relative_source_path(path)
169
+ source = @site_source.to_s
170
+ return path if source.empty?
171
+
172
+ prefix = source.end_with?("/") ? source : "#{source}/"
173
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : path
174
+ end
175
+ end
176
+ end
177
+ end
178
+ 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