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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windsock
4
+ # A resolved Tailwind theme. Keys are variable names without the leading
5
+ # `--`, e.g. "color-brand-500". Values returned by #[] have `var()` and
6
+ # `--theme()` references expanded; #raw returns the CSS as written.
7
+ class Theme
8
+ NAMESPACES = {
9
+ fonts: "font",
10
+ breakpoints: "breakpoint",
11
+ containers: "container",
12
+ radii: "radius",
13
+ shadows: "shadow",
14
+ inset_shadows: "inset-shadow",
15
+ drop_shadows: "drop-shadow",
16
+ text_sizes: "text",
17
+ tracking: "tracking",
18
+ leading: "leading",
19
+ blurs: "blur",
20
+ easings: "ease",
21
+ animations: "animate",
22
+ aspects: "aspect",
23
+ perspectives: "perspective"
24
+ }.freeze
25
+
26
+ REFERENCE = /(?:var|--theme|theme)\(/
27
+
28
+ def self.parse(css, base_dir: nil)
29
+ new(Parser.parse(css, base_dir: base_dir).vars)
30
+ end
31
+
32
+ def self.from_file(path)
33
+ parser = Parser.parse_file(path)
34
+ new(parser.vars, files: parser.files)
35
+ end
36
+
37
+ attr_reader :files
38
+
39
+ def initialize(vars, files: [])
40
+ @vars = vars.to_h.transform_keys(&:to_s).freeze
41
+ @files = files
42
+ @resolved = {}
43
+ end
44
+
45
+ def key?(*parts)
46
+ @vars.key?(key_for(parts))
47
+ end
48
+
49
+ def keys
50
+ @vars.keys
51
+ end
52
+
53
+ # Resolved value, or nil. Accepts a full key ("color-brand-500") or
54
+ # parts (:color, :brand, 500).
55
+ def [](*parts)
56
+ key = key_for(parts)
57
+ return nil unless @vars.key?(key)
58
+
59
+ @resolved[key] ||= resolve(@vars[key])
60
+ end
61
+
62
+ def fetch(*parts)
63
+ self[*parts] || raise(NotFound, "theme has no #{key_for(parts).inspect}")
64
+ end
65
+
66
+ def raw(*parts)
67
+ @vars[key_for(parts)]
68
+ end
69
+
70
+ # All resolved values under a namespace, keyed by the remainder:
71
+ # namespace(:breakpoint) => { "sm" => "40rem", ... }
72
+ def namespace(name)
73
+ prefix = "#{name}-"
74
+ @vars.keys.grep(/\A#{Regexp.escape(prefix)}/).to_h do |key|
75
+ [key.delete_prefix(prefix), self[key]]
76
+ end
77
+ end
78
+
79
+ NAMESPACES.each do |method, ns|
80
+ define_method(method) { namespace(ns) }
81
+ end
82
+
83
+ # color(:brand, 500) / color("brand-500") => Color, or nil when missing.
84
+ #
85
+ # color(:brand) with no --color-brand but a brand-50..950 family returns
86
+ # the whole Scale instead of nil: asking for a family is what a human
87
+ # means by it. An exact --color-brand always wins over the scale.
88
+ def color(*parts)
89
+ value = self[:color, *parts]
90
+ return Color.parse(value) if value
91
+
92
+ name = key_for(parts)
93
+ scale(name) if scale_names.include?(name)
94
+ end
95
+
96
+ def color!(*parts)
97
+ color(*parts) || raise(NotFound, "theme has no color #{key_for([:color, *parts]).inspect}")
98
+ end
99
+
100
+ # The theme's own white and black (--color-white / --color-black), which
101
+ # an app may soften (#f5f5f5). Contrast checks measure against these,
102
+ # not pure #fff / #000, so the numbers match what the page shows.
103
+ def white
104
+ single_color(:white) || Color::WHITE
105
+ end
106
+
107
+ def black
108
+ single_color(:black) || Color::BLACK
109
+ end
110
+
111
+ # The theme's white or black, whichever reads better on the given color:
112
+ # text_color(:brand, 500) => Color.
113
+ def text_color(*parts)
114
+ color = color!(*parts)
115
+ raise NotFound, "#{key_for(parts).inspect} is a scale, not a color" unless color.is_a?(Color)
116
+
117
+ color.text_color(white: white, black: black)
118
+ end
119
+
120
+ # All colors as { "brand-500" => Color }. Values that aren't colors
121
+ # (currentColor, var() chains that don't resolve) are omitted.
122
+ def colors
123
+ @colors ||= namespace(:color).filter_map do |name, value|
124
+ color = Color.parse(value)
125
+ [name, color] if color
126
+ end.to_h.freeze
127
+ end
128
+
129
+ # A single color scale: scale(:brand) => Scale of 50 => Color, ..., 950 => Color.
130
+ # Numeric steps become Integer keys; anything else stays a String.
131
+ def scale(name)
132
+ prefix = "#{name}-"
133
+ steps = colors.select { |k, _| k.start_with?(prefix) }.transform_keys { |k| k.delete_prefix(prefix) }
134
+ Scale.new(name, steps, theme: self)
135
+ end
136
+
137
+ # Names you can hand to #scale: ["red", "orange", ..., "brand"]. A color
138
+ # belongs to a scale when its name ends in a numeric step (brand-500);
139
+ # brand-muted rides along with brand.
140
+ def scale_names
141
+ @scale_names ||= colors.keys.filter_map { |k| k[/\A(.+)-\d+\z/, 1] }.uniq.freeze
142
+ end
143
+
144
+ # Every scale in the theme, keyed by name: { "red" => Scale, "brand" => Scale, ... }.
145
+ def scales
146
+ scale_names.to_h { |name| [name, scale(name)] }
147
+ end
148
+
149
+ # Tailwind's spacing scale: spacing(4) => "1rem" (given --spacing: 0.25rem).
150
+ # Named entries (--spacing-gutter) are looked up directly.
151
+ def spacing(step = nil)
152
+ return self[:spacing] if step.nil?
153
+ return self[:spacing, step] if key?(:spacing, step)
154
+
155
+ base = self[:spacing] or return nil
156
+ match = base.match(/\A(-?\d*\.?\d+)([a-z%]*)\z/) or return nil
157
+ "#{trim(match[1].to_f * step.to_f)}#{match[2]}"
158
+ end
159
+
160
+ def to_h
161
+ @vars.keys.to_h { |key| [key, self[key]] }
162
+ end
163
+
164
+ def inspect
165
+ "#<Windsock::Theme #{@vars.size} vars>"
166
+ end
167
+
168
+ private
169
+
170
+ # One color by exact key, never a scale.
171
+ def single_color(name)
172
+ value = self[:color, name]
173
+ value && Color.parse(value)
174
+ end
175
+
176
+ def key_for(parts)
177
+ parts.flatten.join("-")
178
+ end
179
+
180
+ def trim(number)
181
+ number == number.round ? number.round.to_s : number.round(4).to_s
182
+ end
183
+
184
+ # Expands var(--x), var(--x, fallback) and --theme(--x, fallback).
185
+ def resolve(value, depth = 0)
186
+ return value if depth > 20 || !value.match?(REFERENCE)
187
+
188
+ out = +""
189
+ rest = value
190
+ while (match = REFERENCE.match(rest))
191
+ out << match.pre_match
192
+ inner, rest = balanced(match.post_match)
193
+ name, fallback = inner.split(",", 2)
194
+ out << lookup(name.strip.delete_prefix("--"), fallback&.strip, depth)
195
+ end
196
+ out << rest
197
+ end
198
+
199
+ def lookup(name, fallback, depth)
200
+ if @vars.key?(name)
201
+ resolve(@vars[name], depth + 1)
202
+ elsif fallback && fallback != "initial"
203
+ resolve(fallback, depth + 1)
204
+ else
205
+ "var(--#{name})"
206
+ end
207
+ end
208
+
209
+ # Given text starting just after "(", returns [inside, after_closing_paren].
210
+ def balanced(text)
211
+ depth = 1
212
+ text.each_char.with_index do |char, i|
213
+ depth += 1 if char == "("
214
+ depth -= 1 if char == ")"
215
+ return [text[0...i], text[(i + 1)..]] if depth.zero?
216
+ end
217
+ [text, ""]
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windsock
4
+ VERSION = "0.1.0"
5
+ end
data/lib/windsock.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "windsock/version"
4
+ require_relative "windsock/color"
5
+ require_relative "windsock/parser"
6
+ require_relative "windsock/scale"
7
+ require_relative "windsock/theme"
8
+ require_relative "windsock/helper"
9
+
10
+ # Read your Tailwind v4 theme from Ruby.
11
+ #
12
+ # Windsock.path = "app/assets/tailwind/application.css"
13
+ # Windsock.color(:brand, 500).to_hex # => "#3b82f6"
14
+ # Windsock.scale(:brand) # => 50..950 with WCAG contrast
15
+ # Windsock.theme.breakpoints # => { "sm" => "40rem", ... }
16
+ module Windsock
17
+ class Error < StandardError; end
18
+ class ParseError < Error; end
19
+ class ImportError < Error; end
20
+ class NotFound < Error; end
21
+
22
+ TAILWIND_VERSION = "4.3.3"
23
+
24
+ class << self
25
+ # Path to the app's main Tailwind stylesheet. Set by the Railtie in Rails.
26
+ attr_accessor :path
27
+
28
+ def theme
29
+ @theme ||= load(path || raise(Error, "Windsock.path is not set"))
30
+ end
31
+
32
+ def reset!
33
+ @theme = nil
34
+ end
35
+
36
+ def load(path)
37
+ Theme.from_file(path)
38
+ end
39
+
40
+ def parse(css, base_dir: nil)
41
+ Theme.parse(css, base_dir: base_dir)
42
+ end
43
+
44
+ # Tailwind's built-in palette and scales, without any app customisation.
45
+ def default_theme
46
+ @default_theme ||= Theme.from_file(default_theme_path)
47
+ end
48
+
49
+ def default_theme_path
50
+ File.expand_path("windsock/tailwind/theme.css", __dir__)
51
+ end
52
+
53
+ def color(...) = theme.color(...)
54
+ def color!(...) = theme.color!(...)
55
+ def colors = theme.colors
56
+ def scale(...) = theme.scale(...)
57
+ def scales = theme.scales
58
+ def scale_names = theme.scale_names
59
+ def fetch(...) = theme.fetch(...)
60
+
61
+ def [](*parts)
62
+ theme[*parts]
63
+ end
64
+ end
65
+ end
66
+
67
+ require_relative "windsock/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: windsock
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jim Remsik
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Parses Tailwind v4 @theme CSS so Ruby can use the same colors, fonts
13
+ and breakpoints. Converts oklch to hex for mailers, PDFs and charts. No Node required.
14
+ email:
15
+ - jim@beflagrant.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - CODE_OF_CONDUCT.md
22
+ - CONTRIBUTING.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - SECURITY.md
27
+ - lib/windsock.rb
28
+ - lib/windsock/color.rb
29
+ - lib/windsock/helper.rb
30
+ - lib/windsock/parser.rb
31
+ - lib/windsock/railtie.rb
32
+ - lib/windsock/scale.rb
33
+ - lib/windsock/tailwind/LICENSE
34
+ - lib/windsock/tailwind/theme.css
35
+ - lib/windsock/theme.rb
36
+ - lib/windsock/version.rb
37
+ homepage: https://github.com/beflagrant/windsock
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ source_code_uri: https://github.com/beflagrant/windsock
42
+ changelog_uri: https://github.com/beflagrant/windsock/blob/main/CHANGELOG.md
43
+ bug_tracker_uri: https://github.com/beflagrant/windsock/issues
44
+ rubygems_mfa_required: 'true'
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.2.0
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 4.0.20
60
+ specification_version: 4
61
+ summary: Read your Tailwind v4 theme from Ruby.
62
+ test_files: []