cataract 0.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.
- checksums.yaml +7 -0
- data/.clang-tidy +30 -0
- data/.github/workflows/ci-macos.yml +12 -0
- data/.github/workflows/ci.yml +77 -0
- data/.github/workflows/test.yml +76 -0
- data/.gitignore +45 -0
- data/.overcommit.yml +38 -0
- data/.rubocop.yml +83 -0
- data/BENCHMARKS.md +201 -0
- data/CHANGELOG.md +1 -0
- data/Gemfile +27 -0
- data/LICENSE +21 -0
- data/RAGEL_MIGRATION.md +60 -0
- data/README.md +292 -0
- data/Rakefile +209 -0
- data/benchmarks/benchmark_harness.rb +193 -0
- data/benchmarks/benchmark_merging.rb +121 -0
- data/benchmarks/benchmark_optimization_comparison.rb +168 -0
- data/benchmarks/benchmark_parsing.rb +153 -0
- data/benchmarks/benchmark_ragel_removal.rb +56 -0
- data/benchmarks/benchmark_runner.rb +70 -0
- data/benchmarks/benchmark_serialization.rb +180 -0
- data/benchmarks/benchmark_shorthand.rb +109 -0
- data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
- data/benchmarks/benchmark_specificity.rb +124 -0
- data/benchmarks/benchmark_string_allocation.rb +151 -0
- data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
- data/benchmarks/benchmark_to_s_cached.rb +55 -0
- data/benchmarks/benchmark_value_splitter.rb +54 -0
- data/benchmarks/benchmark_yjit.rb +158 -0
- data/benchmarks/benchmark_yjit_workers.rb +61 -0
- data/benchmarks/profile_to_s.rb +23 -0
- data/benchmarks/speedup_calculator.rb +83 -0
- data/benchmarks/system_metadata.rb +81 -0
- data/benchmarks/templates/benchmarks.md.erb +221 -0
- data/benchmarks/yjit_tests.rb +141 -0
- data/cataract.gemspec +34 -0
- data/cliff.toml +92 -0
- data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
- data/examples/color_conversion_visual_test/generate.rb +202 -0
- data/examples/color_conversion_visual_test/template.html.erb +259 -0
- data/examples/css_analyzer/analyzer.rb +164 -0
- data/examples/css_analyzer/analyzers/base.rb +33 -0
- data/examples/css_analyzer/analyzers/colors.rb +133 -0
- data/examples/css_analyzer/analyzers/important.rb +88 -0
- data/examples/css_analyzer/analyzers/properties.rb +61 -0
- data/examples/css_analyzer/analyzers/specificity.rb +68 -0
- data/examples/css_analyzer/templates/report.html.erb +575 -0
- data/examples/css_analyzer.rb +69 -0
- data/examples/github_analysis.html +5343 -0
- data/ext/cataract/cataract.c +1086 -0
- data/ext/cataract/cataract.h +174 -0
- data/ext/cataract/css_parser.c +1435 -0
- data/ext/cataract/extconf.rb +48 -0
- data/ext/cataract/import_scanner.c +174 -0
- data/ext/cataract/merge.c +973 -0
- data/ext/cataract/shorthand_expander.c +902 -0
- data/ext/cataract/specificity.c +213 -0
- data/ext/cataract/value_splitter.c +116 -0
- data/ext/cataract_color/cataract_color.c +16 -0
- data/ext/cataract_color/color_conversion.c +1687 -0
- data/ext/cataract_color/color_conversion.h +136 -0
- data/ext/cataract_color/color_conversion_lab.c +571 -0
- data/ext/cataract_color/color_conversion_named.c +259 -0
- data/ext/cataract_color/color_conversion_oklab.c +547 -0
- data/ext/cataract_color/extconf.rb +23 -0
- data/ext/cataract_old/cataract.c +393 -0
- data/ext/cataract_old/cataract.h +250 -0
- data/ext/cataract_old/css_parser.c +933 -0
- data/ext/cataract_old/extconf.rb +67 -0
- data/ext/cataract_old/import_scanner.c +174 -0
- data/ext/cataract_old/merge.c +776 -0
- data/ext/cataract_old/shorthand_expander.c +902 -0
- data/ext/cataract_old/specificity.c +213 -0
- data/ext/cataract_old/stylesheet.c +290 -0
- data/ext/cataract_old/value_splitter.c +116 -0
- data/lib/cataract/at_rule.rb +97 -0
- data/lib/cataract/color_conversion.rb +18 -0
- data/lib/cataract/declarations.rb +332 -0
- data/lib/cataract/import_resolver.rb +210 -0
- data/lib/cataract/rule.rb +131 -0
- data/lib/cataract/stylesheet.rb +716 -0
- data/lib/cataract/stylesheet_scope.rb +257 -0
- data/lib/cataract/version.rb +5 -0
- data/lib/cataract.rb +107 -0
- data/lib/tasks/gem.rake +158 -0
- data/scripts/fuzzer/run.rb +828 -0
- data/scripts/fuzzer/worker.rb +99 -0
- data/scripts/generate_benchmarks_md.rb +155 -0
- metadata +135 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cataract
|
|
4
|
+
# Chainable query scope for filtering Stylesheet rules.
|
|
5
|
+
#
|
|
6
|
+
# Inspired by ActiveRecord's Relation, StylesheetScope provides a fluent
|
|
7
|
+
# interface for filtering and querying CSS rules. Scopes are lazy - filters
|
|
8
|
+
# are only applied during iteration.
|
|
9
|
+
#
|
|
10
|
+
# @example Chaining filters
|
|
11
|
+
# sheet.with_media(:print).with_specificity(10..).select(&:selector?)
|
|
12
|
+
#
|
|
13
|
+
# @example Inspect shows results
|
|
14
|
+
# scope = sheet.with_media(:screen)
|
|
15
|
+
# scope.inspect #=> "#<Cataract::StylesheetScope [...]>"
|
|
16
|
+
class StylesheetScope
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
# @private
|
|
20
|
+
def initialize(stylesheet, filters = {})
|
|
21
|
+
@stylesheet = stylesheet
|
|
22
|
+
@filters = filters
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Filter by media query symbol(s).
|
|
26
|
+
#
|
|
27
|
+
# @param media [Symbol, Array<Symbol>] Media query symbol(s)
|
|
28
|
+
# @return [StylesheetScope] New scope with media filter applied
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# sheet.with_media(:print).with_media(:screen) # overwrites to :screen
|
|
32
|
+
def with_media(media)
|
|
33
|
+
StylesheetScope.new(@stylesheet, @filters.merge(media: media))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Filter by CSS specificity.
|
|
37
|
+
#
|
|
38
|
+
# @param specificity [Integer, Range] Specificity value or range
|
|
39
|
+
# @return [StylesheetScope] New scope with specificity filter applied
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# sheet.with_specificity(10) # exactly 10
|
|
43
|
+
# sheet.with_specificity(10..) # 10 or higher
|
|
44
|
+
# sheet.with_specificity(5...10) # between 5 and 9
|
|
45
|
+
def with_specificity(specificity)
|
|
46
|
+
StylesheetScope.new(@stylesheet, @filters.merge(specificity: specificity))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Filter by CSS selector.
|
|
50
|
+
#
|
|
51
|
+
# @param selector [String, Regexp] CSS selector to match (exact string or pattern)
|
|
52
|
+
# @return [StylesheetScope] New scope with selector filter applied
|
|
53
|
+
#
|
|
54
|
+
# @example Exact string match
|
|
55
|
+
# sheet.with_selector('body')
|
|
56
|
+
# sheet.with_media(:print).with_selector('.header')
|
|
57
|
+
#
|
|
58
|
+
# @example Pattern matching
|
|
59
|
+
# sheet.with_selector(/\.btn-/) # All .btn-* classes
|
|
60
|
+
# sheet.with_selector(/^#/) # All ID selectors
|
|
61
|
+
def with_selector(selector)
|
|
62
|
+
StylesheetScope.new(@stylesheet, @filters.merge(selector: selector))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Filter by CSS property name and optional value.
|
|
66
|
+
#
|
|
67
|
+
# @param property [String] CSS property name to match
|
|
68
|
+
# @param value [String, nil] Optional property value to match
|
|
69
|
+
# @return [StylesheetScope] New scope with property filter applied
|
|
70
|
+
#
|
|
71
|
+
# @example Find rules with color property
|
|
72
|
+
# sheet.with_property('color')
|
|
73
|
+
#
|
|
74
|
+
# @example Find rules with specific property value
|
|
75
|
+
# sheet.with_property('position', 'absolute')
|
|
76
|
+
# sheet.with_property('color', 'red')
|
|
77
|
+
def with_property(property, value = nil)
|
|
78
|
+
StylesheetScope.new(@stylesheet, @filters.merge(property: property, property_value: value))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Filter to only base rules (rules not inside any @media query).
|
|
82
|
+
#
|
|
83
|
+
# @return [StylesheetScope] New scope with base_only filter applied
|
|
84
|
+
#
|
|
85
|
+
# @example Get base rules only
|
|
86
|
+
# sheet.base_only.map(&:selector)
|
|
87
|
+
# sheet.base_only.with_property('color').to_a
|
|
88
|
+
def base_only
|
|
89
|
+
StylesheetScope.new(@stylesheet, @filters.merge(base_only: true))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Filter by at-rule type.
|
|
93
|
+
#
|
|
94
|
+
# @param type [Symbol] At-rule type to match (:keyframes, :font_face, etc.)
|
|
95
|
+
# @return [StylesheetScope] New scope with at-rule type filter applied
|
|
96
|
+
#
|
|
97
|
+
# @example Find all @keyframes
|
|
98
|
+
# sheet.with_at_rule_type(:keyframes)
|
|
99
|
+
#
|
|
100
|
+
# @example Find all @font-face
|
|
101
|
+
# sheet.with_at_rule_type(:font_face)
|
|
102
|
+
def with_at_rule_type(type)
|
|
103
|
+
StylesheetScope.new(@stylesheet, @filters.merge(at_rule_type: type))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Filter to rules with !important declarations.
|
|
107
|
+
#
|
|
108
|
+
# @param property [String, nil] Optional property name to match
|
|
109
|
+
# @return [StylesheetScope] New scope with important filter applied
|
|
110
|
+
#
|
|
111
|
+
# @example Find all rules with any !important
|
|
112
|
+
# sheet.with_important
|
|
113
|
+
#
|
|
114
|
+
# @example Find rules with color !important
|
|
115
|
+
# sheet.with_important('color')
|
|
116
|
+
def with_important(property = nil)
|
|
117
|
+
StylesheetScope.new(@stylesheet, @filters.merge(important: true, important_property: property))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Iterate over filtered rules.
|
|
121
|
+
#
|
|
122
|
+
# @yield [rule] Each rule matching the filters
|
|
123
|
+
# @yieldparam rule [Rule, AtRule] The rule object
|
|
124
|
+
# @return [Enumerator] Enumerator if no block given
|
|
125
|
+
def each
|
|
126
|
+
return enum_for(:each) unless block_given?
|
|
127
|
+
|
|
128
|
+
# Get base rules set
|
|
129
|
+
rules = if @filters[:base_only]
|
|
130
|
+
# Get rules not in any media query
|
|
131
|
+
media_index = @stylesheet.instance_variable_get(:@_media_index)
|
|
132
|
+
media_rule_ids = media_index.values.flatten.uniq
|
|
133
|
+
@stylesheet.rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
|
|
134
|
+
elsif @filters[:media]
|
|
135
|
+
media_array = Array(@filters[:media])
|
|
136
|
+
|
|
137
|
+
# :all is a special case meaning "all rules"
|
|
138
|
+
if media_array.include?(:all)
|
|
139
|
+
@stylesheet.rules
|
|
140
|
+
else
|
|
141
|
+
media_index = @stylesheet.instance_variable_get(:@_media_index)
|
|
142
|
+
rule_ids = media_array.flat_map { |m| media_index[m] || [] }.uniq
|
|
143
|
+
rule_ids.map { |id| @stylesheet.rules[id] }
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
@stylesheet.rules
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Apply additional filters during iteration
|
|
150
|
+
rules.each do |rule|
|
|
151
|
+
# Specificity filter
|
|
152
|
+
if @filters[:specificity]
|
|
153
|
+
next if rule.specificity.nil? # AtRules have nil specificity
|
|
154
|
+
next unless case @filters[:specificity]
|
|
155
|
+
when Range
|
|
156
|
+
@filters[:specificity].cover?(rule.specificity)
|
|
157
|
+
else
|
|
158
|
+
@filters[:specificity] == rule.specificity
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Selector filter (String or Regexp)
|
|
163
|
+
if @filters[:selector] && !case @filters[:selector]
|
|
164
|
+
when String
|
|
165
|
+
rule.selector == @filters[:selector]
|
|
166
|
+
when Regexp
|
|
167
|
+
@filters[:selector] =~ rule.selector
|
|
168
|
+
end
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Property filter
|
|
173
|
+
if @filters[:property] && !rule.has_property?(@filters[:property], @filters[:property_value])
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# At-rule type filter
|
|
178
|
+
if @filters[:at_rule_type] && !rule.at_rule_type?(@filters[:at_rule_type])
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Important filter
|
|
183
|
+
if @filters[:important] && !rule.has_important?(@filters[:important_property])
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
yield rule
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Get the number of rules matching the filters.
|
|
192
|
+
#
|
|
193
|
+
# Forces evaluation of the scope.
|
|
194
|
+
#
|
|
195
|
+
# @return [Integer] Number of matching rules
|
|
196
|
+
def size
|
|
197
|
+
to_a.size
|
|
198
|
+
end
|
|
199
|
+
alias length size
|
|
200
|
+
|
|
201
|
+
# Access a rule by index.
|
|
202
|
+
#
|
|
203
|
+
# Forces evaluation of the scope.
|
|
204
|
+
#
|
|
205
|
+
# @param index [Integer] Index of the rule to access
|
|
206
|
+
# @return [Rule, AtRule, nil] Rule at the given index, or nil
|
|
207
|
+
def [](index)
|
|
208
|
+
to_a[index]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if the scope has no matching rules.
|
|
212
|
+
#
|
|
213
|
+
# Forces evaluation of the scope.
|
|
214
|
+
#
|
|
215
|
+
# @return [Boolean] true if no rules match the filters
|
|
216
|
+
def empty?
|
|
217
|
+
to_a.empty?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Compare the scope to another object.
|
|
221
|
+
#
|
|
222
|
+
# Forces evaluation of the scope and compares as an array.
|
|
223
|
+
#
|
|
224
|
+
# @param other [Object] Object to compare with
|
|
225
|
+
# @return [Boolean] true if equal
|
|
226
|
+
def ==(other)
|
|
227
|
+
to_a == other
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Implicit conversion to Array for Ruby coercion.
|
|
231
|
+
#
|
|
232
|
+
# This allows StylesheetScope to be used transparently as an Array
|
|
233
|
+
# in comparisons and other operations.
|
|
234
|
+
#
|
|
235
|
+
# @return [Array] Array of matching rules
|
|
236
|
+
# @api private
|
|
237
|
+
def to_ary
|
|
238
|
+
to_a
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Human-readable representation showing filtered results.
|
|
242
|
+
#
|
|
243
|
+
# Forces evaluation of the scope and displays results.
|
|
244
|
+
#
|
|
245
|
+
# @return [String] Inspection string
|
|
246
|
+
def inspect
|
|
247
|
+
rules = to_a
|
|
248
|
+
if rules.empty?
|
|
249
|
+
'#<Cataract::StylesheetScope []>'
|
|
250
|
+
else
|
|
251
|
+
preview = rules.first(3).map(&:selector).join(', ')
|
|
252
|
+
more = rules.length > 3 ? ', ...' : ''
|
|
253
|
+
"#<Cataract::StylesheetScope [#{preview}#{more}] (#{rules.length} rules)>"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/cataract.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cataract/version'
|
|
4
|
+
require_relative 'cataract/cataract' # Load C extension (defines Rule, Declaration, AtRule structs)
|
|
5
|
+
require_relative 'cataract/rule' # Add Ruby methods to Rule
|
|
6
|
+
require_relative 'cataract/at_rule' # Add Ruby methods to AtRule
|
|
7
|
+
require_relative 'cataract/stylesheet_scope'
|
|
8
|
+
require_relative 'cataract/stylesheet'
|
|
9
|
+
require_relative 'cataract/declarations'
|
|
10
|
+
require_relative 'cataract/import_resolver'
|
|
11
|
+
|
|
12
|
+
# Cataract is a high-performance CSS parser written in C with a Ruby interface.
|
|
13
|
+
#
|
|
14
|
+
# It provides fast CSS parsing, rule querying, cascade merging, and serialization.
|
|
15
|
+
# Designed for performance-critical applications that need to process large amounts of CSS.
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# require 'cataract'
|
|
19
|
+
#
|
|
20
|
+
# # Parse CSS
|
|
21
|
+
# sheet = Cataract.parse_css("body { color: red; } h1 { color: blue; }")
|
|
22
|
+
#
|
|
23
|
+
# # Query rules
|
|
24
|
+
# sheet.select(&:selector?).each { |rule| puts "#{rule.selector}: #{rule.declarations}" }
|
|
25
|
+
#
|
|
26
|
+
# # Merge with cascade rules
|
|
27
|
+
# merged = sheet.merge
|
|
28
|
+
#
|
|
29
|
+
# @see Stylesheet Main class for working with parsed CSS
|
|
30
|
+
# @see Rule Represents individual CSS rules
|
|
31
|
+
module Cataract
|
|
32
|
+
class << self
|
|
33
|
+
# Parse a CSS string into a Stylesheet object.
|
|
34
|
+
#
|
|
35
|
+
# This is the main entry point for parsing CSS. It returns a Stylesheet
|
|
36
|
+
# object that can be queried, modified, and serialized.
|
|
37
|
+
#
|
|
38
|
+
# @param css [String] The CSS string to parse
|
|
39
|
+
# @param imports [Boolean, Hash] Whether to resolve @import statements.
|
|
40
|
+
# Pass true to enable with defaults, or a hash with options:
|
|
41
|
+
# - allowed_schemes: Array of allowed URI schemes (default: ['https'])
|
|
42
|
+
# - extensions: Array of allowed file extensions (default: ['css'])
|
|
43
|
+
# - max_depth: Maximum import nesting depth (default: 5)
|
|
44
|
+
# - base_path: Base directory for resolving relative imports
|
|
45
|
+
# @return [Stylesheet] A new Stylesheet containing the parsed CSS rules
|
|
46
|
+
# @raise [IOError] If import resolution fails and io_exceptions option is enabled
|
|
47
|
+
#
|
|
48
|
+
# @example Parse simple CSS
|
|
49
|
+
# sheet = Cataract.parse_css("body { color: red; }")
|
|
50
|
+
# sheet.size #=> 1
|
|
51
|
+
#
|
|
52
|
+
# @example Parse with imports
|
|
53
|
+
# sheet = Cataract.parse_css("@import 'style.css';", imports: true)
|
|
54
|
+
#
|
|
55
|
+
# @example Parse with import options
|
|
56
|
+
# sheet = Cataract.parse_css(css, imports: {
|
|
57
|
+
# allowed_schemes: ['https', 'file'],
|
|
58
|
+
# base_path: '/path/to/css'
|
|
59
|
+
# })
|
|
60
|
+
#
|
|
61
|
+
# @see Stylesheet#parse
|
|
62
|
+
# @see Stylesheet.parse
|
|
63
|
+
def parse_css(css, imports: false)
|
|
64
|
+
# Resolve @import statements if requested
|
|
65
|
+
css = ImportResolver.resolve(css, imports) if imports
|
|
66
|
+
|
|
67
|
+
Stylesheet.parse(css)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Merge CSS rules according to CSS cascade rules.
|
|
71
|
+
#
|
|
72
|
+
# Takes a Stylesheet or CSS string and merges all rules according to CSS cascade
|
|
73
|
+
# precedence rules. Returns a new Stylesheet with a single merged rule containing
|
|
74
|
+
# the final computed declarations.
|
|
75
|
+
#
|
|
76
|
+
# @param stylesheet_or_css [Stylesheet, String] The stylesheet to merge, or a CSS string to parse and merge
|
|
77
|
+
# @return [Stylesheet] A new Stylesheet with merged rules
|
|
78
|
+
#
|
|
79
|
+
# Merge rules (in order of precedence):
|
|
80
|
+
# 1. !important declarations win over non-important
|
|
81
|
+
# 2. Higher specificity wins
|
|
82
|
+
# 3. Later declarations with same specificity and importance win
|
|
83
|
+
# 4. Shorthand properties are created from longhand when possible (e.g., margin-* -> margin)
|
|
84
|
+
#
|
|
85
|
+
# @example Merge a stylesheet
|
|
86
|
+
# sheet = Cataract.parse_css(".test { color: red; } #test { color: blue; }")
|
|
87
|
+
# merged = Cataract.merge(sheet)
|
|
88
|
+
# merged.rules.first.declarations #=> [#<Declaration property="color" value="blue" important=false>]
|
|
89
|
+
#
|
|
90
|
+
# @example Merge with !important
|
|
91
|
+
# sheet = Cataract.parse_css(".test { color: red !important; } #test { color: blue; }")
|
|
92
|
+
# merged = Cataract.merge(sheet)
|
|
93
|
+
# merged.rules.first.declarations #=> [#<Declaration property="color" value="red" important=true>]
|
|
94
|
+
#
|
|
95
|
+
# @example Shorthand creation
|
|
96
|
+
# css = ".test { margin-top: 10px; margin-right: 10px; margin-bottom: 10px; margin-left: 10px; }"
|
|
97
|
+
# merged = Cataract.merge(Cataract.parse_css(css))
|
|
98
|
+
# # merged contains single "margin: 10px" declaration instead of four longhand properties
|
|
99
|
+
#
|
|
100
|
+
# @note This is a module-level convenience method. The same functionality is available
|
|
101
|
+
# as an instance method: `stylesheet.merge`
|
|
102
|
+
# @note Implemented in C (see ext/cataract/merge.c)
|
|
103
|
+
#
|
|
104
|
+
# @see Stylesheet#merge
|
|
105
|
+
# Cataract.merge is defined in C via rb_define_module_function
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/tasks/gem.rake
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Gem release tasks
|
|
4
|
+
namespace :gem do
|
|
5
|
+
desc 'Prepare gem for release (compile, test, lint)'
|
|
6
|
+
task prep: %i[compile test lint] do
|
|
7
|
+
puts "\n#{'=' * 80}"
|
|
8
|
+
puts '✓ Gem preparation complete!'
|
|
9
|
+
puts ' - Code compiled successfully'
|
|
10
|
+
puts ' - All tests passed'
|
|
11
|
+
puts ' - Linting passed'
|
|
12
|
+
puts '=' * 80
|
|
13
|
+
puts "\nReady for release! Next steps:"
|
|
14
|
+
puts ' 1. Update version in lib/cataract/version.rb'
|
|
15
|
+
puts ' 2. Update CHANGELOG.md'
|
|
16
|
+
puts ' 3. Commit changes: git commit -am "Release vX.Y.Z"'
|
|
17
|
+
puts ' 4. Run: rake release (creates tag, builds gem, pushes to rubygems)'
|
|
18
|
+
puts '=' * 80
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc 'Build and test gem locally (sanity check before release)'
|
|
22
|
+
task build_test: :build do
|
|
23
|
+
require_relative '../cataract/version'
|
|
24
|
+
version = Cataract::VERSION
|
|
25
|
+
gem_file = "pkg/cataract-#{version}.gem"
|
|
26
|
+
|
|
27
|
+
puts "\nTesting gem installation locally..."
|
|
28
|
+
sh "gem install #{gem_file} --local"
|
|
29
|
+
|
|
30
|
+
puts "\nRunning smoke test..."
|
|
31
|
+
ruby_code = <<~RUBY
|
|
32
|
+
require 'cataract'
|
|
33
|
+
sheet = Cataract::Stylesheet.parse('body { color: red; }')
|
|
34
|
+
raise 'Smoke test failed!' unless sheet.rules_count == 1
|
|
35
|
+
puts '✓ Smoke test passed'
|
|
36
|
+
RUBY
|
|
37
|
+
sh "ruby -e \"#{ruby_code}\""
|
|
38
|
+
|
|
39
|
+
puts "\n✓ Local gem test successful: #{gem_file}"
|
|
40
|
+
puts "\nTo release, run: rake release"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'Bump version (usage: rake gem:bump[major|minor|patch])'
|
|
44
|
+
task :bump, [:type] do |_t, args|
|
|
45
|
+
type = args[:type] || 'patch'
|
|
46
|
+
unless %w[major minor patch].include?(type)
|
|
47
|
+
abort "Invalid version bump type: #{type}. Use: major, minor, or patch"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
version_file = 'lib/cataract/version.rb'
|
|
51
|
+
content = File.read(version_file)
|
|
52
|
+
|
|
53
|
+
# Extract current version
|
|
54
|
+
current_version = content[/VERSION = ['"](.+?)['"]/, 1]
|
|
55
|
+
major, minor, patch = current_version.split('.').map(&:to_i)
|
|
56
|
+
|
|
57
|
+
# Bump version
|
|
58
|
+
case type
|
|
59
|
+
when 'major'
|
|
60
|
+
major += 1
|
|
61
|
+
minor = 0
|
|
62
|
+
patch = 0
|
|
63
|
+
when 'minor'
|
|
64
|
+
minor += 1
|
|
65
|
+
patch = 0
|
|
66
|
+
when 'patch'
|
|
67
|
+
patch += 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
new_version = "#{major}.#{minor}.#{patch}"
|
|
71
|
+
|
|
72
|
+
# Update file
|
|
73
|
+
new_content = content.gsub(/VERSION = ['"]#{Regexp.escape(current_version)}['"]/, "VERSION = '#{new_version}'")
|
|
74
|
+
File.write(version_file, new_content)
|
|
75
|
+
|
|
76
|
+
puts "Version bumped: #{current_version} → #{new_version}"
|
|
77
|
+
puts "\nNext steps:"
|
|
78
|
+
puts " 1. Review changes: git diff #{version_file}"
|
|
79
|
+
puts ' 2. Update CHANGELOG.md'
|
|
80
|
+
puts " 3. Commit: git commit -am 'Bump version to #{new_version}'"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'Prepare release commit (prep, bump version, commit)'
|
|
84
|
+
task :release_commit, [:type] => :prep do |_t, args|
|
|
85
|
+
type = args[:type] || 'patch'
|
|
86
|
+
|
|
87
|
+
# Check for uncommitted changes (ignore untracked files)
|
|
88
|
+
modified_files = `git status --porcelain`.lines.reject { |line| line.start_with?('??') }
|
|
89
|
+
unless modified_files.empty?
|
|
90
|
+
abort 'ERROR: Working directory has uncommitted changes. Commit or stash them first.'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check we're on main branch
|
|
94
|
+
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
|
95
|
+
unless current_branch == 'main'
|
|
96
|
+
puts "WARNING: You're on branch '#{current_branch}', not 'main'"
|
|
97
|
+
print 'Continue anyway? (y/N): '
|
|
98
|
+
response = $stdin.gets.chomp
|
|
99
|
+
abort 'Aborted.' unless response.downcase == 'y'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Bump version
|
|
103
|
+
Rake::Task['gem:bump'].invoke(type)
|
|
104
|
+
|
|
105
|
+
# Reload version
|
|
106
|
+
load 'lib/cataract/version.rb'
|
|
107
|
+
new_version = Cataract::VERSION
|
|
108
|
+
|
|
109
|
+
# Auto-generate CHANGELOG with git-cliff
|
|
110
|
+
puts "\n#{'=' * 80}"
|
|
111
|
+
puts 'Generating CHANGELOG.md with git-cliff...'
|
|
112
|
+
puts '=' * 80
|
|
113
|
+
|
|
114
|
+
if system('which git-cliff > /dev/null 2>&1')
|
|
115
|
+
sh "git-cliff --tag v#{new_version} --output CHANGELOG.md"
|
|
116
|
+
puts '✓ CHANGELOG.md generated'
|
|
117
|
+
|
|
118
|
+
# Show the changelog for review
|
|
119
|
+
puts "\nGenerated CHANGELOG (preview):"
|
|
120
|
+
puts '-' * 80
|
|
121
|
+
system('head -n 50 CHANGELOG.md')
|
|
122
|
+
puts '-' * 80
|
|
123
|
+
|
|
124
|
+
print "\nAccept this CHANGELOG? (Y/n): "
|
|
125
|
+
response = $stdin.gets.chomp
|
|
126
|
+
abort 'Aborted. Edit CHANGELOG.md manually if needed.' if response.downcase == 'n'
|
|
127
|
+
else
|
|
128
|
+
puts 'WARNING: git-cliff not found. Please update CHANGELOG.md manually.'
|
|
129
|
+
puts 'Install: cargo install git-cliff'
|
|
130
|
+
puts "\nPress Enter when ready to commit..."
|
|
131
|
+
$stdin.gets
|
|
132
|
+
|
|
133
|
+
# Check if CHANGELOG was actually updated
|
|
134
|
+
changelog_diff = `git diff CHANGELOG.md`
|
|
135
|
+
if changelog_diff.strip.empty?
|
|
136
|
+
puts 'WARNING: CHANGELOG.md was not modified'
|
|
137
|
+
print 'Continue anyway? (y/N): '
|
|
138
|
+
response = $stdin.gets.chomp
|
|
139
|
+
abort 'Aborted. Please update CHANGELOG.md first.' unless response.downcase == 'y'
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Commit changes with Release Bot as author
|
|
144
|
+
commit_message = "Release v#{new_version}"
|
|
145
|
+
git_email = `git config user.email`.strip
|
|
146
|
+
sh 'git add lib/cataract/version.rb CHANGELOG.md'
|
|
147
|
+
sh "git commit --author 'Release Bot <#{git_email}>' -m '#{commit_message}'"
|
|
148
|
+
|
|
149
|
+
puts "\n#{'=' * 80}"
|
|
150
|
+
puts "✓ Release commit created: #{commit_message}"
|
|
151
|
+
puts '=' * 80
|
|
152
|
+
puts "\nNext steps:"
|
|
153
|
+
puts ' 1. Review commit: git show'
|
|
154
|
+
puts ' 2. Push to GitHub: git push'
|
|
155
|
+
puts ' 3. Create release: rake release (builds gem, creates tag, pushes to rubygems)'
|
|
156
|
+
puts '=' * 80
|
|
157
|
+
end
|
|
158
|
+
end
|