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.
@@ -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 ![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
@@ -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[: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,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
- # 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.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
- return nil if token.nil? || token.empty?
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
- return nil if source.nil? || source.empty?
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, allow_nil: true) if source && !source.empty?
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
- nil
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.sub(%r{^https?://}, "")
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