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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +36 -0
- data/jekyll-highlight-cards.gemspec +4 -2
- data/lib/jekyll-highlight-cards/archive_helper.rb +73 -54
- data/lib/jekyll-highlight-cards/commands/freeze_archives.rb +178 -0
- data/lib/jekyll-highlight-cards/dimension_parser.rb +10 -18
- data/lib/jekyll-highlight-cards/expression_evaluator.rb +22 -23
- data/lib/jekyll-highlight-cards/freeze_archives/archive_inserter.rb +72 -0
- data/lib/jekyll-highlight-cards/freeze_archives/markup_analyzer.rb +59 -0
- data/lib/jekyll-highlight-cards/freeze_archives/tag_locator.rb +32 -0
- data/lib/jekyll-highlight-cards/image_sizing_hooks.rb +53 -62
- data/lib/jekyll-highlight-cards/linkcard_markup.rb +83 -0
- data/lib/jekyll-highlight-cards/linkcard_tag.rb +13 -92
- data/lib/jekyll-highlight-cards/polaroid_markup.rb +81 -0
- data/lib/jekyll-highlight-cards/polaroid_tag.rb +43 -119
- data/lib/jekyll-highlight-cards/template_renderer.rb +22 -20
- data/lib/jekyll-highlight-cards/version.rb +1 -1
- data/lib/jekyll-highlight-cards.rb +6 -0
- metadata +39 -5
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllHighlightCards
|
|
4
|
+
module FreezeArchives
|
|
5
|
+
# Surgically insert an archive token into a located tag span.
|
|
6
|
+
#
|
|
7
|
+
# Single-line tags splice after the last non-whitespace of the markup.
|
|
8
|
+
# Multiline tags add a new line before the closer, indented like the last
|
|
9
|
+
# content line (see creative-source-scan-rewrite).
|
|
10
|
+
class ArchiveInserter
|
|
11
|
+
# Insert archive URL into +content+ at +span+
|
|
12
|
+
#
|
|
13
|
+
# @param content [String] full file contents
|
|
14
|
+
# @param span [Hash] locator span with +:tag+, +:markup+, +:range+
|
|
15
|
+
# @param archive_url [String] Wayback (or other) archive URL to freeze
|
|
16
|
+
# @return [String] updated file contents
|
|
17
|
+
def insert(content, span, archive_url)
|
|
18
|
+
range = span.fetch(:range)
|
|
19
|
+
tag_text = content[range]
|
|
20
|
+
updated = rewrite_tag(tag_text, span.fetch(:tag), archive_url)
|
|
21
|
+
content[0...range.begin] + updated + content[range.end..]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rewrite_tag(tag_text, tag, archive_url)
|
|
27
|
+
token = archive_token(tag, archive_url)
|
|
28
|
+
match = tag_text.match(/\A(?<head>[\s\S]*?)(?<closer>-?%})\z/)
|
|
29
|
+
raise ArgumentError, "tag span missing closing %}" unless match
|
|
30
|
+
|
|
31
|
+
head = match[:head]
|
|
32
|
+
closer = match[:closer]
|
|
33
|
+
|
|
34
|
+
if head.include?("\n")
|
|
35
|
+
insert_multiline(head, closer, token)
|
|
36
|
+
else
|
|
37
|
+
insert_single_line(head, closer, token)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def insert_single_line(head, closer, token)
|
|
42
|
+
# Preserve trailing whitespace before %}
|
|
43
|
+
trailing = head[/\s*\z/] || ""
|
|
44
|
+
body = head[0...(head.length - trailing.length)]
|
|
45
|
+
"#{body} #{token}#{trailing}#{closer}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def insert_multiline(head, closer, token)
|
|
49
|
+
lines = head.split("\n", -1)
|
|
50
|
+
# Drop trailing whitespace-only segment before %} and keep its indent for the closer
|
|
51
|
+
closer_indent = ""
|
|
52
|
+
closer_indent = lines.pop if lines.last&.strip&.empty?
|
|
53
|
+
|
|
54
|
+
last_content = lines.reverse.find { |line| !line.strip.empty? }
|
|
55
|
+
indent = last_content ? last_content[/\A[ \t]*/] : ""
|
|
56
|
+
|
|
57
|
+
"#{lines.join("\n")}\n#{indent}#{token}\n#{closer_indent}#{closer}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def archive_token(tag, archive_url)
|
|
61
|
+
case tag.to_s
|
|
62
|
+
when "linkcard"
|
|
63
|
+
"archive:#{archive_url}"
|
|
64
|
+
when "polaroid"
|
|
65
|
+
%(archive="#{archive_url.to_s.gsub('"', '\\"')}")
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "unsupported tag: #{tag}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllHighlightCards
|
|
4
|
+
module FreezeArchives
|
|
5
|
+
# Classify `{% linkcard %}` / `{% polaroid %}` markup for freeze-archives.
|
|
6
|
+
#
|
|
7
|
+
# A freeze candidate is a tag that has a literal archiveable target URL and
|
|
8
|
+
# does not already encode an archive (`archive:` / `archive=` / `none`).
|
|
9
|
+
# Liquid-dynamic targets are skipped (build-time fallback remains).
|
|
10
|
+
class MarkupAnalyzer
|
|
11
|
+
include ArchiveHelper
|
|
12
|
+
include ExpressionEvaluator
|
|
13
|
+
|
|
14
|
+
# @param site [Jekyll::Site, nil] site for +highlight_cards.noarchive+
|
|
15
|
+
def initialize(site: nil)
|
|
16
|
+
@site = site
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Analyze tag markup for freeze eligibility
|
|
20
|
+
#
|
|
21
|
+
# @param tag [String] +"linkcard"+ or +"polaroid"+
|
|
22
|
+
# @param markup [String] contents between the tag name and +%}+
|
|
23
|
+
# @return [Hash, nil] +{ target_url: String }+ when freezable, else +nil+
|
|
24
|
+
def analyze(tag, markup)
|
|
25
|
+
case tag.to_s
|
|
26
|
+
when "linkcard"
|
|
27
|
+
analyze_linkcard(markup)
|
|
28
|
+
when "polaroid"
|
|
29
|
+
analyze_polaroid(markup)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def analyze_linkcard(markup)
|
|
36
|
+
parsed = LinkcardMarkup.split(markup)
|
|
37
|
+
return nil if parsed.key?(:archive)
|
|
38
|
+
|
|
39
|
+
candidate_for(strip_outer_quotes(parsed[:url]))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def analyze_polaroid(markup)
|
|
43
|
+
parsed = PolaroidMarkup.parse(markup)
|
|
44
|
+
return nil if parsed.key?(:archive)
|
|
45
|
+
return nil unless parsed.key?(:link)
|
|
46
|
+
|
|
47
|
+
candidate_for(strip_outer_quotes(parsed[:link]))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def candidate_for(target)
|
|
51
|
+
return nil if target.to_s.empty?
|
|
52
|
+
return nil if variable_lookup?(target)
|
|
53
|
+
return nil unless archiveable_url?(target, site: @site)
|
|
54
|
+
|
|
55
|
+
{ target_url: target }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllHighlightCards
|
|
4
|
+
module FreezeArchives
|
|
5
|
+
# Locate `{% linkcard %}` / `{% polaroid %}` spans in source text.
|
|
6
|
+
#
|
|
7
|
+
# Supports multiline tags. Returns structural spans for analysis/insert —
|
|
8
|
+
# does not decide freeze eligibility.
|
|
9
|
+
class TagLocator
|
|
10
|
+
# Optional +- after +{ %+ / before +%}+ is Liquid whitespace control —
|
|
11
|
+
# keep the trailing dash out of +:markup+ (leading dash stays in the opener).
|
|
12
|
+
TAG_PATTERN = /{%-?\s*(linkcard|polaroid)\s+([\s\S]*?)-?%}/
|
|
13
|
+
|
|
14
|
+
# Find highlight-card tag spans in +text+
|
|
15
|
+
#
|
|
16
|
+
# @param text [String] file contents
|
|
17
|
+
# @return [Array<Hash>] each hash has +:tag+, +:markup+, +:range+ (exclusive end)
|
|
18
|
+
def locate(text)
|
|
19
|
+
spans = []
|
|
20
|
+
text.to_s.scan(TAG_PATTERN) do
|
|
21
|
+
match = Regexp.last_match
|
|
22
|
+
spans << {
|
|
23
|
+
tag: match[1],
|
|
24
|
+
markup: match[2],
|
|
25
|
+
range: match.begin(0)...match.end(0)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
spans
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -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  to <!-- 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
|
|
60
|
+
processed_line = line
|
|
46
61
|
|
|
47
62
|
# Match  pattern
|
|
48
63
|
# Use gsub with block to check each match
|
|
49
|
-
processed_line.gsub!(
|
|
50
|
-
|
|
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 =
|
|
57
|
-
src =
|
|
58
|
-
size =
|
|
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
|
"<!-- 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!(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
94
|
-
attrs << %(height="#{height}") unless height.
|
|
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
|
-
|
|
107
|
-
next modified_img if src.nil? || src.empty?
|
|
121
|
+
prefix = img_link_prefix(output, match)
|
|
108
122
|
|
|
109
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllHighlightCards
|
|
4
|
+
# Shared structural parser for `{% linkcard %}` markup.
|
|
5
|
+
#
|
|
6
|
+
# Used by {LinkcardTag} at render time and by freeze-archives analysis
|
|
7
|
+
# without Liquid evaluation. Tokens are returned as written in source.
|
|
8
|
+
module LinkcardMarkup
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parse linkcard markup into URL, title, and archive components
|
|
12
|
+
#
|
|
13
|
+
# @param markup [String] the tag markup (contents between tag name and `%}`)
|
|
14
|
+
# @return [Hash] keys +:url+, optional +:title+, optional +:archive+ (unevaluated)
|
|
15
|
+
def split(markup)
|
|
16
|
+
tokens = tokenize(markup)
|
|
17
|
+
|
|
18
|
+
result = {}
|
|
19
|
+
result[:url] = tokens.shift
|
|
20
|
+
|
|
21
|
+
title_tokens = []
|
|
22
|
+
tokens.each do |token|
|
|
23
|
+
if token.start_with?("archive:")
|
|
24
|
+
result[:archive] = token.delete_prefix("archive:")
|
|
25
|
+
else
|
|
26
|
+
title_tokens << token
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
result[:title] = title_tokens.join(" ") unless title_tokens.empty?
|
|
30
|
+
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Tokenize markup respecting quotes and Liquid brace depth
|
|
35
|
+
#
|
|
36
|
+
# @param markup [String] the tag markup
|
|
37
|
+
# @return [Array<String>] non-empty tokens
|
|
38
|
+
def tokenize(markup)
|
|
39
|
+
tokens = []
|
|
40
|
+
current = ""
|
|
41
|
+
in_quotes = false
|
|
42
|
+
quote_char = nil
|
|
43
|
+
in_liquid = 0
|
|
44
|
+
escaped = false
|
|
45
|
+
|
|
46
|
+
"#{markup} ".each_char do |char|
|
|
47
|
+
if escaped
|
|
48
|
+
current += char
|
|
49
|
+
escaped = false
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if char == "\\" && in_quotes
|
|
54
|
+
escaped = true
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if char == "{" && !in_quotes
|
|
59
|
+
in_liquid += 1
|
|
60
|
+
current += char
|
|
61
|
+
elsif char == "}" && in_liquid.positive? && !in_quotes
|
|
62
|
+
in_liquid -= 1
|
|
63
|
+
current += char
|
|
64
|
+
elsif char == '"' && !in_quotes
|
|
65
|
+
in_quotes = true
|
|
66
|
+
quote_char = char
|
|
67
|
+
current += char
|
|
68
|
+
elsif char == quote_char
|
|
69
|
+
in_quotes = false
|
|
70
|
+
current += char
|
|
71
|
+
quote_char = nil
|
|
72
|
+
elsif char.match?(/\s/) && !in_quotes && in_liquid.zero?
|
|
73
|
+
tokens << current
|
|
74
|
+
current = ""
|
|
75
|
+
else
|
|
76
|
+
current += char
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
tokens.reject!(&:empty?)
|
|
80
|
+
tokens
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
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
|
|
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,73 +62,7 @@ module JekyllHighlightCards
|
|
|
66
62
|
# @param markup [String] the tag markup
|
|
67
63
|
# @return [Hash] parsed components
|
|
68
64
|
def split_markup(markup)
|
|
69
|
-
|
|
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.positive?
|
|
97
|
-
in_liquid -= 1
|
|
98
|
-
current += char
|
|
99
|
-
# Track quote boundaries
|
|
100
|
-
elsif ['"', "'"].include?(char) && !in_quotes && in_liquid.zero?
|
|
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.zero?
|
|
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
|
|
65
|
+
LinkcardMarkup.split(markup)
|
|
136
66
|
end
|
|
137
67
|
|
|
138
68
|
# Resolve URL from token (may be Liquid expression or literal)
|
|
@@ -141,9 +71,7 @@ module JekyllHighlightCards
|
|
|
141
71
|
# @param context [Liquid::Context] the Liquid context
|
|
142
72
|
# @return [String] resolved URL
|
|
143
73
|
def resolve_url(token, context)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
evaluate_expression(token, context, allow_nil: false)
|
|
74
|
+
evaluate_expression(token, context)
|
|
147
75
|
end
|
|
148
76
|
|
|
149
77
|
# Resolve title from source (may be Liquid expression or literal)
|
|
@@ -152,9 +80,7 @@ module JekyllHighlightCards
|
|
|
152
80
|
# @param context [Liquid::Context] the Liquid context
|
|
153
81
|
# @return [String, nil] resolved title
|
|
154
82
|
def resolve_title(source, context)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
evaluate_expression(source, context, allow_nil: true)
|
|
83
|
+
evaluate_expression(source, context)
|
|
158
84
|
end
|
|
159
85
|
|
|
160
86
|
# Resolve archive URL (may be explicit, auto-lookup, or opt-out)
|
|
@@ -168,12 +94,9 @@ module JekyllHighlightCards
|
|
|
168
94
|
return nil if source && source.downcase == "none"
|
|
169
95
|
|
|
170
96
|
# Check for explicit archive URL
|
|
171
|
-
return evaluate_expression(source, context
|
|
172
|
-
|
|
173
|
-
# Auto-lookup if enabled
|
|
174
|
-
return archive_url_for(url) if archive_enabled?
|
|
97
|
+
return evaluate_expression(source, context) if source && !source.empty?
|
|
175
98
|
|
|
176
|
-
|
|
99
|
+
archive_enabled? && archive_url_for(url, site: context.registers[:site])
|
|
177
100
|
end
|
|
178
101
|
|
|
179
102
|
# Build template variables hash for rendering
|
|
@@ -183,19 +106,17 @@ module JekyllHighlightCards
|
|
|
183
106
|
# @param archive_url [String, nil] the archive URL
|
|
184
107
|
# @return [Hash] template variables with raw and escaped versions
|
|
185
108
|
def build_template_variables(url, title, archive_url)
|
|
186
|
-
display_url = strip_protocol(url)
|
|
187
|
-
display_url = display_url.sub(%r{/$}, "")
|
|
109
|
+
display_url = strip_protocol(url).delete_suffix("/")
|
|
188
110
|
|
|
189
|
-
{
|
|
190
|
-
"url" => url,
|
|
191
|
-
"display_url" => display_url,
|
|
111
|
+
variables = {
|
|
192
112
|
"title" => title,
|
|
193
113
|
"archive_url" => archive_url,
|
|
194
114
|
"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
|
|
115
|
+
"escaped_display_url" => CGI.escapeHTML(display_url)
|
|
198
116
|
}
|
|
117
|
+
variables["escaped_title"] = CGI.escapeHTML(title) if title
|
|
118
|
+
variables["escaped_archive_url"] = CGI.escapeHTML(archive_url) if archive_url
|
|
119
|
+
variables
|
|
199
120
|
end
|
|
200
121
|
|
|
201
122
|
# Strip protocol from URL for display
|
|
@@ -203,7 +124,7 @@ module JekyllHighlightCards
|
|
|
203
124
|
# @param url [String] the URL
|
|
204
125
|
# @return [String] URL without protocol
|
|
205
126
|
def strip_protocol(url)
|
|
206
|
-
url.
|
|
127
|
+
url.delete_prefix("https://").delete_prefix("http://")
|
|
207
128
|
end
|
|
208
129
|
end
|
|
209
130
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllHighlightCards
|
|
4
|
+
# Shared structural parser for `{% polaroid %}` markup.
|
|
5
|
+
#
|
|
6
|
+
# Used by {PolaroidTag} at render time (with Liquid evaluation applied by the
|
|
7
|
+
# tag) and by freeze-archives analysis on unevaluated tokens.
|
|
8
|
+
module PolaroidMarkup
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parse polaroid markup into image URL and named parameters (unevaluated)
|
|
12
|
+
#
|
|
13
|
+
# @param markup [String] the tag markup (contents between tag name and `%}`)
|
|
14
|
+
# @return [Hash] +:image_url+ plus any +key=value+ params as symbols (raw tokens)
|
|
15
|
+
def parse(markup)
|
|
16
|
+
tokens = tokenize(markup)
|
|
17
|
+
|
|
18
|
+
image_url_token = tokens.shift
|
|
19
|
+
result = { image_url: image_url_token }
|
|
20
|
+
tokens.each do |token|
|
|
21
|
+
next unless token =~ /\A(\w+)=(.+)\z/
|
|
22
|
+
|
|
23
|
+
key = Regexp.last_match(1).to_sym
|
|
24
|
+
value_token = Regexp.last_match(2)
|
|
25
|
+
result[key] = value_token
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Tokenize markup respecting quotes (single or double) and Liquid brace depth
|
|
32
|
+
#
|
|
33
|
+
# @param markup [String] the tag markup
|
|
34
|
+
# @return [Array<String>] tokens (may include empty strings from leading whitespace)
|
|
35
|
+
def tokenize(markup)
|
|
36
|
+
tokens = []
|
|
37
|
+
current = ""
|
|
38
|
+
in_quotes = false
|
|
39
|
+
quote_char = nil
|
|
40
|
+
in_liquid = 0
|
|
41
|
+
escaped = false
|
|
42
|
+
|
|
43
|
+
# Trailing space flushes the final token through the whitespace branch
|
|
44
|
+
"#{markup} ".each_char do |char|
|
|
45
|
+
if escaped
|
|
46
|
+
current += char
|
|
47
|
+
escaped = false
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if char == "\\" && in_quotes
|
|
52
|
+
escaped = true
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if char == "{" && !in_quotes
|
|
57
|
+
in_liquid += 1
|
|
58
|
+
current += char
|
|
59
|
+
elsif char == "}" && in_liquid.positive? && !in_quotes
|
|
60
|
+
in_liquid -= 1
|
|
61
|
+
current += char
|
|
62
|
+
elsif ['"', "'"].include?(char) && !in_quotes
|
|
63
|
+
in_quotes = true
|
|
64
|
+
quote_char = char
|
|
65
|
+
current += char
|
|
66
|
+
elsif char == quote_char
|
|
67
|
+
in_quotes = false
|
|
68
|
+
current += char
|
|
69
|
+
quote_char = nil
|
|
70
|
+
elsif char.match?(/\s/) && !in_quotes && in_liquid.zero?
|
|
71
|
+
tokens << current
|
|
72
|
+
current = ""
|
|
73
|
+
else
|
|
74
|
+
current += char
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tokens
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|