montage 0.2.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.
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