css_dryer 0.0.1
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.
- data/.autotest +8 -0
- data/.gitignore +2 -0
- data/CHANGELOG +129 -0
- data/Gemfile +3 -0
- data/MIT-LICENCE +20 -0
- data/README.markdown +273 -0
- data/Rakefile +23 -0
- data/config.ru +14 -0
- data/css_dryer.gemspec +19 -0
- data/generators/css_dryer/USAGE +11 -0
- data/generators/css_dryer/css_dryer_generator.rb +23 -0
- data/generators/css_dryer/templates/_foo.css.ncss +4 -0
- data/generators/css_dryer/templates/stylesheets_controller.rb +11 -0
- data/generators/css_dryer/templates/stylesheets_helper.rb +63 -0
- data/generators/css_dryer/templates/test.css.ncss +12 -0
- data/init.rb +5 -0
- data/install.rb +3 -0
- data/lib/css_dryer/ncss_handler.rb +18 -0
- data/lib/css_dryer/processor.rb +333 -0
- data/lib/rack/css_dryer.rb +47 -0
- data/tasks/css_dryer_tasks.rake +21 -0
- data/test/css_dryer_test.rb +826 -0
- metadata +110 -0
@@ -0,0 +1,333 @@
|
|
1
|
+
# Lifted from Rails.
|
2
|
+
# "", " ", nil, [], and {} are blank
|
3
|
+
class Object #:nodoc:
|
4
|
+
def blank?
|
5
|
+
if respond_to?(:empty?) && respond_to?(:strip)
|
6
|
+
empty? or strip.empty?
|
7
|
+
elsif respond_to?(:empty?)
|
8
|
+
empty?
|
9
|
+
else
|
10
|
+
!self
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
# Converts DRY stylesheets into normal CSS ones.
|
17
|
+
module CssDryer
|
18
|
+
module Processor
|
19
|
+
|
20
|
+
VERSION = '0.4.3'
|
21
|
+
|
22
|
+
# Converts a stylesheet with nested styles into a flattened,
|
23
|
+
# normal CSS stylesheet. The original whitespace is preserved
|
24
|
+
# as much as possible.
|
25
|
+
#
|
26
|
+
# For example, the following DRY stylesheet:
|
27
|
+
#
|
28
|
+
# div {
|
29
|
+
# font-family: Verdana;
|
30
|
+
# #content {
|
31
|
+
# background-color: green;
|
32
|
+
# p { color: red; }
|
33
|
+
# }
|
34
|
+
# }
|
35
|
+
#
|
36
|
+
# is converted into this CSS:
|
37
|
+
#
|
38
|
+
# div {
|
39
|
+
# font-family: Verdana;
|
40
|
+
# }
|
41
|
+
# div #content {
|
42
|
+
# background-color: green;
|
43
|
+
# }
|
44
|
+
# div #content p { color: red; }
|
45
|
+
#
|
46
|
+
# Note, though, that @media blocks are preserved. For example:
|
47
|
+
#
|
48
|
+
# @media screen, projection {
|
49
|
+
# div {font-size:100%;}
|
50
|
+
# }
|
51
|
+
#
|
52
|
+
# is left unchanged.
|
53
|
+
#
|
54
|
+
# Styles may be nested to an arbitrary level.
|
55
|
+
def process(nested_css, indent = 2) #:doc:
|
56
|
+
# 'Normalise' comma separated selectors
|
57
|
+
nested_css = factor_out_comma_separated_selectors(nested_css, indent)
|
58
|
+
structure_to_css(nested_css_to_structure(nested_css), indent)
|
59
|
+
end
|
60
|
+
|
61
|
+
def nested_css_to_structure(css) #:nodoc:
|
62
|
+
# Implementation notes:
|
63
|
+
# - the correct way to do this would be using a lexer and parser
|
64
|
+
# - ironically there is a degree of repetition here
|
65
|
+
document = []
|
66
|
+
selectors = []
|
67
|
+
media_block = false
|
68
|
+
css.each_line do |line|
|
69
|
+
depth = selectors.length
|
70
|
+
case line.chomp!
|
71
|
+
# Media block (multiline) opening - treat as plain text but start
|
72
|
+
# watching for close of media block.
|
73
|
+
# Assume media blocks are never themselves nested.
|
74
|
+
# (This must precede the multiline selector condition.)
|
75
|
+
when /^(\s*@media.*)[{]\s*$/
|
76
|
+
media_block = true
|
77
|
+
document << line if depth == 0
|
78
|
+
# Media block inline
|
79
|
+
# Assume media blocks are never themselves nested.
|
80
|
+
when /^\s*@media.*[{].*[}]\s*$/
|
81
|
+
document << line if depth == 0
|
82
|
+
# Multiline selector opening
|
83
|
+
when /^\s*([^{]*?)\s*[{]\s*$/
|
84
|
+
hsh = StyleHash[ $1 => [] ]
|
85
|
+
hsh.multiline = true
|
86
|
+
if depth == 0
|
87
|
+
document << hsh
|
88
|
+
else
|
89
|
+
prev_hsh = selectors.last
|
90
|
+
prev_hsh.value << hsh
|
91
|
+
end
|
92
|
+
selectors << hsh
|
93
|
+
# Neither opening nor closing - 'plain text'
|
94
|
+
when /^[^{}]*$/
|
95
|
+
if depth == 0
|
96
|
+
document << line
|
97
|
+
else
|
98
|
+
hsh = selectors.last
|
99
|
+
hsh.value << (depth == 1 ? line : line.strip)
|
100
|
+
end
|
101
|
+
# Multiline selector closing
|
102
|
+
when /^([^{]*)[}]\s*$/
|
103
|
+
if media_block
|
104
|
+
media_block = false
|
105
|
+
if depth == 0
|
106
|
+
document << line
|
107
|
+
else
|
108
|
+
hsh = selectors.last
|
109
|
+
hsh.value << line
|
110
|
+
end
|
111
|
+
else
|
112
|
+
selectors.pop
|
113
|
+
end
|
114
|
+
# Inline selector
|
115
|
+
when /^([^{]*?)\s*[{]([^}]*)[}]\s*$/
|
116
|
+
key = (depth == 0 ? $1 : $1.strip)
|
117
|
+
hsh = StyleHash[ key => [ $2 ] ]
|
118
|
+
if depth == 0
|
119
|
+
document << hsh
|
120
|
+
else
|
121
|
+
prev_hsh = selectors.last
|
122
|
+
prev_hsh.value << hsh
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
document
|
127
|
+
end
|
128
|
+
|
129
|
+
def structure_to_css(structure, indent = 2) #:nodoc:
|
130
|
+
# Implementation note: the recursion and the formatting
|
131
|
+
# ironically both feel repetitive; DRY them.
|
132
|
+
indentation = ' ' * indent
|
133
|
+
css = ''
|
134
|
+
structure.each do |elem|
|
135
|
+
# Top-level hash
|
136
|
+
if elem.kind_of? StyleHash
|
137
|
+
set_asides = []
|
138
|
+
key = elem.key
|
139
|
+
if elem.has_non_style_hash_children
|
140
|
+
css << "#{key} {"
|
141
|
+
css << (elem.multiline ? "\n" : '')
|
142
|
+
end
|
143
|
+
elem.value.each do |v|
|
144
|
+
# Nested hash, depth = 1
|
145
|
+
if v.kind_of? StyleHash
|
146
|
+
# Set aside
|
147
|
+
set_asides << set_aside(combo_key(key, v.key), v.value, v.multiline)
|
148
|
+
else
|
149
|
+
unless v.blank?
|
150
|
+
css << (elem.multiline ? "#{v}" : v)
|
151
|
+
css << (elem.multiline ? "\n" : '')
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
css << "}\n" if elem.has_non_style_hash_children
|
156
|
+
# Now write out the styles that were nested in the above hash
|
157
|
+
set_asides.flatten.each { |hsh|
|
158
|
+
next unless hsh.has_non_style_hash_children
|
159
|
+
css << "#{hsh.key} {"
|
160
|
+
css << (hsh.multiline ? "\n" : '')
|
161
|
+
hsh.value.each { |item|
|
162
|
+
unless item.blank?
|
163
|
+
css << (hsh.multiline ? "#{indentation}#{item}" : item)
|
164
|
+
css << (hsh.multiline ? "\n" : '')
|
165
|
+
end
|
166
|
+
}
|
167
|
+
css << "}\n"
|
168
|
+
}
|
169
|
+
set_asides.clear
|
170
|
+
else
|
171
|
+
css << "#{elem}\n"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
css
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def set_aside(key, value, multiline) #:nodoc:
|
180
|
+
flattened = []
|
181
|
+
hsh = StyleHash[ key => [] ]
|
182
|
+
hsh.multiline = multiline
|
183
|
+
flattened << hsh
|
184
|
+
value.each { |val|
|
185
|
+
if val.kind_of? StyleHash
|
186
|
+
flattened << set_aside(combo_key(key, val.key), val.value, val.multiline)
|
187
|
+
else
|
188
|
+
hsh[key] << val
|
189
|
+
end
|
190
|
+
}
|
191
|
+
flattened
|
192
|
+
end
|
193
|
+
|
194
|
+
def combo_key(branch, leaf) #:nodoc:
|
195
|
+
(leaf =~ /\A[.:#\[]/) ? "#{branch}#{leaf}" : "#{branch} #{leaf}"
|
196
|
+
end
|
197
|
+
|
198
|
+
def factor_out_comma_separated_selectors(css, indent = 2) #:nodoc:
|
199
|
+
# TODO: replace with a nice regex
|
200
|
+
commas = false
|
201
|
+
css.each_line do |line|
|
202
|
+
next if line =~ /@media/
|
203
|
+
next if line =~ /,.*;\s*$/ # allow comma separated style values
|
204
|
+
commas = true if line =~ /,/
|
205
|
+
end
|
206
|
+
return css unless commas
|
207
|
+
|
208
|
+
state_machine = StateMachine.new indent
|
209
|
+
css.each_line { |line| state_machine.act_on line }
|
210
|
+
factor_out_comma_separated_selectors state_machine.result
|
211
|
+
end
|
212
|
+
|
213
|
+
|
214
|
+
class StateMachine #:nodoc:
|
215
|
+
def initialize(indent = 2)
|
216
|
+
@state = 'flow'
|
217
|
+
@depth = 0
|
218
|
+
@output = []
|
219
|
+
@indent = ' ' * indent
|
220
|
+
end
|
221
|
+
def result
|
222
|
+
@output.join
|
223
|
+
end
|
224
|
+
def act_on(input)
|
225
|
+
# Implementation notes:
|
226
|
+
# - the correct way to do this would be to use a lexer and parser
|
227
|
+
if @state.eql? 'flow'
|
228
|
+
case input
|
229
|
+
when %r{/[*]} # open comment
|
230
|
+
@state = 'reading_comments'
|
231
|
+
act_on input
|
232
|
+
when /^[^,]*$/ # no commas
|
233
|
+
@output << input
|
234
|
+
when /,.*;\s*$/ # comma separated style values
|
235
|
+
@output << input
|
236
|
+
when /@media/ # @media block
|
237
|
+
@output << input
|
238
|
+
when /,/ # commas
|
239
|
+
@state = 'reading_selectors'
|
240
|
+
@selectors = []
|
241
|
+
@styles = []
|
242
|
+
act_on input
|
243
|
+
end
|
244
|
+
return
|
245
|
+
|
246
|
+
elsif @state.eql? 'reading_comments'
|
247
|
+
# Dodgy hack: remove commas from comments so that the
|
248
|
+
# factor_out_comma_separated_selectors method doesn't
|
249
|
+
# go into an infinite loop.
|
250
|
+
@output << input.gsub(',', ' ')
|
251
|
+
if input =~ %r{[*]/} # close comment
|
252
|
+
@state = 'flow'
|
253
|
+
end
|
254
|
+
return
|
255
|
+
|
256
|
+
elsif @state.eql? 'reading_selectors'
|
257
|
+
if input !~ /[{]/
|
258
|
+
@selectors << extract_selectors(input)
|
259
|
+
else
|
260
|
+
@selectors << extract_selectors($`)
|
261
|
+
@state = 'reading_styles'
|
262
|
+
act_on input
|
263
|
+
end
|
264
|
+
return
|
265
|
+
|
266
|
+
elsif @state.eql? 'reading_styles'
|
267
|
+
case input
|
268
|
+
when /\A[^{}]*\Z/ # no braces
|
269
|
+
@styles << input
|
270
|
+
when /\A[^,]*[{](.*)[}]/ # inline styles (no commas)
|
271
|
+
@styles << (@depth == 0 ? $1 : input)
|
272
|
+
when /[{](.*)[}]/ # inline styles (commas)
|
273
|
+
@styles << $1
|
274
|
+
when /[{][^}]*\Z/ # open multiline block
|
275
|
+
@styles << input unless @depth == 0
|
276
|
+
@depth += 1
|
277
|
+
when /[^{]*[}]/ # close multiline block
|
278
|
+
@depth -= 1
|
279
|
+
@styles << input unless @depth == 0
|
280
|
+
end
|
281
|
+
if @depth == 0 && input =~ /[}]/
|
282
|
+
@selectors.flatten.each { |selector|
|
283
|
+
# Force each style declaration onto a new line.
|
284
|
+
@output << "#{selector} {\n"
|
285
|
+
@styles.each { |style| @output << "#{@indent}#{style.chomp.strip}\n" }
|
286
|
+
@output << "}\n"
|
287
|
+
}
|
288
|
+
@state = 'flow'
|
289
|
+
end
|
290
|
+
return
|
291
|
+
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
private
|
296
|
+
|
297
|
+
def extract_selectors(line)
|
298
|
+
line.split(',').map { |selector| selector.strip }.delete_if { |selector| selector =~ /\A\s*\Z/ }
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
|
303
|
+
class StyleHash < Hash #:nodoc:
|
304
|
+
attr_accessor :multiline
|
305
|
+
def initialize *a, &b
|
306
|
+
super
|
307
|
+
multiline = false
|
308
|
+
end
|
309
|
+
def has_non_style_hash_children
|
310
|
+
value.each do |elem|
|
311
|
+
next if elem.kind_of? StyleHash
|
312
|
+
return true unless elem.blank?
|
313
|
+
end
|
314
|
+
false
|
315
|
+
end
|
316
|
+
# We only ever have one key and one value
|
317
|
+
def key
|
318
|
+
self.keys.first
|
319
|
+
end
|
320
|
+
def key=(key)
|
321
|
+
self.keys.first = key
|
322
|
+
end
|
323
|
+
def value
|
324
|
+
self.values.first
|
325
|
+
end
|
326
|
+
def value=(value)
|
327
|
+
self.values.first = value
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
|
332
|
+
end
|
333
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../css_dryer/processor')
|
2
|
+
require 'ERB'
|
3
|
+
|
4
|
+
# Rack::CssDryer is a Rack middleware that serves de-nested nested
|
5
|
+
# stylesheets (.ncss) if available.
|
6
|
+
#
|
7
|
+
# Nested stylesheets are run through ERB and then denested to convert them
|
8
|
+
# into valid CSS.
|
9
|
+
module Rack
|
10
|
+
class CssDryer
|
11
|
+
include ::CssDryer::Processor
|
12
|
+
File = ::File
|
13
|
+
|
14
|
+
# Options:
|
15
|
+
#
|
16
|
+
# +:url+ the parent URL of the stylesheets. Defaults to 'css'. In a Rails app
|
17
|
+
# you probably want to set this to 'stylesheets'.
|
18
|
+
#
|
19
|
+
# +:path+ the file system directory containing your nested stylesheets (.ncss).
|
20
|
+
# In a Rails app you probably want to set this to 'app/views/stylesheets'.
|
21
|
+
def initialize(app, options = {})
|
22
|
+
@app = app
|
23
|
+
@url = options[:url] || 'css'
|
24
|
+
@path = options[:path] || 'stylesheets'
|
25
|
+
end
|
26
|
+
|
27
|
+
def call(env)
|
28
|
+
path = env['PATH_INFO']
|
29
|
+
if path =~ %r{/#{@url}/}
|
30
|
+
file = path.sub("/#{@url}", @path)
|
31
|
+
ncss = file.sub(/\.css$/, '.ncss')
|
32
|
+
|
33
|
+
if File.exists?(ncss)
|
34
|
+
nested_css = File.read(ncss)
|
35
|
+
nested_css = ::ERB.new(nested_css, nil, '-').result
|
36
|
+
css = process(nested_css)
|
37
|
+
length = ''.respond_to?(:bytesize) ? css.bytesize.to_s : css.size.to_s
|
38
|
+
[200, {'Content-Type' => 'text/css', 'Content-Length' => length}, [css]]
|
39
|
+
else
|
40
|
+
@app.call(env)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
@app.call(env)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'erb'
|
2
|
+
|
3
|
+
namespace :css_dryer do
|
4
|
+
|
5
|
+
task :to_css do
|
6
|
+
require File.join(RAILS_ROOT, 'app', 'helpers', 'stylesheets_helper')
|
7
|
+
include StylesheetsHelper
|
8
|
+
|
9
|
+
require File.join(File.dirname(__FILE__), '..', 'lib', 'css_dryer', 'processor')
|
10
|
+
include CssDryer::Processor
|
11
|
+
|
12
|
+
Dir.glob(File.join(RAILS_ROOT, 'app', 'views', 'stylesheets', '*')).each do |ncss|
|
13
|
+
@output_buffer = ''
|
14
|
+
::ERB.new(File.read(ncss), nil, '-', '@output_buffer').result(binding)
|
15
|
+
File.open(File.join(RAILS_ROOT, 'public', 'stylesheets', File.basename(ncss, '.ncss')), 'w') do |f|
|
16
|
+
f.write process(@output_buffer)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|