apropos 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,69 @@
1
+ module Apropos
2
+ # A collection of methods mixed into Sass::Script::Functions to allow
3
+ # Apropos to be used from Sass files. The primary method is
4
+ # `image-variants`, which generates the actual CSS rules. Configuration
5
+ # directly from the Sass file is possible with the `add-dpi-image-variant`
6
+ # and `add-breakpoint-image-variant` methods, although the limitations of
7
+ # Sass syntax require that the output of these functions be assigned to a
8
+ # dummy variable.
9
+ module SassFunctions
10
+ def self.sass_function_exist?(meth)
11
+ Sass::Script::Functions.instance_methods.include? meth
12
+ end
13
+
14
+ def self.included(mod)
15
+ ::Sass::Script::Functions.declare :image_variants, []
16
+ ::Sass::Script::Functions.declare :add_dpi_image_variant, []
17
+ ::Sass::Script::Functions.declare :add_breakpoint_image_variant, []
18
+ ::Sass::Script::Functions.declare :nth_polyfill, [:list, :index]
19
+ unless sass_function_exist? :str_index
20
+ ::Sass::Script::Functions.declare :str_index, [:string, :substring]
21
+ end
22
+ end
23
+
24
+ def self.value(val)
25
+ val.respond_to?(:value) ? val.value : val
26
+ end
27
+
28
+ def image_variants(path)
29
+ assert_type path, :String
30
+ out = ::Apropos.image_variant_rules(path.value)
31
+ ::Apropos.convert_to_sass_value(out)
32
+ end
33
+
34
+ def add_dpi_image_variant(id, query, sort=0)
35
+ sort = ::Apropos::SassFunctions.value(sort)
36
+ ::Apropos.add_dpi_image_variant(id.value, query.value, sort)
37
+ ::Sass::Script::Bool.new(false)
38
+ end
39
+
40
+ def add_breakpoint_image_variant(id, query, sort=0)
41
+ sort = ::Apropos::SassFunctions.value(sort)
42
+ ::Apropos.add_breakpoint_image_variant(id.value, query.value, sort)
43
+ ::Sass::Script::Bool.new(false)
44
+ end
45
+
46
+ # Can be replaced with stock `nth` once dca1498 makes it into a Sass release
47
+ # http://git.io/eGNOKA
48
+ def nth_polyfill(list, index)
49
+ index = index.value
50
+ list = list.value
51
+ index = list.length + index + 1 if index < 0
52
+ list[index - 1]
53
+ end
54
+
55
+ # Polyfill for `str-index` function from master branch of Sass.
56
+ # Implementation taken from:
57
+ # https://github.com/nex3/sass/blob/master/lib/sass/script/functions.rb
58
+ # Using Sass::Script::Number rather than Sass::Script::Value::Number for
59
+ # backwards compatibility, however.
60
+ unless sass_function_exist? :str_index
61
+ def str_index(string, substring)
62
+ assert_type string, :String, :string
63
+ assert_type substring, :String, :substring
64
+ index = string.value.index(substring.value) || -1
65
+ ::Sass::Script::Number.new(index + 1)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ module Apropos
2
+ # A Set generates a list of Variants from a base image path. Any file in the
3
+ # same directory as the base image with the pattern "basename.*.extension" is
4
+ # considered to be a potential Variant, though not all generated Variants are
5
+ # guaranteed to be valid.
6
+ class Set
7
+ attr_reader :path
8
+
9
+ def initialize(path, basedir)
10
+ @path = path
11
+ @basedir = Pathname.new(basedir)
12
+ end
13
+
14
+ def variants
15
+ variant_paths.map do |code_fragment, path|
16
+ Variant.new(code_fragment, path)
17
+ end.sort
18
+ end
19
+
20
+ def variant_paths
21
+ paths = {}
22
+ Dir.glob(@basedir.join(variant_path_glob)).each do |path|
23
+ key = code_fragment(path)
24
+ paths[key] = remove_basedir(path)
25
+ end
26
+ paths
27
+ end
28
+
29
+ def remove_basedir(path)
30
+ path.sub(basedir_re, '')
31
+ end
32
+
33
+ def basedir_re
34
+ @basedir_re ||= Regexp.new("^.*#{Regexp.escape(@basedir.to_s)}/")
35
+ end
36
+
37
+ def code_fragment(path)
38
+ start = File.join(File.dirname(path), basename)
39
+ path[(start.length + 1)...(path.length - extname.length)]
40
+ end
41
+
42
+ def variant_path_glob
43
+ Pathname.new(dirname).join("#{basename}#{SEPARATOR}*#{extname}")
44
+ end
45
+
46
+ def dirname
47
+ @dirname ||= File.dirname(@path)
48
+ end
49
+
50
+ def basename
51
+ @basename ||= File.basename(@path, extname)
52
+ end
53
+
54
+ def extname
55
+ @extname ||= File.extname(@path)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,67 @@
1
+ module Apropos
2
+ # A Variant represents a single image file that should be displayed instead
3
+ # of the base image in one or more conditions. These conditions are parsed
4
+ # from the codes in the provided code fragment. If none of the available
5
+ # parsers can understand the Variant's codes, then the Variant is not
6
+ # considered valid.
7
+ #
8
+ # A valid Variant can generate a CSS rule from its matching conditions, and
9
+ # can be compared to other Variants based on the aggregate sort values of its
10
+ # matching conditions.
11
+ class Variant
12
+ attr_reader :path
13
+
14
+ def initialize(code_fragment, path)
15
+ @code_fragment = code_fragment
16
+ @path = path
17
+ end
18
+
19
+ def codes
20
+ @_codes ||= @code_fragment.split(SEPARATOR)
21
+ end
22
+
23
+ def conditions
24
+ @_conditions ||= codes.map do |code|
25
+ ExtensionParser.each_parser.inject(nil) do |_, parser|
26
+ query_or_selector = parser.match(code)
27
+ break query_or_selector if query_or_selector
28
+ end
29
+ end.compact
30
+ end
31
+
32
+ def conditions_by_type
33
+ @_conditions_by_type ||= {}.tap do |combination|
34
+ conditions.each do |condition|
35
+ combination[condition.type] = if combination[condition.type]
36
+ combination[condition.type].combine(condition)
37
+ else
38
+ condition
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def valid?
45
+ !conditions.empty?
46
+ end
47
+
48
+ def rule
49
+ sorted_selector_types = conditions_by_type.keys.sort
50
+ condition_css = sorted_selector_types.map do |rule_type|
51
+ conditions_by_type[rule_type].to_css
52
+ end
53
+ key = sorted_selector_types.join('+')
54
+ [key] + condition_css + [path]
55
+ end
56
+
57
+ def aggregate_sort_value
58
+ conditions.inject(0) do |total, query_or_selector|
59
+ total + query_or_selector.sort_value
60
+ end
61
+ end
62
+
63
+ def <=>(other)
64
+ aggregate_sort_value <=> other.aggregate_sort_value
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,3 @@
1
+ module Apropos
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,8 @@
1
+ require_relative "../spec_helper.rb"
2
+
3
+ describe Apropos::ClassList do
4
+ it "combines class lists" do
5
+ combo = described_class.new([".foo"]).combine(described_class.new([".bar"]))
6
+ combo.to_css.should == ".foo, .bar"
7
+ end
8
+ end
@@ -0,0 +1,71 @@
1
+ require_relative "../spec_helper.rb"
2
+
3
+ describe Apropos::ExtensionParser do
4
+ before :each do
5
+ described_class.parsers.clear
6
+ end
7
+
8
+ describe ".parsers" do
9
+ it "keeps track of variant parsers" do
10
+ parser = described_class.add_parser('2x') do
11
+ end
12
+ described_class.parsers['2x'].should == parser
13
+ described_class.parsers.count.should == 1
14
+ end
15
+ end
16
+
17
+ describe ".add_parser" do
18
+ it "overrides previously defined parsers with the same extension" do
19
+ old_parser = described_class.add_parser('2x')
20
+ new_parser = described_class.add_parser('2x')
21
+ described_class.parsers['2x'].should == new_parser
22
+ end
23
+ end
24
+
25
+ describe ".each_parser" do
26
+ it "yields each parser to the block" do
27
+ described_class.add_parser('2x')
28
+ described_class.add_parser('medium')
29
+ described_class.add_parser('fr')
30
+ vals = []
31
+ described_class.each_parser do |parser|
32
+ vals << parser.pattern
33
+ end
34
+ vals.should == %w[2x medium fr]
35
+ end
36
+ end
37
+
38
+ describe "#match" do
39
+ let(:locale_pattern) { /^([a-z]{2})$/ }
40
+
41
+ it "calls the block when the extension matches" do
42
+ lastmatch = nil
43
+ parser = described_class.new(locale_pattern) do |match|
44
+ lastmatch = match
45
+ end
46
+ parser.match('fr')
47
+ lastmatch[1].should == 'fr'
48
+ end
49
+
50
+ it "doesn't call the block when there is no match" do
51
+ expect {
52
+ parser = described_class.new(/^fr$/) do |match|
53
+ raise
54
+ end
55
+ parser.match('en').should be_nil
56
+ }.to_not raise_error
57
+ end
58
+
59
+ it "allows the block to return a nil value" do
60
+ parser = described_class.new(locale_pattern) do |match|
61
+ if match[1] == 'fr'
62
+ true
63
+ else
64
+ nil
65
+ end
66
+ end
67
+ parser.match('fr').should == true
68
+ parser.match('en').should be_nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,185 @@
1
+ require_relative "../spec_helper.rb"
2
+
3
+ describe Apropos do
4
+ def stub_files(*files)
5
+ Dir.stub(:glob).with(Pathname.new("/project/images/foo.*.jpg")).and_return(files)
6
+ end
7
+
8
+ let(:rules) { Apropos.image_variant_rules("foo.jpg") }
9
+
10
+ before :each do
11
+ Compass.configuration.stub(:project_path).and_return('/project')
12
+ Compass.configuration.stub(:images_dir).and_return('images')
13
+ end
14
+
15
+ describe ".add_class_image_variant" do
16
+ after :each do
17
+ Apropos.clear_image_variants
18
+ end
19
+
20
+ it "adds a simple class variant" do
21
+ Apropos.add_class_image_variant('alt', 'alternate')
22
+ stub_files("/foo.alt.jpg")
23
+ rules.should == [
24
+ ["class", ".alternate", "/foo.alt.jpg"]
25
+ ]
26
+ end
27
+
28
+ it "adds multiple classes" do
29
+ Apropos.add_class_image_variant('alt', ['alternate', 'alt'])
30
+ stub_files("/foo.alt.jpg")
31
+ rules.should == [
32
+ ["class", ".alternate, .alt", "/foo.alt.jpg"]
33
+ ]
34
+ end
35
+
36
+ it "respects sort order" do
37
+ Apropos.add_class_image_variant('alt', 'alternate', 1)
38
+ Apropos.add_class_image_variant('b', 'blue', 0)
39
+ stub_files("/foo.alt.jpg", "/foo.b.jpg")
40
+ rules.should == [
41
+ ["class", ".blue", "/foo.b.jpg"],
42
+ ["class", ".alternate", "/foo.alt.jpg"]
43
+ ]
44
+ end
45
+
46
+ it "uses a custom block to generate class names" do
47
+ Apropos.add_class_image_variant(/^[a-z]{2}$/) do |match|
48
+ if match[0] == 'en'
49
+ "lang-en"
50
+ elsif match[0] == 'fr'
51
+ ["lang-fr", "country-FR"]
52
+ end
53
+ end
54
+ stub_files('/foo.en.jpg', '/foo.fr.jpg', '/foo.de.jpg')
55
+ rules.should == [
56
+ ["class", ".lang-en", "/foo.en.jpg"],
57
+ [ "class", ".lang-fr, .country-FR", "/foo.fr.jpg"]
58
+ ]
59
+ end
60
+ end
61
+
62
+ describe ".image_variant_rules" do
63
+ before :all do
64
+ Apropos.add_dpi_image_variant('2x', "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", 0.5)
65
+ Apropos.add_breakpoint_image_variant('medium', 'min-width: 768px', 1)
66
+ Apropos.add_breakpoint_image_variant('large', 'min-width: 1024px', 2)
67
+ Apropos::ExtensionParser.add_parser(/^([a-z]{2}(?:-(ca))?)$/) do |match|
68
+ if match[2]
69
+ Apropos::ClassList.new([".locale-fr-CA"], 3)
70
+ elsif match[1]
71
+ if match[1] == 'fr'
72
+ Apropos::ClassList.new([".lang-fr"], 2)
73
+ elsif match[1] == 'ca'
74
+ Apropos::ClassList.new([".country-CA"], 1)
75
+ end
76
+ else
77
+ nil
78
+ end
79
+ end
80
+ end
81
+
82
+ after :all do
83
+ Apropos.clear_image_variants
84
+ end
85
+
86
+ it "ignores invalid variants" do
87
+ stub_files("/foo.1x.jpg", "/foo.de.jpg", "/foo.ca.jpg")
88
+ rules.should == [
89
+ ["class", ".country-CA", "/foo.ca.jpg"]
90
+ ]
91
+ end
92
+
93
+ it "generates a locale class rule for localized variant" do
94
+ stub_files("/foo.ca.jpg")
95
+ rules.should == [
96
+ ["class", ".country-CA", "/foo.ca.jpg"]
97
+ ]
98
+ end
99
+
100
+ it "generates a media query rule for breakpoint variant" do
101
+ stub_files("/foo.medium.jpg")
102
+ rules.should == [
103
+ ["media", "(min-width: 768px)", "/foo.medium.jpg"]
104
+ ]
105
+ end
106
+
107
+ it "generates a media query rule for retina variant" do
108
+ stub_files("/foo.2x.jpg")
109
+ rules.should == [
110
+ ["media", "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", "/foo.2x.jpg"]
111
+ ]
112
+ end
113
+
114
+ it "generates a combined rule for retina + localized variant" do
115
+ stub_files("/foo.2x.ca.jpg")
116
+ rules.should == [
117
+ ["class+media", ".country-CA", "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", "/foo.2x.ca.jpg"]
118
+ ]
119
+ end
120
+
121
+ it "generates multiple rules for multiple variants" do
122
+ stub_files("/foo.2x.ca.jpg", "/foo.medium.2x.fr.jpg")
123
+ rules.should == [
124
+ ["class+media", ".country-CA", "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", "/foo.2x.ca.jpg"],
125
+ ["class+media", ".lang-fr", "(min-width: 768px) and (-webkit-min-device-pixel-ratio: 2), (min-width: 768px) and (min-resolution: 192dpi)", "/foo.medium.2x.fr.jpg"]
126
+ ]
127
+ end
128
+
129
+ it "sorts breakpoint rules" do
130
+ stub_files("/foo.large.jpg", "/foo.medium.jpg")
131
+ rules.should == [
132
+ ["media", "(min-width: 768px)", "/foo.medium.jpg"],
133
+ ["media", "(min-width: 1024px)", "/foo.large.jpg"]
134
+ ]
135
+ end
136
+
137
+ it "sorts retina rules after non-retina rules" do
138
+ stub_files("/foo.2x.large.jpg", "/foo.large.jpg")
139
+ rules.should == [
140
+ ["media", "(min-width: 1024px)", "/foo.large.jpg"],
141
+ ["media", "(-webkit-min-device-pixel-ratio: 2) and (min-width: 1024px), (min-resolution: 192dpi) and (min-width: 1024px)", "/foo.2x.large.jpg"]
142
+ ]
143
+ end
144
+
145
+ it "sorts breakpoints within retina rules" do
146
+ stub_files("/foo.2x.large.jpg", "/foo.2x.medium.jpg", "/foo.2x.jpg")
147
+ rules.should == [
148
+ ["media", "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", "/foo.2x.jpg"],
149
+ ["media", "(-webkit-min-device-pixel-ratio: 2) and (min-width: 768px), (min-resolution: 192dpi) and (min-width: 768px)", "/foo.2x.medium.jpg"],
150
+ ["media", "(-webkit-min-device-pixel-ratio: 2) and (min-width: 1024px), (min-resolution: 192dpi) and (min-width: 1024px)", "/foo.2x.large.jpg"]
151
+ ]
152
+ end
153
+
154
+ it "sorts locale rules: country < lang < locale" do
155
+ stub_files("/foo.fr.jpg", "/foo.fr-ca.jpg", "/foo.ca.jpg")
156
+ rules.should == [
157
+ ["class", ".country-CA", "/foo.ca.jpg"],
158
+ ["class", ".lang-fr", "/foo.fr.jpg"],
159
+ ["class", ".locale-fr-CA", "/foo.fr-ca.jpg"]
160
+ ]
161
+ end
162
+ end
163
+
164
+ describe ".convert_to_sass_value" do
165
+ it "converts strings to sass strings" do
166
+ val = Apropos.convert_to_sass_value("foo")
167
+ val.value.should == "foo"
168
+ val.class.should == Sass::Script::String
169
+ end
170
+
171
+ it "converts arrays to sass lists" do
172
+ original_value = ["foo", "bar"]
173
+ val = Apropos.convert_to_sass_value(original_value)
174
+ val.class.should == Sass::Script::List
175
+ val.value.map(&:value).should == original_value
176
+ val.separator.should == :space
177
+ end
178
+
179
+ it "raises an exception on other input types" do
180
+ expect {
181
+ Apropos.convert_to_sass_value(3)
182
+ }.to raise_exception
183
+ end
184
+ end
185
+ end