windsock 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.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windsock
4
+ # Extracts theme variables from Tailwind v4 CSS.
5
+ #
6
+ # Walks the stylesheet looking for top-level `@theme { ... }` blocks and
7
+ # `@import` statements. Everything else (`@layer`, `@utility`, plain rules)
8
+ # is skipped. `@import "tailwindcss"` pulls in the bundled default theme.
9
+ #
10
+ # Precedence follows Tailwind: values from `@theme default` blocks never
11
+ # override values set in a regular `@theme` block, regardless of order.
12
+ # `--color-*: initial` clears a namespace; `--*: initial` clears everything.
13
+ class Parser
14
+ TAILWIND_IMPORTS = %w[tailwindcss tailwindcss/theme tailwindcss/theme.css].freeze
15
+
16
+ attr_reader :vars, :files
17
+
18
+ def self.parse(css, base_dir: nil)
19
+ new.parse(css, base_dir: base_dir)
20
+ end
21
+
22
+ def self.parse_file(path)
23
+ new.parse_file(path)
24
+ end
25
+
26
+ def initialize
27
+ @vars = {}
28
+ @owned = {} # keys set by a non-default @theme block
29
+ @files = []
30
+ end
31
+
32
+ def parse_file(path)
33
+ path = File.expand_path(path)
34
+ return self if files.include?(path)
35
+
36
+ files << path
37
+ parse(File.read(path), base_dir: File.dirname(path))
38
+ end
39
+
40
+ def parse(css, base_dir: nil)
41
+ Scanner.new(strip_comments(css)).each_rule do |name, prelude, body|
42
+ case name
43
+ when "import" then import(prelude, base_dir)
44
+ when "theme" then theme_block(prelude, body)
45
+ end
46
+ end
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def strip_comments(css)
53
+ css.gsub(%r{/\*.*?\*/}m, "")
54
+ end
55
+
56
+ def import(prelude, base_dir)
57
+ spec = prelude[/\A\s*(?:url\()?\s*["']([^"']+)["']/, 1]
58
+ return unless spec
59
+
60
+ if TAILWIND_IMPORTS.include?(spec)
61
+ parse_file(Windsock.default_theme_path)
62
+ elsif spec.start_with?("./", "../", "/")
63
+ parse_file(resolve_import(spec, base_dir))
64
+ end
65
+ # Bare specifiers ("tailwindcss/utilities", "tw-animate-css", ...) are ignored.
66
+ end
67
+
68
+ def resolve_import(spec, base_dir)
69
+ base = spec.start_with?("/") ? spec : File.expand_path(spec, base_dir || Dir.pwd)
70
+ candidates = [base, "#{base}.css", File.join(base, "index.css")]
71
+ candidates.find { |path| File.file?(path) } ||
72
+ raise(ImportError, "cannot resolve @import #{spec.inspect} from #{base_dir || Dir.pwd}")
73
+ end
74
+
75
+ def theme_block(prelude, body)
76
+ default = prelude.split.include?("default")
77
+ Scanner.new(body).each_declaration do |name, value|
78
+ next unless name.start_with?("--")
79
+
80
+ key = name[2..]
81
+ value = value.gsub(/\s*\n\s*/, " ")
82
+ if value == "initial"
83
+ reset(key)
84
+ else
85
+ assign(key, value, default)
86
+ end
87
+ end
88
+ end
89
+
90
+ def assign(key, value, default)
91
+ return if default && @owned[key]
92
+
93
+ @vars[key] = value
94
+ @owned[key] = true unless default
95
+ end
96
+
97
+ def reset(key)
98
+ if key == "*"
99
+ @vars.clear
100
+ @owned.clear
101
+ elsif key.end_with?("-*")
102
+ prefix = key[0..-2]
103
+ @vars.delete_if { |k, _| k.start_with?(prefix) }
104
+ @owned.delete_if { |k, _| k.start_with?(prefix) }
105
+ else
106
+ @vars.delete(key)
107
+ @owned.delete(key)
108
+ end
109
+ end
110
+
111
+ # Minimal CSS tokenizer: finds at-rules and declarations at one nesting
112
+ # level, honouring strings, parentheses and braces.
113
+ class Scanner
114
+ def initialize(css)
115
+ @css = css
116
+ @pos = 0
117
+ end
118
+
119
+ # Yields [name, prelude, body] for each at-rule. Body is nil for
120
+ # statement at-rules (`@import ...;`). Other rules are skipped.
121
+ def each_rule
122
+ until eof?
123
+ skip_whitespace
124
+ break if eof?
125
+
126
+ if peek == "@"
127
+ yield(*read_at_rule)
128
+ else
129
+ skip_rule
130
+ end
131
+ end
132
+ end
133
+
134
+ # Yields [name, value] for each declaration. Nested blocks (e.g.
135
+ # `@keyframes` inside `@theme`) are skipped.
136
+ def each_declaration
137
+ until eof?
138
+ skip_whitespace
139
+ break if eof?
140
+
141
+ if peek == "@"
142
+ read_at_rule
143
+ elsif peek == ";"
144
+ advance
145
+ else
146
+ text = read_until(";", "{", "}")
147
+ if peek == "{"
148
+ skip_block
149
+ else
150
+ advance if peek == ";"
151
+ name, value = text.split(":", 2)
152
+ yield(name.strip, value.strip) if value
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def eof? = @pos >= @css.length
161
+ def peek = @css[@pos]
162
+ def advance = (@pos += 1)
163
+
164
+ def skip_whitespace
165
+ @pos += 1 while !eof? && peek.match?(/\s/)
166
+ end
167
+
168
+ def read_at_rule
169
+ advance # "@"
170
+ name = @css[@pos..][/\A[\w-]+/] || ""
171
+ @pos += name.length
172
+ prelude = read_until(";", "{", "}").strip
173
+
174
+ case peek
175
+ when "{"
176
+ [name, prelude, read_block]
177
+ else
178
+ advance if peek == ";"
179
+ [name, prelude, nil]
180
+ end
181
+ end
182
+
183
+ def skip_rule
184
+ read_until(";", "{")
185
+ peek == "{" ? skip_block : advance
186
+ end
187
+
188
+ def skip_block
189
+ read_block
190
+ end
191
+
192
+ # Assumes peek == "{". Returns the inner text and moves past "}".
193
+ def read_block
194
+ advance
195
+ start = @pos
196
+ depth = 1
197
+ until eof?
198
+ char = peek
199
+ if %w[" '].include?(char)
200
+ skip_string
201
+ next
202
+ end
203
+ depth += 1 if char == "{"
204
+ depth -= 1 if char == "}"
205
+ advance
206
+ return @css[start...(@pos - 1)] if depth.zero?
207
+ end
208
+ @css[start..]
209
+ end
210
+
211
+ # Reads until one of the stop chars at paren depth zero, outside strings.
212
+ def read_until(*stops)
213
+ start = @pos
214
+ depth = 0
215
+ until eof?
216
+ char = peek
217
+ if %w[" '].include?(char)
218
+ skip_string
219
+ next
220
+ end
221
+ depth += 1 if char == "("
222
+ depth -= 1 if char == ")" && depth.positive?
223
+ break if depth.zero? && stops.include?(char)
224
+
225
+ advance
226
+ end
227
+ @css[start...@pos]
228
+ end
229
+
230
+ def skip_string
231
+ quote = peek
232
+ advance
233
+ until eof?
234
+ char = peek
235
+ advance
236
+ if char == "\\"
237
+ advance
238
+ elsif char == quote
239
+ break
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windsock
4
+ class Railtie < ::Rails::Railtie
5
+ config.windsock = ActiveSupport::OrderedOptions.new
6
+
7
+ initializer "windsock.path" do |app|
8
+ Windsock.path ||= app.config.windsock.path || app.root.join("app/assets/tailwind/application.css")
9
+ end
10
+
11
+ initializer "windsock.helper" do
12
+ ActiveSupport.on_load(:action_view) { include Windsock::Helper }
13
+ end
14
+
15
+ # Re-parse when any CSS under the stylesheet's directory changes.
16
+ initializer "windsock.reloader" do |app|
17
+ config = app.config
18
+ reloading = config.respond_to?(:reloading_enabled?) ? config.reloading_enabled? : !config.cache_classes
19
+ next unless reloading
20
+
21
+ dir = File.dirname(Windsock.path.to_s)
22
+ next unless File.directory?(dir)
23
+
24
+ watcher = app.config.file_watcher.new([], { dir => ["css"] }) { Windsock.reset! }
25
+ app.reloaders << watcher
26
+ app.reloader.to_run { watcher.execute_if_updated }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pp"
4
+
5
+ module Windsock
6
+ # One color scale from the theme: scale(:brand) => 50..950 steps.
7
+ #
8
+ # Behaves like a Hash of step => Color (Enumerable over [step, color]
9
+ # pairs) and adds WCAG contrast lookups so you can pick a usable step
10
+ # without leaving the console.
11
+ #
12
+ # scale = Windsock.scale(:brand)
13
+ # scale[500].to_hex # "#2b7fff"
14
+ # scale.contrast(:white) # { 50 => 1.05, ..., 950 => 17.3 }
15
+ # scale.accessible(on: :white).steps # steps that pass AA for text
16
+ # scale.text_colors[500].to_hex # "#ffffff" — text to put on brand-500
17
+ #
18
+ # "white" and "black" mean the theme's --color-white / --color-black when
19
+ # the scale came from a theme (an app may soften white to #f5f5f5), and
20
+ # pure #fff / #000 otherwise. Any other name is looked up in the theme
21
+ # first (scale.contrast(:surface)), then parsed as CSS.
22
+ class Scale
23
+ include Enumerable
24
+
25
+ attr_reader :name
26
+
27
+ def initialize(name, steps, theme: nil)
28
+ @name = name.to_s
29
+ @theme = theme
30
+ @steps = steps.to_h { |step, color| [normalize(step), Color.parse!(color)] }.freeze
31
+ end
32
+
33
+ def white
34
+ @theme&.white || Color::WHITE
35
+ end
36
+
37
+ def black
38
+ @theme&.black || Color::BLACK
39
+ end
40
+
41
+ # scale[500], scale["500"], scale[:muted] all work.
42
+ def [](step)
43
+ @steps[normalize(step)]
44
+ end
45
+
46
+ def fetch(step)
47
+ self[step] || raise(NotFound, "scale #{name.inspect} has no step #{step.inspect}")
48
+ end
49
+
50
+ def step?(step)
51
+ @steps.key?(normalize(step))
52
+ end
53
+ alias key? step?
54
+
55
+ def steps
56
+ @steps.keys
57
+ end
58
+ alias keys steps
59
+
60
+ def colors
61
+ @steps.values
62
+ end
63
+ alias values colors
64
+
65
+ def each(&)
66
+ return enum_for(:each) unless block_given?
67
+
68
+ @steps.each(&)
69
+ end
70
+
71
+ def size
72
+ @steps.size
73
+ end
74
+ alias length size
75
+
76
+ def empty?
77
+ @steps.empty?
78
+ end
79
+
80
+ def to_h
81
+ @steps.dup
82
+ end
83
+
84
+ # WCAG contrast ratio of every step against +on+ (a Color, a theme color
85
+ # name, or any CSS color): { 50 => 1.05, 100 => 1.12, ... }.
86
+ def contrast(on = :white)
87
+ other = resolve(on)
88
+ @steps.transform_values { |color| color.contrast(other).round(2) }
89
+ end
90
+
91
+ # The steps whose contrast against +on+ meets a WCAG level, as a new
92
+ # Scale. Levels: :aa (4.5), :aa_large (3), :aaa (7), :aaa_large (4.5).
93
+ #
94
+ # scale.accessible(on: :white) # text colors usable on white
95
+ # scale.accessible(on: :black, level: :aaa)
96
+ def accessible(on: :white, level: :aa)
97
+ other = resolve(on)
98
+ Scale.new(name, @steps.select { |_, color| color.accessible?(other, level: level) }, theme: @theme)
99
+ end
100
+
101
+ # The theme's black or white, per step, for text placed on that step:
102
+ # { 50 => #000000, ..., 900 => #ffffff }.
103
+ def text_colors
104
+ @steps.transform_values { |color| color.text_color(white: white, black: black) }
105
+ end
106
+
107
+ def ==(other)
108
+ other.is_a?(Scale) && name == other.name && to_h == other.to_h
109
+ end
110
+ alias eql? ==
111
+
112
+ def hash
113
+ [name, @steps].hash
114
+ end
115
+
116
+ def to_s
117
+ "#{name}: " + @steps.map { |step, color| "#{step}=#{color.to_hex}" }.join(" ")
118
+ end
119
+
120
+ # A table, one row per step: contrast against white and black, and the
121
+ # text color to use on the step. Ratios that meet +level+ (default AA,
122
+ # 4.5:1) print green, the rest red, when +color+ is on. +color+ defaults
123
+ # to whether stdout is a terminal, so logs and tests stay plain.
124
+ #
125
+ # scale.table # what the console shows
126
+ # scale.table(level: :aaa)
127
+ # puts scale.table(color: false)
128
+ def table(level: DEFAULT_LEVEL, color: $stdout.tty?)
129
+ return "#<Windsock::Scale #{name} (empty)>" if empty?
130
+
131
+ minimum = Color::WCAG.fetch(level) { raise ArgumentError, "level must be one of #{Color::WCAG.keys.join(", ")}" }
132
+ width = steps.map { |s| s.to_s.length }.max
133
+ rows = @steps.map do |step, c|
134
+ format(" %#{width}s %-9s %s %s %s", step, c.to_hex,
135
+ ratio_cell(c.contrast(white), minimum, color),
136
+ ratio_cell(c.contrast(black), minimum, color),
137
+ c.text_color(white: white, black: black) == white ? "white" : "black")
138
+ end
139
+ header = format(" %#{width}s %-9s %6s %6s %s", "step", "hex", "white", "black", "text")
140
+ title = "#<Windsock::Scale #{name} (#{size} steps; contrast vs white #{white.to_hex} / black #{black.to_hex}; " \
141
+ "#{level.to_s.upcase.tr("_", " ")} needs #{minimum}:1; text = what to write on it)"
142
+ [title, header, *rows, ">"].join("\n")
143
+ end
144
+
145
+ def inspect
146
+ table
147
+ end
148
+
149
+ # IRB's pretty printer wraps anything that inspects as #<...> in green and
150
+ # re-lexes the rest as Ruby, so the first red cell ended its green and
151
+ # the rows after it came out half-coloured. Hand it the finished table
152
+ # through the base PrettyPrint#text so nothing is re-coloured.
153
+ def pretty_print(q)
154
+ PrettyPrint.instance_method(:text).bind_call(q, table)
155
+ end
156
+
157
+ DEFAULT_LEVEL = :aa
158
+ GREEN = "\e[32m"
159
+ RED = "\e[31m"
160
+ RESET = "\e[0m"
161
+
162
+ private
163
+
164
+ # :white / :black => the theme's; another theme color name => that;
165
+ # a Color => itself; anything else => parsed as CSS.
166
+ def resolve(on)
167
+ return on if on.is_a?(Color)
168
+
169
+ key = on.to_s
170
+ return white if key == "white"
171
+ return black if key == "black"
172
+
173
+ themed = @theme && @theme[:color, key]
174
+ Color.parse!(themed || key)
175
+ end
176
+
177
+ def ratio_cell(ratio, minimum, color)
178
+ cell = format("%6.2f", ratio)
179
+ return cell unless color
180
+
181
+ "#{ratio >= minimum ? GREEN : RED}#{cell}#{RESET}"
182
+ end
183
+
184
+ def normalize(step)
185
+ text = step.to_s
186
+ text.match?(/\A\d+\z/) ? text.to_i : text
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Tailwind Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.