jekyll-highlight-cards 0.3.1

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.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllHighlightCards
4
+ # Evaluate Liquid expressions and handle string values
5
+ #
6
+ # Provides utilities for evaluating Liquid variables (e.g., `{{ page.title }}`)
7
+ # and processing string values with quote stripping and fallback behavior.
8
+ #
9
+ # @example Evaluate a Liquid variable
10
+ # result = evaluate_expression("{{ page.title }}", context)
11
+ #
12
+ # @example Evaluate a quoted string
13
+ # result = evaluate_expression('"My Title"', context) #=> "My Title"
14
+ module ExpressionEvaluator
15
+ # Evaluate a token as a Liquid expression or literal string
16
+ #
17
+ # @param token [String] the token to evaluate
18
+ # @param context [Liquid::Context] the Liquid context for variable resolution
19
+ # @param allow_nil [Boolean] whether to allow nil results
20
+ # @return [String, nil] evaluated value or nil if evaluation fails
21
+ def evaluate_expression(token, context, allow_nil: true)
22
+ return nil if token.nil?
23
+ return "" if token.empty?
24
+
25
+ # Strip outer quotes if present
26
+ stripped = strip_outer_quotes(token)
27
+
28
+ # If the original token was quoted, treat as literal string
29
+ if stripped != token
30
+ return stripped if allow_nil
31
+
32
+ return stripped.empty? ? nil : stripped
33
+ end
34
+
35
+ # Try to evaluate as Liquid expression
36
+ if variable_lookup?(token)
37
+ begin
38
+ # Parse and evaluate the Liquid expression
39
+ template = Liquid::Template.parse(token)
40
+ result = template.render(context)
41
+ return result if allow_nil
42
+
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
48
+
49
+ return token.empty? ? nil : token
50
+ end
51
+ end
52
+
53
+ # Return as literal string
54
+ return token if allow_nil
55
+
56
+ token.empty? ? nil : token
57
+ end
58
+
59
+ # Check if an expression looks like a Liquid variable lookup
60
+ #
61
+ # @param expression [String] the expression to check
62
+ # @return [Boolean] true if expression contains Liquid syntax
63
+ def variable_lookup?(expression)
64
+ return false if expression.nil? || expression.empty?
65
+
66
+ expression.include?("{{") || expression.include?("{%")
67
+ end
68
+
69
+ # Strip outer quotes from a string (both single and double quotes)
70
+ #
71
+ # @param value [String] the string to process
72
+ # @return [String] string with outer quotes removed if present
73
+ 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
81
+
82
+ value
83
+ end
84
+
85
+ # Log debug message
86
+ #
87
+ # @param message [String] message to log
88
+ def log_debug(message)
89
+ Jekyll.logger.debug("HighlightCards:", message)
90
+ end
91
+
92
+ # Log info message
93
+ #
94
+ # @param message [String] message to log
95
+ def log_info(message)
96
+ Jekyll.logger.info("HighlightCards:", message)
97
+ end
98
+
99
+ # Log warning message
100
+ #
101
+ # @param message [String] message to log
102
+ def log_warn(message)
103
+ Jekyll.logger.warn("HighlightCards:", message)
104
+ end
105
+
106
+ # Log error message
107
+ #
108
+ # @param message [String] message to log
109
+ def log_error(message)
110
+ Jekyll.logger.error("HighlightCards:", message)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllHighlightCards
4
+ # Jekyll hooks for Markdown image sizing syntax
5
+ #
6
+ # Extends standard Markdown image syntax with dimension specifiers:
7
+ # `![alt](image.jpg =300x200)`
8
+ #
9
+ # Automatically wraps sized images in links to themselves and applies
10
+ # width/height attributes. Skips images in code blocks.
11
+ #
12
+ # @example Basic sizing
13
+ # ![Photo](image.jpg =300x200)
14
+ #
15
+ # @example Width only
16
+ # ![Photo](image.jpg =400x)
17
+ #
18
+ # @example Height only
19
+ # ![Photo](image.jpg =x300)
20
+ #
21
+ # @note Images inside code fences and inline code are not processed
22
+ # @note Sized images are automatically wrapped in <a> tags (if not already)
23
+ module ImageSizingHooks
24
+ extend DimensionParser
25
+
26
+ # Process document content before rendering
27
+ # Converts ![alt](src =WxH) to ![alt](src)<!-- IMG_SIZE:W:H -->
28
+ #
29
+ # @param document [Jekyll::Document] the document being processed
30
+ def self.process_pre_render(document)
31
+ content = document.content
32
+ return unless content
33
+
34
+ lines = content.lines
35
+ result = []
36
+
37
+ lines.each_with_index do |line, idx|
38
+ # Skip lines in code fences
39
+ if in_code_fence?(lines, idx)
40
+ result << line
41
+ next
42
+ end
43
+
44
+ # Process the line to convert sized images
45
+ processed_line = line.dup
46
+
47
+ # Match ![alt](src =dimensions) pattern
48
+ # Use gsub with block to check each match
49
+ processed_line.gsub!(/!\[([^\]]*)\]\(([^)]+)\s+=([^)]+)\)/) do |match|
50
+ match_start = Regexp.last_match.begin(0)
51
+
52
+ # Skip if inside inline code
53
+ if in_inline_code?(line, match_start)
54
+ match
55
+ else
56
+ alt = Regexp.last_match(1)
57
+ src = Regexp.last_match(2).strip
58
+ size = Regexp.last_match(3).strip
59
+
60
+ # Parse dimensions using DimensionParser
61
+ width, height = parse_dimensions(size)
62
+
63
+ # Build marker comment
64
+ "![#{alt}](#{src})<!-- IMG_SIZE:#{width}:#{height} -->"
65
+ end
66
+ end
67
+
68
+ result << processed_line
69
+ end
70
+
71
+ document.content = result.join
72
+ end
73
+
74
+ # Process document content after rendering
75
+ # Applies width/height attributes and auto-links sized images
76
+ #
77
+ # @param document [Jekyll::Document] the document being processed
78
+ def self.process_post_render(document)
79
+ output = document.output
80
+ return unless output
81
+
82
+ # Duplicate to avoid frozen string errors
83
+ output = output.dup
84
+
85
+ # 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
90
+
91
+ # Build attributes to add
92
+ attrs = []
93
+ attrs << %(width="#{width}") unless width.to_s.empty?
94
+ attrs << %(height="#{height}") unless height.to_s.empty?
95
+
96
+ # Add attributes to img tag
97
+ modified_img = if attrs.any?
98
+ img_tag.sub("<img", "<img #{attrs.join(" ")}")
99
+ else
100
+ img_tag
101
+ end
102
+
103
+ # Extract src for auto-linking
104
+ src = img_tag[/src=["']([^"']+)["']/, 1]
105
+
106
+ # Skip auto-linking if src is missing or malformed
107
+ next modified_img if src.nil? || src.empty?
108
+
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)
118
+
119
+ # Auto-link if not already linked
120
+ if already_linked
121
+ modified_img
122
+ else
123
+ %(<a href="#{CGI.escapeHTML(src)}">#{modified_img}</a>)
124
+ end
125
+ end
126
+
127
+ document.output = output
128
+ end
129
+
130
+ # Check if a line is inside a code fence
131
+ #
132
+ # @param lines [Array<String>] all lines in the document
133
+ # @param line_idx [Integer] the current line index
134
+ # @return [Boolean] true if inside code fence
135
+ 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 > 0
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
159
+
160
+ # If we found a contiguous run of indented lines reaching current line
161
+ idx < line_idx
162
+ end
163
+
164
+ # Check if text position is inside inline code
165
+ #
166
+ # @param line [String] the line of text
167
+ # @param position [Integer] the position in the line
168
+ # @return [Boolean] true if inside inline code
169
+ 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?
177
+ end
178
+ end
179
+ end
180
+
181
+ # Register Jekyll hooks
182
+ Jekyll::Hooks.register :documents, :pre_render do |document|
183
+ JekyllHighlightCards::ImageSizingHooks.process_pre_render(document)
184
+ end
185
+
186
+ Jekyll::Hooks.register :documents, :post_render do |document|
187
+ JekyllHighlightCards::ImageSizingHooks.process_post_render(document)
188
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllHighlightCards
4
+ # Liquid tag for creating styled link card components
5
+ #
6
+ # Syntax:
7
+ # {% linkcard URL [TITLE] [archive:ARCHIVE_URL] %}
8
+ #
9
+ # Parameters:
10
+ # - URL (required): The URL to link to (can be Liquid expression)
11
+ # - TITLE (optional): Title text to display (can be Liquid expression)
12
+ # - archive:URL (optional): Explicit archive URL or "none" to opt out
13
+ #
14
+ # Examples:
15
+ # {% linkcard https://example.com %}
16
+ # {% linkcard https://example.com My Link Title %}
17
+ # {% linkcard {{ page.url }} {{ page.title }} %}
18
+ # {% linkcard https://example.com Title archive:none %}
19
+ class LinkcardTag < Liquid::Tag
20
+ include ArchiveHelper
21
+ include ExpressionEvaluator
22
+ include TemplateRenderer
23
+
24
+ # Initialize the tag
25
+ #
26
+ # @param tag_name [String] the name of the tag
27
+ # @param markup [String] the tag markup containing parameters
28
+ # @param tokens [Array] parse tokens (unused)
29
+ def initialize(tag_name, markup, tokens)
30
+ super
31
+ @markup = markup.strip
32
+ end
33
+
34
+ # Render the linkcard tag
35
+ #
36
+ # @param context [Liquid::Context] the Liquid rendering context
37
+ # @return [String] rendered HTML
38
+ def render(context)
39
+ # Parse markup
40
+ parsed = split_markup(@markup)
41
+
42
+ # Resolve URL (required)
43
+ url = resolve_url(parsed[:url], context)
44
+ raise ArgumentError, "linkcard tag requires a URL" if url.nil? || url.empty?
45
+
46
+ # Resolve title (optional)
47
+ title = resolve_title(parsed[:title], context)
48
+
49
+ # Resolve archive (optional, may auto-lookup)
50
+ archive_url = resolve_archive(parsed[:archive], context, url)
51
+
52
+ # Build template variables
53
+ variables = build_template_variables(url, title, archive_url)
54
+
55
+ # Get site from context
56
+ site = context.registers[:site]
57
+
58
+ # Render template
59
+ render_template(site, "linkcard", variables)
60
+ end
61
+
62
+ private
63
+
64
+ # Parse the tag markup into URL, title, and archive components
65
+ #
66
+ # @param markup [String] the tag markup
67
+ # @return [Hash] parsed components
68
+ 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
+ tokens = []
72
+ current = ""
73
+ in_quotes = false
74
+ quote_char = nil
75
+ in_liquid = 0 # Track nested Liquid expressions
76
+ escaped = false # Track if next character is escaped
77
+
78
+ markup.each_char do |char|
79
+ # Handle escape sequences when in quotes
80
+ if escaped
81
+ current += char # Add the escaped character directly
82
+ escaped = false
83
+ next
84
+ end
85
+
86
+ # Check for escape character when in quotes
87
+ if char == "\\" && in_quotes
88
+ escaped = true
89
+ next # Don't add backslash to output, it's just the escape marker
90
+ end
91
+
92
+ # Track Liquid expression boundaries
93
+ if char == "{" && !in_quotes
94
+ in_liquid += 1
95
+ current += char
96
+ elsif char == "}" && !in_quotes && in_liquid > 0
97
+ in_liquid -= 1
98
+ current += char
99
+ # Track quote boundaries
100
+ elsif ['"', "'"].include?(char) && !in_quotes && in_liquid == 0
101
+ in_quotes = true
102
+ quote_char = char
103
+ current += char
104
+ elsif char == quote_char && in_quotes
105
+ in_quotes = false
106
+ current += char
107
+ quote_char = nil
108
+ # Split on whitespace only if not in quotes or Liquid expression
109
+ elsif char.match?(/\s/) && !in_quotes && in_liquid == 0
110
+ tokens << current unless current.empty?
111
+ current = ""
112
+ else
113
+ current += char
114
+ end
115
+ end
116
+ tokens << current unless current.empty?
117
+
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
+ }
124
+
125
+ # Process remaining tokens
126
+ tokens.each do |token|
127
+ if token.start_with?("archive:")
128
+ result[:archive] = token.sub(/^archive:/, "")
129
+ else
130
+ # Accumulate title tokens
131
+ result[:title] = result[:title].nil? ? token : "#{result[:title]} #{token}"
132
+ end
133
+ end
134
+
135
+ result
136
+ end
137
+
138
+ # Resolve URL from token (may be Liquid expression or literal)
139
+ #
140
+ # @param token [String] the URL token
141
+ # @param context [Liquid::Context] the Liquid context
142
+ # @return [String] resolved URL
143
+ def resolve_url(token, context)
144
+ return nil if token.nil? || token.empty?
145
+
146
+ evaluate_expression(token, context, allow_nil: false)
147
+ end
148
+
149
+ # Resolve title from source (may be Liquid expression or literal)
150
+ #
151
+ # @param source [String, nil] the title source
152
+ # @param context [Liquid::Context] the Liquid context
153
+ # @return [String, nil] resolved title
154
+ def resolve_title(source, context)
155
+ return nil if source.nil? || source.empty?
156
+
157
+ evaluate_expression(source, context, allow_nil: true)
158
+ end
159
+
160
+ # Resolve archive URL (may be explicit, auto-lookup, or opt-out)
161
+ #
162
+ # @param source [String, nil] the archive source
163
+ # @param context [Liquid::Context] the Liquid context
164
+ # @param url [String] the target URL to archive
165
+ # @return [String, nil] resolved archive URL
166
+ def resolve_archive(source, context, url)
167
+ # Check for explicit opt-out
168
+ return nil if source && source.downcase == "none"
169
+
170
+ # 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?
175
+
176
+ nil
177
+ end
178
+
179
+ # Build template variables hash for rendering
180
+ #
181
+ # @param url [String] the link URL
182
+ # @param title [String, nil] the title text
183
+ # @param archive_url [String, nil] the archive URL
184
+ # @return [Hash] template variables with raw and escaped versions
185
+ def build_template_variables(url, title, archive_url)
186
+ display_url = strip_protocol(url)
187
+
188
+ {
189
+ "url" => url,
190
+ "display_url" => display_url,
191
+ "title" => title,
192
+ "archive_url" => archive_url,
193
+ "escaped_url" => CGI.escapeHTML(url),
194
+ "escaped_display_url" => CGI.escapeHTML(display_url),
195
+ "escaped_title" => title ? CGI.escapeHTML(title) : nil,
196
+ "escaped_archive_url" => archive_url ? CGI.escapeHTML(archive_url) : nil
197
+ }
198
+ end
199
+
200
+ # Strip protocol from URL for display
201
+ #
202
+ # @param url [String] the URL
203
+ # @return [String] URL without protocol
204
+ def strip_protocol(url)
205
+ url.sub(%r{^https?://}, "")
206
+ end
207
+ end
208
+ end
209
+
210
+ # Register the tag with Liquid
211
+ Liquid::Template.register_tag("linkcard", JekyllHighlightCards::LinkcardTag)