sevk 1.0.0 → 1.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.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module StyleHelpers
6
+ STYLE_ORDER = %w[
7
+ color background-color background-image background-size background-position background-repeat
8
+ font-size font-family font-weight line-height text-align text-decoration
9
+ width height max-width max-height min-width min-height
10
+ padding padding-top padding-right padding-bottom padding-left
11
+ margin margin-top margin-right margin-bottom margin-left
12
+ border border-color border-width border-style
13
+ border-top border-right border-bottom border-left
14
+ border-radius border-top-left-radius border-top-right-radius
15
+ border-bottom-left-radius border-bottom-right-radius
16
+ border-collapse list-style-type vertical-align
17
+ ].freeze
18
+
19
+ private
20
+
21
+ def parse_attributes(attrs_str)
22
+ attrs = {}
23
+ attrs_str.scan(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/) do |key, dq_val, sq_val|
24
+ attrs[key] = dq_val.nil? || dq_val.empty? ? (sq_val || '') : dq_val
25
+ end
26
+ attrs
27
+ end
28
+
29
+ def extract_all_style_attributes(attrs)
30
+ attrs = attrs.reject { |_, value| value.to_s.empty? }
31
+ style = {}
32
+
33
+ if attrs['text-color']
34
+ style['color'] = attrs['text-color']
35
+ elsif attrs['color']
36
+ style['color'] = attrs['color']
37
+ end
38
+ style['background-color'] = attrs['background-color'] if attrs['background-color']
39
+ # Background image group lives between background-color and font-size to
40
+ # match the reference extractStyleAttributes insertion order.
41
+ bg_image = attrs['background-image']
42
+ style['background-image'] = "url('#{bg_image}')" if bg_image
43
+ if attrs['background-size']
44
+ style['background-size'] = attrs['background-size']
45
+ elsif bg_image
46
+ style['background-size'] = 'cover'
47
+ end
48
+ if attrs['background-position']
49
+ style['background-position'] = attrs['background-position']
50
+ elsif bg_image
51
+ style['background-position'] = 'center'
52
+ end
53
+ if attrs['background-repeat']
54
+ style['background-repeat'] = attrs['background-repeat']
55
+ elsif bg_image
56
+ style['background-repeat'] = 'no-repeat'
57
+ end
58
+ style['font-size'] = attrs['font-size'] if attrs['font-size']
59
+ style['font-family'] = attrs['font-family'] if attrs['font-family']
60
+ style['font-weight'] = attrs['font-weight'] if attrs['font-weight']
61
+ style['line-height'] = attrs['line-height'] if attrs['line-height']
62
+ style['text-align'] = attrs['text-align'] if attrs['text-align']
63
+ style['text-decoration'] = attrs['text-decoration'] if attrs['text-decoration']
64
+
65
+ style['width'] = attrs['width'] if attrs['width']
66
+ style['height'] = attrs['height'] if attrs['height']
67
+ style['max-width'] = attrs['max-width'] if attrs['max-width']
68
+ style['max-height'] = attrs['max-height'] if attrs['max-height']
69
+ style['min-width'] = attrs['min-width'] if attrs['min-width']
70
+ style['min-height'] = attrs['min-height'] if attrs['min-height']
71
+
72
+ # Spacing - Padding (shorthand + per-side both emit; per-side wins per CSS)
73
+ style['padding'] = attrs['padding'] if attrs['padding']
74
+ style['padding-top'] = attrs['padding-top'] if attrs['padding-top']
75
+ style['padding-right'] = attrs['padding-right'] if attrs['padding-right']
76
+ style['padding-bottom'] = attrs['padding-bottom'] if attrs['padding-bottom']
77
+ style['padding-left'] = attrs['padding-left'] if attrs['padding-left']
78
+
79
+ # Spacing - Margin (shorthand + per-side both emit; per-side wins per CSS)
80
+ style['margin'] = attrs['margin'] if attrs['margin']
81
+ style['margin-top'] = attrs['margin-top'] if attrs['margin-top']
82
+ style['margin-right'] = attrs['margin-right'] if attrs['margin-right']
83
+ style['margin-bottom'] = attrs['margin-bottom'] if attrs['margin-bottom']
84
+ style['margin-left'] = attrs['margin-left'] if attrs['margin-left']
85
+
86
+ # Borders cascade: shorthand -> per-property -> per-side (most specific last)
87
+ style['border'] = attrs['border'] if attrs['border']
88
+ style['border-color'] = attrs['border-color'] if attrs['border-color']
89
+ style['border-width'] = attrs['border-width'] if attrs['border-width']
90
+ style['border-style'] = attrs['border-style'] if attrs['border-style']
91
+ style['border-top'] = attrs['border-top'] if attrs['border-top']
92
+ style['border-right'] = attrs['border-right'] if attrs['border-right']
93
+ style['border-bottom'] = attrs['border-bottom'] if attrs['border-bottom']
94
+ style['border-left'] = attrs['border-left'] if attrs['border-left']
95
+
96
+ # Border Radius (shorthand + per-corner both emit; per-corner wins per CSS)
97
+ style['border-radius'] = attrs['border-radius'] if attrs['border-radius']
98
+ style['border-top-left-radius'] = attrs['border-top-left-radius'] if attrs['border-top-left-radius']
99
+ style['border-top-right-radius'] = attrs['border-top-right-radius'] if attrs['border-top-right-radius']
100
+ style['border-bottom-left-radius'] = attrs['border-bottom-left-radius'] if attrs['border-bottom-left-radius']
101
+ style['border-bottom-right-radius'] = attrs['border-bottom-right-radius'] if attrs['border-bottom-right-radius']
102
+
103
+ style
104
+ end
105
+
106
+ def class_attribute(attrs)
107
+ value = attrs['class'].to_s.empty? ? attrs['className'] : attrs['class']
108
+ value.to_s.empty? ? '' : %( class="#{value}")
109
+ end
110
+
111
+ def style_to_string(style)
112
+ seen = {}
113
+ parts = []
114
+ STYLE_ORDER.each do |key|
115
+ value = style[key]
116
+ next if value.nil? || value == ''
117
+
118
+ parts << "#{key}:#{value}"
119
+ seen[key] = true
120
+ end
121
+ style.each do |key, value|
122
+ next if seen[key]
123
+ next if value.nil? || value == ''
124
+
125
+ parts << "#{key}:#{value}"
126
+ end
127
+ parts.join(';')
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module SyntaxHighlighting
6
+ MAX_CODEBLOCK_LENGTH = 2500
7
+ MAX_TOTAL_CODEBLOCK_LENGTH = 2500
8
+
9
+ LANGUAGES = %w[
10
+ plain extend plaintext text txt markup html mathml svg xml ssml atom rss css clike javascript js typescript ts
11
+ jsx tsx python py java c cpp csharp cs dotnet php ruby rb go rust sql bash sh shell json webmanifest
12
+ yaml yml markdown md
13
+ ].freeze
14
+
15
+ LANGUAGE_ALIASES = { 'jsx' => 'javascript', 'tsx' => 'typescript' }.freeze
16
+
17
+ ROUGE_LEXERS = {
18
+ 'markup' => 'html', 'mathml' => 'xml', 'svg' => 'xml', 'ssml' => 'xml', 'atom' => 'xml', 'rss' => 'xml',
19
+ 'dotnet' => 'csharp', 'webmanifest' => 'json', 'plain' => nil, 'plaintext' => nil, 'text' => nil,
20
+ 'txt' => nil, 'clike' => nil
21
+ }.freeze
22
+
23
+ THEME_BASES = {
24
+ 'vscDarkPlus' => {
25
+ 'color' => '#d4d4d4', 'fontSize' => '13px', 'textShadow' => 'none',
26
+ 'fontFamily' => "Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', 'Courier New', monospace",
27
+ 'direction' => 'ltr', 'textAlign' => 'left', 'whiteSpace' => 'pre', 'wordSpacing' => 'normal',
28
+ 'wordBreak' => 'normal', 'lineHeight' => '1.5', 'MozTabSize' => '4', 'OTabSize' => '4', 'tabSize' => '4',
29
+ 'WebkitHyphens' => 'none', 'MozHyphens' => 'none', 'hyphens' => 'none', 'padding' => '16px',
30
+ 'overflow' => 'auto', 'backgroundColor' => '#1e1e1e'
31
+ },
32
+ 'vs' => {
33
+ 'color' => '#393A34',
34
+ 'fontFamily' => "'Consolas', 'Bitstream Vera Sans Mono', 'Courier New', Courier, monospace",
35
+ 'direction' => 'ltr', 'textAlign' => 'left', 'whiteSpace' => 'pre', 'wordSpacing' => 'normal',
36
+ 'wordBreak' => 'normal', 'fontSize' => '.9em', 'lineHeight' => '1.2em', 'MozTabSize' => '4',
37
+ 'OTabSize' => '4', 'tabSize' => '4', 'WebkitHyphens' => 'none', 'MozHyphens' => 'none',
38
+ 'hyphens' => 'none', 'padding' => '16px', 'overflow' => 'auto', 'border' => '1px solid #dddddd',
39
+ 'backgroundColor' => 'white'
40
+ },
41
+ 'oneDark' => {
42
+ 'backgroundColor' => '#282c34', 'color' => '#abb2bf', 'textShadow' => '0 1px rgba(0, 0, 0, 0.3)',
43
+ 'fontFamily' => "'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
44
+ 'direction' => 'ltr', 'textAlign' => 'left', 'whiteSpace' => 'pre', 'wordSpacing' => 'normal',
45
+ 'wordBreak' => 'normal', 'lineHeight' => '1.5', 'MozTabSize' => '2', 'OTabSize' => '2', 'tabSize' => '2',
46
+ 'WebkitHyphens' => 'none', 'MozHyphens' => 'none', 'hyphens' => 'none', 'padding' => '16px',
47
+ 'overflow' => 'auto', 'borderRadius' => '6px'
48
+ },
49
+ 'oneLight' => {
50
+ 'backgroundColor' => '#fafafa', 'color' => '#383a42',
51
+ 'fontFamily' => "'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
52
+ 'direction' => 'ltr', 'textAlign' => 'left', 'whiteSpace' => 'pre', 'wordSpacing' => 'normal',
53
+ 'wordBreak' => 'normal', 'lineHeight' => '1.5', 'MozTabSize' => '2', 'OTabSize' => '2', 'tabSize' => '2',
54
+ 'WebkitHyphens' => 'none', 'MozHyphens' => 'none', 'hyphens' => 'none', 'padding' => '16px',
55
+ 'overflow' => 'auto', 'borderRadius' => '6px'
56
+ }
57
+ }.freeze
58
+
59
+ THEME_TOKENS = {
60
+ 'vscDarkPlus' => {
61
+ 'comment' => { 'color' => '#6a9955' }, 'prolog' => { 'color' => '#6a9955' },
62
+ 'punctuation' => { 'color' => '#d4d4d4' }, 'property' => { 'color' => '#9cdcfe' },
63
+ 'tag' => { 'color' => '#569cd6' }, 'boolean' => { 'color' => '#569cd6' },
64
+ 'number' => { 'color' => '#b5cea8' }, 'constant' => { 'color' => '#9cdcfe' },
65
+ 'symbol' => { 'color' => '#b5cea8' }, 'inserted' => { 'color' => '#b5cea8' },
66
+ 'selector' => { 'color' => '#d7ba7d' }, 'attr-name' => { 'color' => '#9cdcfe' },
67
+ 'string' => { 'color' => '#ce9178' }, 'char' => { 'color' => '#ce9178' },
68
+ 'builtin' => { 'color' => '#ce9178' }, 'deleted' => { 'color' => '#ce9178' },
69
+ 'operator' => { 'color' => '#d4d4d4' }, 'entity' => { 'color' => '#569cd6' },
70
+ 'atrule' => { 'color' => '#ce9178' }, 'keyword' => { 'color' => '#569CD6' },
71
+ 'function' => { 'color' => '#dcdcaa' }, 'regex' => { 'color' => '#d16969' },
72
+ 'important' => { 'color' => '#569cd6' }, 'italic' => { 'font-style' => 'italic' },
73
+ 'class-name' => { 'color' => '#4ec9b0' }, 'variable' => { 'color' => '#9cdcfe' },
74
+ 'attr-value' => { 'color' => '#ce9178' }, 'cdata' => { 'color' => '#808080' },
75
+ 'namespace' => { 'color' => '#4ec9b0' }
76
+ },
77
+ 'vs' => {
78
+ 'comment' => { 'color' => '#008000', 'font-style' => 'italic' },
79
+ 'prolog' => { 'color' => '#008000', 'font-style' => 'italic' },
80
+ 'doctype' => { 'color' => '#008000', 'font-style' => 'italic' },
81
+ 'cdata' => { 'color' => '#008000', 'font-style' => 'italic' }, 'namespace' => { 'opacity' => '.7' },
82
+ 'string' => { 'color' => '#A31515' }, 'punctuation' => { 'color' => '#393A34' },
83
+ 'operator' => { 'color' => '#393A34' }, 'url' => { 'color' => '#36acaa' },
84
+ 'symbol' => { 'color' => '#36acaa' }, 'number' => { 'color' => '#36acaa' },
85
+ 'boolean' => { 'color' => '#36acaa' }, 'variable' => { 'color' => '#36acaa' },
86
+ 'constant' => { 'color' => '#36acaa' }, 'inserted' => { 'color' => '#36acaa' },
87
+ 'atrule' => { 'color' => '#0000ff' }, 'keyword' => { 'color' => '#0000ff' },
88
+ 'attr-value' => { 'color' => '#0000ff' }, 'function' => { 'color' => '#393A34' },
89
+ 'deleted' => { 'color' => '#9a050f' }, 'selector' => { 'color' => '#800000' },
90
+ 'important' => { 'color' => '#e90', 'font-weight' => 'bold' }, 'bold' => { 'font-weight' => 'bold' },
91
+ 'italic' => { 'font-style' => 'italic' }, 'class-name' => { 'color' => '#2B91AF' },
92
+ 'tag' => { 'color' => '#800000' }, 'attr-name' => { 'color' => '#ff0000' },
93
+ 'property' => { 'color' => '#ff0000' }, 'regex' => { 'color' => '#ff0000' },
94
+ 'entity' => { 'color' => '#ff0000' }
95
+ },
96
+ 'oneDark' => {
97
+ 'comment' => { 'color' => '#5c6370', 'font-style' => 'italic' }, 'prolog' => { 'color' => '#5c6370' },
98
+ 'cdata' => { 'color' => '#5c6370' }, 'doctype' => { 'color' => '#abb2bf' },
99
+ 'punctuation' => { 'color' => '#abb2bf' }, 'entity' => { 'color' => '#abb2bf', 'cursor' => 'help' },
100
+ 'attr-name' => { 'color' => '#d19a66' }, 'class-name' => { 'color' => '#d19a66' },
101
+ 'boolean' => { 'color' => '#d19a66' }, 'constant' => { 'color' => '#d19a66' },
102
+ 'number' => { 'color' => '#d19a66' }, 'atrule' => { 'color' => '#d19a66' },
103
+ 'keyword' => { 'color' => '#c678dd' }, 'property' => { 'color' => '#e06c75' },
104
+ 'tag' => { 'color' => '#e06c75' }, 'symbol' => { 'color' => '#e06c75' },
105
+ 'deleted' => { 'color' => '#e06c75' }, 'important' => { 'color' => '#e06c75' },
106
+ 'selector' => { 'color' => '#98c379' }, 'string' => { 'color' => '#98c379' },
107
+ 'char' => { 'color' => '#98c379' }, 'builtin' => { 'color' => '#98c379' },
108
+ 'inserted' => { 'color' => '#98c379' }, 'regex' => { 'color' => '#98c379' },
109
+ 'attr-value' => { 'color' => '#98c379' }, 'variable' => { 'color' => '#61afef' },
110
+ 'operator' => { 'color' => '#61afef' }, 'function' => { 'color' => '#61afef' },
111
+ 'url' => { 'color' => '#56b6c2' }, 'bold' => { 'font-weight' => 'bold' },
112
+ 'italic' => { 'font-style' => 'italic' }, 'namespace' => { 'opacity' => '0.8' }
113
+ },
114
+ 'oneLight' => {
115
+ 'comment' => { 'color' => '#a0a1a7', 'font-style' => 'italic' }, 'prolog' => { 'color' => '#a0a1a7' },
116
+ 'cdata' => { 'color' => '#a0a1a7' }, 'doctype' => { 'color' => '#383a42' },
117
+ 'punctuation' => { 'color' => '#383a42' }, 'entity' => { 'color' => '#383a42', 'cursor' => 'help' },
118
+ 'attr-name' => { 'color' => '#b76b01' }, 'class-name' => { 'color' => '#b76b01' },
119
+ 'boolean' => { 'color' => '#b76b01' }, 'constant' => { 'color' => '#b76b01' },
120
+ 'number' => { 'color' => '#b76b01' }, 'atrule' => { 'color' => '#b76b01' },
121
+ 'keyword' => { 'color' => '#a626a4' }, 'property' => { 'color' => '#e45649' },
122
+ 'tag' => { 'color' => '#e45649' }, 'symbol' => { 'color' => '#e45649' },
123
+ 'deleted' => { 'color' => '#e45649' }, 'important' => { 'color' => '#e45649' },
124
+ 'selector' => { 'color' => '#50a14f' }, 'string' => { 'color' => '#50a14f' },
125
+ 'char' => { 'color' => '#50a14f' }, 'builtin' => { 'color' => '#50a14f' },
126
+ 'inserted' => { 'color' => '#50a14f' }, 'regex' => { 'color' => '#50a14f' },
127
+ 'attr-value' => { 'color' => '#50a14f' }, 'variable' => { 'color' => '#4078f2' },
128
+ 'operator' => { 'color' => '#4078f2' }, 'function' => { 'color' => '#4078f2' },
129
+ 'url' => { 'color' => '#0184bc' }, 'bold' => { 'font-weight' => 'bold' },
130
+ 'italic' => { 'font-style' => 'italic' }, 'namespace' => { 'opacity' => '0.8' }
131
+ }
132
+ }.freeze
133
+
134
+ ROUGE_TOKENS = {
135
+ 'Comment' => 'comment', 'Keyword' => 'keyword', 'Keyword.Constant' => 'boolean', 'Keyword.Type' => 'class-name',
136
+ 'Literal.String' => 'string', 'Literal.String.Regex' => 'regex', 'Literal.String.Symbol' => 'symbol',
137
+ 'Literal.String.Escape' => 'string', 'Literal.Number' => 'number', 'Name.Function' => 'function',
138
+ 'Name.Class' => 'class-name', 'Name.Constant' => 'constant', 'Name.Builtin' => 'builtin',
139
+ 'Name.Variable' => 'variable', 'Name.Tag' => 'tag', 'Name.Attribute' => 'attr-name',
140
+ 'Name.Decorator' => 'function', 'Name.Namespace' => 'namespace', 'Name.Entity' => 'entity',
141
+ 'Name.Label' => 'symbol', 'Name.Property' => 'property', 'Operator' => 'operator',
142
+ 'Operator.Word' => 'keyword', 'Punctuation' => 'punctuation', 'Generic.Deleted' => 'deleted',
143
+ 'Generic.Inserted' => 'inserted', 'Generic.Emph' => 'italic', 'Generic.Strong' => 'bold',
144
+ 'Generic.Heading' => 'important', 'Generic.Subheading' => 'important'
145
+ }.freeze
146
+
147
+ TOKEN_SPACE = "\u00A0\u200D\u200B"
148
+
149
+ private
150
+
151
+ def process_codeblock(attrs, code)
152
+ requested = (attrs['language'].to_s.empty? ? 'javascript' : attrs['language']).downcase
153
+ language = LANGUAGE_ALIASES.fetch(requested, requested)
154
+ theme = THEME_BASES.key?(attrs['theme'].to_s) ? attrs['theme'] : 'oneDark'
155
+ custom_style = extract_all_style_attributes(attrs)
156
+
157
+ return '<pre><code></code></pre>' if code.empty?
158
+
159
+ length = code.length + code.count("\u{10000}-\u{10FFFF}")
160
+
161
+ if !LANGUAGES.include?(language) || length > MAX_CODEBLOCK_LENGTH ||
162
+ @codeblock_budget + length > MAX_TOTAL_CODEBLOCK_LENGTH
163
+ style = pre_style(THEME_BASES[theme], { 'width' => '100%' }, custom_style)
164
+ escaped = code.gsub('<', '&lt;').gsub('>', '&gt;')
165
+ return %(<pre style="#{style}"><code>#{escaped}</code></pre>)
166
+ end
167
+
168
+ @codeblock_budget += length
169
+ inherited = attrs['font-family'].to_s.empty? ? {} : { 'font-family' => attrs['font-family'] }
170
+ lines = highlight_lines(code, language, theme, inherited)
171
+ numbered = attrs['line-numbers'] == 'true'
172
+ html = lines.each_with_index.map do |line, index|
173
+ number = numbered ? %(<span style="width:2em;display:inline-block">#{index + 1}</span>) : ''
174
+ %(<p style="margin:0;min-height:1em">#{number}#{line}</p>)
175
+ end.join
176
+ style = pre_style(THEME_BASES[theme], { 'width' => '100%', 'box-sizing' => 'border-box' }, custom_style)
177
+ %(<pre style="#{style}"><code>#{html}</code></pre>)
178
+ end
179
+
180
+ def pre_style(*layers)
181
+ style = {}
182
+ layers.each { |layer| layer.each { |key, value| style[key] = value } }
183
+ style.map do |key, value|
184
+ property = key.gsub(/[A-Z]/) { |letter| "-#{letter.downcase}" }
185
+ property = "-#{property}" if property.start_with?('ms-')
186
+ "#{property}:#{value}"
187
+ end.join(';')
188
+ end
189
+
190
+ def highlight_lines(code, language, theme, inherited)
191
+ lexer_name = ROUGE_LEXERS.fetch(language, language)
192
+ lexer = lexer_name && Rouge::Lexer.find(lexer_name)&.new
193
+ tokens = [[nil, code]]
194
+ tokens = lexer.lex(code).map { |type, text| [token_style(theme, type, inherited), text] } if lexer
195
+ inherited_style = style_to_string(inherited)
196
+ lines = [+'']
197
+
198
+ tokens.each do |style, text|
199
+ style = inherited_style if style.nil? && !inherited.empty?
200
+ text.split("\n", -1).each_with_index do |part, index|
201
+ lines << +'' if index.positive?
202
+ next if part.empty?
203
+
204
+ escaped = part.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
205
+ .gsub("'", '&#39;').gsub(' ', TOKEN_SPACE)
206
+ lines.last << (style.nil? || style.empty? ? escaped : %(<span style="#{style}">#{escaped}</span>))
207
+ end
208
+ end
209
+
210
+ lines
211
+ end
212
+
213
+ def token_style(theme, type, inherited)
214
+ qualname = type.qualname
215
+ token = nil
216
+ until token || qualname.nil?
217
+ token = ROUGE_TOKENS[qualname]
218
+ dot = qualname.rindex('.')
219
+ qualname = dot ? qualname[0...dot] : nil
220
+ end
221
+ return nil if token.nil?
222
+
223
+ style_to_string(inherited.merge(THEME_TOKENS[theme].fetch(token, {})))
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module TagScanner
6
+ TagPatterns = Struct.new(:open, :close, :nested, :close_length)
7
+
8
+ def tag_patterns(tag_name)
9
+ TagPatterns.new(/<#{tag_name}([^>]*)>/i, %r{</#{tag_name}>}i, /<#{tag_name}/i, tag_name.length + 3)
10
+ end
11
+
12
+ def replace_self_closing(source, tag, self_close_only: false)
13
+ open_pattern = /<#{tag}/i
14
+ name_length = tag.length + 1
15
+ output = +''
16
+ last_index = 0
17
+ position = 0
18
+
19
+ while (opening = source.index(open_pattern, position))
20
+ closing_bracket = source.index('>', opening + name_length)
21
+ break unless closing_bracket
22
+
23
+ position = closing_bracket + 1
24
+ next if self_close_only && source[closing_bracket - 1] != '/'
25
+
26
+ attributes_end = self_close_only ? closing_bracket - 1 : closing_bracket
27
+ output << source[last_index...opening] << yield(source[(opening + name_length)...attributes_end])
28
+ last_index = closing_bracket + 1
29
+ end
30
+
31
+ output << source[last_index..]
32
+ end
33
+
34
+ def get_first_tag_content(source, tag)
35
+ scanner = StringScanner.new(source)
36
+ return nil unless scanner.skip_until(/<#{tag}/i) && scanner.skip_until(/>/)
37
+
38
+ content_start = scanner.pos
39
+ return nil unless scanner.skip_until(%r{</#{tag}>}i)
40
+
41
+ source.byteslice(content_start, scanner.pos - (tag.length + 3) - content_start)
42
+ end
43
+
44
+ def get_all_tag_contents(source, tag)
45
+ open_pattern = /<#{tag}/i
46
+ close_pattern = %r{</#{tag}>}i
47
+ scanner = StringScanner.new(source)
48
+ contents = []
49
+
50
+ while scanner.skip_until(open_pattern) && scanner.skip_until(/>/)
51
+ content_start = scanner.pos
52
+ break unless scanner.skip_until(close_pattern)
53
+
54
+ contents << source.byteslice(content_start, scanner.pos - (tag.length + 3) - content_start)
55
+ end
56
+
57
+ contents
58
+ end
59
+
60
+ def strip_tag_pairs(source, tag)
61
+ open_pattern = /<#{tag}/i
62
+ close_pattern = %r{</#{tag}>}i
63
+ scanner = StringScanner.new(source)
64
+ output = +''
65
+ last_index = 0
66
+
67
+ while scanner.skip_until(open_pattern)
68
+ opening = scanner.pos - scanner.matched_size
69
+ break unless scanner.skip_until(/>/) && scanner.skip_until(close_pattern)
70
+
71
+ output << source.byteslice(last_index, opening - last_index)
72
+ last_index = scanner.pos
73
+ end
74
+
75
+ output << source.byteslice(last_index, source.bytesize - last_index)
76
+ end
77
+
78
+ def get_tag_attributes(source, tag)
79
+ open_pattern = /<#{tag}/i
80
+ scanner = StringScanner.new(source)
81
+ attributes = []
82
+
83
+ while scanner.skip_until(open_pattern)
84
+ attributes_start = scanner.pos
85
+ break unless scanner.skip_until(/>/)
86
+
87
+ attributes << source.byteslice(attributes_start, scanner.pos - 1 - attributes_start)
88
+ end
89
+
90
+ attributes
91
+ end
92
+
93
+ def find_hidden_preview_div(html)
94
+ position = 0
95
+
96
+ while (opening = html.index(/<div/i, position))
97
+ closing_bracket = html.index('>', opening + 4)
98
+ return nil unless closing_bracket
99
+
100
+ if html[opening..closing_bracket].match?(/style=["'][^"']*display:\s*none/i)
101
+ closing = html.index(%r{</div>}i, closing_bracket + 1)
102
+ return nil unless closing
103
+
104
+ return [html[opening...(closing + 6)], html[(closing_bracket + 1)...closing]]
105
+ end
106
+
107
+ position = closing_bracket + 1
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end