montage 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. data/.document +5 -0
  2. data/.gitignore +31 -0
  3. data/History.md +40 -0
  4. data/LICENSE +19 -0
  5. data/README.md +89 -0
  6. data/Rakefile +41 -0
  7. data/VERSION +1 -0
  8. data/bin/montage +22 -0
  9. data/lib/montage.rb +33 -0
  10. data/lib/montage/commands.rb +32 -0
  11. data/lib/montage/commands/generate.rb +234 -0
  12. data/lib/montage/commands/init.rb +119 -0
  13. data/lib/montage/core_ext.rb +80 -0
  14. data/lib/montage/project.rb +185 -0
  15. data/lib/montage/sass_builder.rb +37 -0
  16. data/lib/montage/source.rb +75 -0
  17. data/lib/montage/sprite.rb +132 -0
  18. data/lib/montage/templates/montage.yml +26 -0
  19. data/lib/montage/templates/sass_mixins.erb +20 -0
  20. data/lib/montage/templates/sources/book.png +0 -0
  21. data/lib/montage/templates/sources/box-label.png +0 -0
  22. data/lib/montage/templates/sources/calculator.png +0 -0
  23. data/lib/montage/templates/sources/calendar-month.png +0 -0
  24. data/lib/montage/templates/sources/camera.png +0 -0
  25. data/lib/montage/templates/sources/eraser.png +0 -0
  26. data/lib/montage/version.rb +3 -0
  27. data/montage.gemspec +145 -0
  28. data/spec/fixtures/custom_dirs/montage.yml +8 -0
  29. data/spec/fixtures/default/montage.yml +7 -0
  30. data/spec/fixtures/default/public/images/sprites/src/one.png +0 -0
  31. data/spec/fixtures/default/public/images/sprites/src/three.png +0 -0
  32. data/spec/fixtures/default/public/images/sprites/src/two.png +0 -0
  33. data/spec/fixtures/directory_config/config/montage.yml +5 -0
  34. data/spec/fixtures/missing_source/montage.yml +3 -0
  35. data/spec/fixtures/missing_source_dir/montage.yml +5 -0
  36. data/spec/fixtures/root_config/montage.yml +5 -0
  37. data/spec/fixtures/root_config/public/images/sprites/src/source_one.png +0 -0
  38. data/spec/fixtures/root_config/public/images/sprites/src/source_three.jpg +0 -0
  39. data/spec/fixtures/root_config/public/images/sprites/src/source_two +0 -0
  40. data/spec/fixtures/sources/hundred.png +0 -0
  41. data/spec/fixtures/sources/mammoth.png +0 -0
  42. data/spec/fixtures/sources/other.png +0 -0
  43. data/spec/fixtures/sources/twenty.png +0 -0
  44. data/spec/fixtures/subdirs/montage.yml +5 -0
  45. data/spec/fixtures/subdirs/sub/sub/keep +0 -0
  46. data/spec/lib/command_runner.rb +140 -0
  47. data/spec/lib/fixtures.rb +7 -0
  48. data/spec/lib/have_public_method_defined.rb +19 -0
  49. data/spec/lib/project_helper.rb +135 -0
  50. data/spec/lib/shared_project_specs.rb +32 -0
  51. data/spec/lib/shared_sprite_specs.rb +30 -0
  52. data/spec/montage/commands/generate_spec.rb +308 -0
  53. data/spec/montage/commands/init_spec.rb +120 -0
  54. data/spec/montage/core_ext_spec.rb +33 -0
  55. data/spec/montage/project_spec.rb +181 -0
  56. data/spec/montage/sass_builder_spec.rb +269 -0
  57. data/spec/montage/source_spec.rb +53 -0
  58. data/spec/montage/spec/have_public_method_defined_spec.rb +31 -0
  59. data/spec/montage/sprite_spec.rb +170 -0
  60. data/spec/rcov.opts +8 -0
  61. data/spec/spec.opts +4 -0
  62. data/spec/spec_helper.rb +19 -0
  63. data/tasks/spec.rake +17 -0
  64. data/tasks/yard.rake +11 -0
  65. metadata +249 -0
@@ -0,0 +1,119 @@
1
+ module Montage
2
+ module Commands
3
+ # Creates a new project.
4
+ class Init
5
+ extend Commands
6
+
7
+ # Path to lib/templates
8
+ TEMPLATES = Pathname.new(__FILE__).dirname.parent + 'templates'
9
+
10
+ # === Class Methods ====================================================
11
+
12
+ # Creates a new project in the current directory.
13
+ #
14
+ # @param [Array] argv
15
+ # The arguments given on the command line.
16
+ #
17
+ def self.run(*)
18
+ new(Dir.pwd).run!
19
+
20
+ rescue Montage::ProjectExists => e
21
+ if e.message.match(/`(.*)'$/)[1] == Dir.pwd
22
+ say color("A Montage project already exists in the " \
23
+ "current directory", :red)
24
+ else
25
+ say color(e.message.compress_lines, :red)
26
+ end
27
+
28
+ exit(1)
29
+ end
30
+
31
+ # === Instance Methods =================================================
32
+
33
+ # Creates a new Generate instance.
34
+ #
35
+ # @param [Pathname]
36
+ # The directory in which a new project is to be created.
37
+ #
38
+ def initialize(dir)
39
+ @dir = Pathname.new(dir)
40
+ end
41
+
42
+ # Runs the command, creating the new project structure.
43
+ #
44
+ def run!
45
+ begin
46
+ found = Project.find(@dir)
47
+ rescue MissingProject
48
+ ask_questions!
49
+ make_directories!
50
+ create_config!
51
+ copy_sources!
52
+ say color("Your project was created", :green)
53
+ say Montage::Commands::BLANK
54
+ else
55
+ raise Montage::ProjectExists, <<-ERROR.compress_lines
56
+ A Montage project exists in a parent directory at
57
+ `#{found.paths.root}'
58
+ ERROR
59
+ end
60
+ end
61
+
62
+ private # ==============================================================
63
+
64
+ # Step 1: Ask the user where they want files to be stored.
65
+ #
66
+ def ask_questions!
67
+ normalise_path = lambda do |path|
68
+ npath = Pathname.new(path).expand_path
69
+ npath = npath.relative_path_from(@dir) unless path[0].chr == '/'
70
+ npath
71
+ end
72
+
73
+ @sprites_path =
74
+ ask("Where do you want generated sprites to be stored?") do |query|
75
+ query.default = 'public/images/sprites'
76
+ query.answer_type = normalise_path
77
+ end
78
+
79
+ @sources_path =
80
+ ask("Where are the source images stored?") do |query|
81
+ query.default = "#{@sprites_path}/src"
82
+ query.answer_type = normalise_path
83
+ end
84
+ end
85
+
86
+ # Step 2: Make the sprites and sources directories.
87
+ #
88
+ def make_directories!
89
+ @sprites_path.mkpath unless @sprites_path.directory?
90
+ @sources_path.mkpath unless @sources_path.directory?
91
+ end
92
+
93
+ # Step 3: Write the configuration.
94
+ #
95
+ def create_config!
96
+ template = File.read(TEMPLATES + 'montage.yml')
97
+ template.gsub!(/<sprites>/, %("#{@sprites_path.to_s}"))
98
+ template.gsub!(/<sources>/, %("#{@sources_path.to_s}"))
99
+
100
+ if (@dir + 'config').directory?
101
+ config_path = @dir + 'config/montage.yml'
102
+ else
103
+ config_path = @dir + 'montage.yml'
104
+ end
105
+
106
+ File.open(config_path, 'w') do |config|
107
+ config.puts template
108
+ end
109
+ end
110
+
111
+ # Step 4: Copy the sample source images.
112
+ #
113
+ def copy_sources!
114
+ FileUtils.cp_r(TEMPLATES + 'sources/.', @sources_path)
115
+ end
116
+
117
+ end # Init
118
+ end # Commands
119
+ end # Montage
@@ -0,0 +1,80 @@
1
+ class String
2
+
3
+ # Replace sequences of whitespace (including newlines) with either
4
+ # a single space or remove them entirely (according to param _spaced_)
5
+ #
6
+ # <<QUERY.compress_lines
7
+ # SELECT name
8
+ # FROM users
9
+ # QUERY => "SELECT name FROM users"
10
+ #
11
+ # @param [TrueClass, FalseClass] spaced (default=true)
12
+ # Determines whether returned string has whitespace collapsed or removed
13
+ #
14
+ # @return [String] Receiver with whitespace (including newlines) replaced
15
+ #
16
+ def compress_lines(spaced = true)
17
+ split($/).map { |line| line.strip }.join(spaced ? ' ' : '')
18
+ end
19
+
20
+ # Removes leading whitespace from each line, such as might be added when
21
+ # using a HEREDOC string.
22
+ #
23
+ # @return [String] Receiver with leading whitespace removed.
24
+ #
25
+ def unindent
26
+ (other = dup) and other.unindent! and other
27
+ end
28
+
29
+ # Bang version of #unindent.
30
+ #
31
+ # @return [String] Receiver with leading whitespace removed.
32
+ #
33
+ def unindent!
34
+ gsub!(/^[ \t]{#{minimum_leading_whitespace}}/, '')
35
+ end
36
+
37
+ private
38
+
39
+ # Checks each line and determines the minimum amount of leading whitespace.
40
+ #
41
+ # @return [Integer] The number of leading whitespace characters.
42
+ #
43
+ def minimum_leading_whitespace
44
+ whitespace = split("\n", -1).inject(0) do |indent, line|
45
+ if line.strip.empty?
46
+ indent # Ignore completely blank lines.
47
+ elsif line =~ /^(\s+)/
48
+ (1.0 / $1.length) > indent ? 1.0 / $1.length : indent
49
+ else
50
+ 1.0
51
+ end
52
+ end
53
+
54
+ whitespace == 1.0 ? 0 : (1.0 / whitespace).to_i
55
+ end
56
+
57
+ end
58
+
59
+ # String#compress_lines is extracted from the extlib gem
60
+ # ------------------------------------------------------
61
+ #
62
+ # Copyright (c) 2009 Dan Kubb
63
+ #
64
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
65
+ # of this software and associated documentation files (the "Software"), to
66
+ # deal in the Software without restriction, including without limitation the
67
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
68
+ # sell copies of the Software, and to permit persons to whom the Software is
69
+ # furnished to do so, subject to the following conditions:
70
+ #
71
+ # The above copyright notice and this permission notice shall be included in
72
+ # all copies or substantial portions of the Software.
73
+ #
74
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
75
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
76
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
77
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
78
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
79
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
80
+ # IN THE SOFTWARE.
@@ -0,0 +1,185 @@
1
+ module Montage
2
+ # Represents a directory in which it is expected that there be a
3
+ # configuration file, and source images.
4
+ class Project
5
+ DEFAULTS = {
6
+ :sources => 'public/images/sprites/src',
7
+ :sprites => 'public/images/sprites',
8
+ :sass => 'public/stylesheets/sass',
9
+ :sprite_url => '/images/sprites'
10
+ }
11
+
12
+ # Stores all the paths the project needs.
13
+ Paths = Struct.new(:root, :config, :sources, :sprites, :sass, :url)
14
+
15
+ # Returns the Paths instance for the project.
16
+ #
17
+ # @return [Montage::Project::Paths]
18
+ #
19
+ attr_reader :paths
20
+
21
+ # Returns an Array containing all the Sprites defined in the project.
22
+ #
23
+ # @return [Array<Montage::Sprite>]
24
+ #
25
+ attr_reader :sprites
26
+
27
+ # Returns the amount of space to be used between each source image when
28
+ # saving sprites.
29
+ #
30
+ # @return [Integer]
31
+ #
32
+ attr_reader :padding
33
+
34
+ # Creates a new Project instance.
35
+ #
36
+ # Note that +new+ does no validation of the given paths: it expects them
37
+ # to be correct. If you're not sure of the exact paths, use +Project.find+
38
+ # instead.
39
+ #
40
+ # @param [String, Pathname] root_path
41
+ # Path to the root of the Montage project.
42
+ # @param [String, Pathname] config_path
43
+ # Path to the config file.
44
+ #
45
+ def initialize(root_path, config_path)
46
+ root_path = Pathname.new(root_path)
47
+ config_path = Pathname.new(config_path)
48
+
49
+ config = YAML.load_file(config_path)
50
+
51
+ @paths = Paths.new(
52
+ root_path, config_path,
53
+ extract_path_from_config(config, :sources, root_path),
54
+ extract_path_from_config(config, :sprites, root_path),
55
+ extract_path_from_config(config, :sass, root_path),
56
+ config.delete('config.sprite_url') { DEFAULTS[:sprite_url] }
57
+ )
58
+
59
+ @padding = (config.delete('config.padding') || 20).to_i
60
+
61
+ # All remaining config keys are sprite defintions.
62
+ @sprites = config.inject([]) do |sprites, (name, sources)|
63
+ sprites << Sprite.new(name, sources, self)
64
+ end
65
+ end
66
+
67
+ # Returns a particular sprite identified by +name+.
68
+ #
69
+ # @param [String] name
70
+ # The name of the sprite to be retrieved.
71
+ #
72
+ # @return [Montage::Sprite]
73
+ #
74
+ def sprite(name)
75
+ sprites.detect { |sprite| sprite.name == name }
76
+ end
77
+
78
+ private # ================================================================
79
+
80
+ # Extracts a configuration value from a configuration hash. If the value
81
+ # exists, and is a string, it will be appended to the +root+ path.
82
+ #
83
+ # The configuration item will be _removed_ from the hash.
84
+ #
85
+ # @param [Hash] config The configuration Hash.
86
+ # @param [Symbol] key The configuration key.
87
+ # @param [Pathname] root The project root path.
88
+ #
89
+ # @return [Pathname, false]
90
+ #
91
+ def extract_path_from_config(config, key, root)
92
+ value = config.delete("config.#{key}") { DEFAULTS[key] }
93
+ value.is_a?(String) ? root + value : value
94
+ end
95
+
96
+ # === Class Methods ======================================================
97
+
98
+ class << self
99
+
100
+ # Given a path to a directory, or config file, attempts to find the
101
+ # Montage root.
102
+ #
103
+ # If given a path to a file:
104
+ #
105
+ # * Montage assumes that the file is the configuration.
106
+ #
107
+ # * The root directory is assumed to be the same directory as the one
108
+ # in which the configuration resides _unless_ the directory is
109
+ # called 'config', in which case the root is considered to be the
110
+ # parent directory.
111
+ #
112
+ # If given a path to a directory:
113
+ #
114
+ # * Montage will look for montage.yml, or config/montage.yml.
115
+ #
116
+ # * If a configuration couldn't be found, +find+ looks in the next
117
+ # directory up. It continues until it finds a valid project or runs
118
+ # out of parent directories.
119
+ #
120
+ # @param [String, Pathname] path
121
+ # Path to the configuration or directory.
122
+ #
123
+ # @return [Montage::Project]
124
+ # Returns the project.
125
+ #
126
+ # @raise [MissingProject]
127
+ # Raised when a project directory couldn't be found.
128
+ #
129
+ def find(path)
130
+ path = Pathname(path).expand_path
131
+ config_path, root_path = nil, nil
132
+
133
+ if path.file?
134
+ root_path = find_root(path)
135
+ config_path = path
136
+ elsif path.directory?
137
+ if config_path = find_config(path)
138
+ root_path = path
139
+ else
140
+ # Assume we're in a subdirectory of the current project.
141
+ path.split.first.ascend do |directory|
142
+ if config_path = find_config(directory)
143
+ break if root_path = find_root(config_path)
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ raise MissingProject, "Montage couldn't find a project to work " \
150
+ "on at `#{path}'" if root_path.nil?
151
+
152
+ new(root_path, config_path)
153
+ end
154
+
155
+ private
156
+
157
+ # Attempt to find the configuration file, first by looking in
158
+ # ./montage.yml, then ./config/montage.yml
159
+ #
160
+ # @return [String]
161
+ #
162
+ def find_config(dir)
163
+ config_paths = [ dir + 'montage.yml', dir + 'config/montage.yml' ]
164
+ config_paths.detect { |config| config.file? }
165
+ end
166
+
167
+ # Attempts to find the project root for the configuration file. If the
168
+ # config file is in a directory called 'config' then the project root is
169
+ # assumed to be one level up.
170
+ #
171
+ # @return [String]
172
+ #
173
+ def find_root(config)
174
+ config_dir = config.dirname
175
+
176
+ if config_dir.split.last.to_s == 'config'
177
+ (config_dir + '..').expand_path
178
+ else
179
+ config_dir
180
+ end
181
+ end
182
+
183
+ end # class << self
184
+ end # Project
185
+ end # Montage
@@ -0,0 +1,37 @@
1
+ module Montage
2
+ # Given a project, builds a SASS file containing mixin to simplify use of
3
+ # the generated sprites in a project.
4
+ #
5
+ class SassBuilder
6
+
7
+ TEMPLATE = Pathname.new(__FILE__).dirname + 'templates/sass_mixins.erb'
8
+
9
+ # Creates a new SassBuilder instance.
10
+ #
11
+ # @param [Montage::Project] project
12
+ # The project whose Sass file is to be built.
13
+ #
14
+ def initialize(project)
15
+ @project = project
16
+ end
17
+
18
+ # Builds the Sass mixin file, then writes it to disk.
19
+ #
20
+ # @return [Boolean]
21
+ #
22
+ def write
23
+ if @project.paths.sass.to_s[-5..-1] == '.sass'
24
+ @project.paths.sass.dirname.mkpath
25
+ save_to = @project.paths.sass
26
+ else
27
+ @project.paths.sass.mkpath
28
+ save_to = @project.paths.sass + '_montage.sass'
29
+ end
30
+
31
+ File.open(save_to, 'w') do |file|
32
+ file.puts ERB.new(File.read(TEMPLATE), nil, '<>').result(binding)
33
+ end
34
+ end
35
+
36
+ end # SassBuilder
37
+ end
@@ -0,0 +1,75 @@
1
+ module Montage
2
+ # Represents a single source file used in a sprite.
3
+ #
4
+ class Source
5
+
6
+ attr_reader :name
7
+ alias_method :to_s, :name
8
+
9
+ # Creates a new Source instance.
10
+ #
11
+ # @param [Pathname] dir
12
+ # The directory in which the source image should stored.
13
+ # @param [String] name
14
+ # The name of the source image, sans extension
15
+ # @param [String] sprite
16
+ # The name of the sprite to which the source belongs. Used only in
17
+ # error messages.
18
+ #
19
+ def initialize(dir, name, sprite_name)
20
+ @dir, @name, @sprite_name = dir, name, sprite_name
21
+ end
22
+
23
+ def inspect # :nodoc
24
+ "#<Montage::Source #{@sprite_name}:#{@name}>"
25
+ end
26
+
27
+ # Returns the full path to the source image.
28
+ #
29
+ # @return [Pathname]
30
+ # @raise [Montage::MissingSource]
31
+ #
32
+ def path
33
+ @path ||= begin
34
+ unless @dir.directory?
35
+ raise MissingSource, <<-MESSAGE.compress_lines
36
+ Couldn't find the source directory for the `#{@sprite_name}'
37
+ sprite. Montage was looking for #{@dir}; if your sprites are in a
38
+ different location, add a 'config.sources' option your config
39
+ file.
40
+ MESSAGE
41
+ end
42
+
43
+ path = @dir.entries.detect do |entry|
44
+ entry.to_s.chomp(entry.extname) == @name
45
+ end
46
+
47
+ if path.nil?
48
+ raise MissingSource, <<-MESSAGE.compress_lines
49
+ Couldn't find a matching file for source image `#{@name}' as part
50
+ of the `#{@sprite_name}' sprite. Was looking in `#{@dir}'.
51
+ MESSAGE
52
+ end
53
+
54
+ @dir + path
55
+ end
56
+ end
57
+
58
+ # Returns the RMagick image instance representing the source.
59
+ #
60
+ # @return [Magick::Image]
61
+ #
62
+ def image
63
+ @image ||= Magick::Image.read(path).first
64
+ end
65
+
66
+ # Returns a digest which represents the sprite name and file contents.
67
+ #
68
+ # @return [String]
69
+ #
70
+ def digest
71
+ Digest::SHA256.hexdigest(@name + Digest::SHA256.file(path).to_s)
72
+ end
73
+
74
+ end # Source
75
+ end # Montage