apropos 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,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