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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +85 -0
- data/CONTRIBUTING.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +200 -0
- data/Rakefile +12 -0
- data/SECURITY.md +14 -0
- data/lib/windsock/color.rb +441 -0
- data/lib/windsock/helper.rb +56 -0
- data/lib/windsock/parser.rb +245 -0
- data/lib/windsock/railtie.rb +29 -0
- data/lib/windsock/scale.rb +189 -0
- data/lib/windsock/tailwind/LICENSE +21 -0
- data/lib/windsock/tailwind/theme.css +510 -0
- data/lib/windsock/theme.rb +220 -0
- data/lib/windsock/version.rb +5 -0
- data/lib/windsock.rb +67 -0
- metadata +62 -0
|
@@ -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
|
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: []
|