lotus-assets 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+
6
+ options = {}
7
+ OptionParser.new do |opts|
8
+ opts.banner = "Usage: lotus-assets --config=path/to/config.rb"
9
+
10
+ opts.on("-c", "--config FILE", "Path to config") do |c|
11
+ options[:config] = c
12
+ end
13
+ end.parse!
14
+
15
+ config = options.fetch(:config) { raise ArgumentError.new("You must specify a configuration file") }
16
+ config = Pathname.new(config)
17
+ config.exist? or raise ArgumentError.new("Cannot find configuration file: #{ config }")
18
+
19
+ require 'lotus/assets'
20
+ load config
21
+
22
+ Lotus::Assets.deploy
@@ -1,7 +1,158 @@
1
- require "lotus/assets/version"
1
+ require 'thread'
2
+ require 'lotus/utils/class_attribute'
2
3
 
3
4
  module Lotus
5
+ # Assets management for Ruby web applications
6
+ #
7
+ # @since 0.1.0
4
8
  module Assets
5
- # Your code goes here...
9
+ # Base error for Lotus::Assets
10
+ #
11
+ # All the errors defined in this framework MUST inherit from it.
12
+ #
13
+ # @since 0.1.0
14
+ class Error < ::StandardError
15
+ end
16
+
17
+ require 'lotus/assets/version'
18
+ require 'lotus/assets/configuration'
19
+ require 'lotus/assets/config/global_sources'
20
+ require 'lotus/assets/helpers'
21
+
22
+ include Utils::ClassAttribute
23
+
24
+ # Configuration
25
+ #
26
+ # @since 0.1.0
27
+ # @api private
28
+ class_attribute :configuration
29
+ self.configuration = Configuration.new
30
+
31
+ # Configure framework
32
+ #
33
+ # @param blk [Proc] configuration code block
34
+ #
35
+ # @return self
36
+ #
37
+ # @since 0.1.0
38
+ #
39
+ # @see Lotus::Assets::Configuration
40
+ def self.configure(&blk)
41
+ configuration.instance_eval(&blk)
42
+ self
43
+ end
44
+
45
+ # Prepare assets for deploys
46
+ #
47
+ # @since 0.1.0
48
+ def self.deploy
49
+ require 'lotus/assets/precompiler'
50
+ require 'lotus/assets/bundler'
51
+
52
+ Precompiler.new(configuration, duplicates).run
53
+ Bundler.new(configuration, duplicates).run
54
+ end
55
+
56
+ # Preload the framework
57
+ #
58
+ # This MUST be used in production mode
59
+ #
60
+ # @since 0.1.0
61
+ #
62
+ # @example Direct Invocation
63
+ # require 'lotus/assets'
64
+ #
65
+ # Lotus::Assets.load!
66
+ #
67
+ # @example Load Via Configuration Block
68
+ # require 'lotus/assets'
69
+ #
70
+ # Lotus::Assets.configure do
71
+ # # ...
72
+ # end.load!
73
+ def self.load!
74
+ configuration.load!
75
+ end
76
+
77
+ # Global assets sources
78
+ #
79
+ # This is designed for third party integration gems with frontend frameworks
80
+ # like Bootstrap, Ember.js or React.
81
+ #
82
+ # Developers can maintain gems that ship static assets for these frameworks
83
+ # and make them available to Lotus::Assets.
84
+ #
85
+ # @return [Lotus::Assets::Config::GlobalSources]
86
+ #
87
+ # @since 0.1.0
88
+ #
89
+ # @example Ember.js Integration
90
+ # # lib/lotus/emberjs.rb (third party gem)
91
+ # require 'lotus/assets'
92
+ #
93
+ # Lotus::Assets.sources << '/path/to/emberjs/assets'
94
+ def self.sources
95
+ synchronize do
96
+ @@sources ||= Config::GlobalSources.new
97
+ end
98
+ end
99
+
100
+ # Duplicate the framework and generate modules for the target application
101
+ #
102
+ # @param mod [Module] the Ruby namespace of the application
103
+ # @param blk [Proc] an optional block to configure the framework
104
+ #
105
+ # @return [Module] a copy of Lotus::Assets
106
+ #
107
+ # @since 0.1.0
108
+ #
109
+ # @see Lotus::Assets#dupe
110
+ # @see Lotus::Assets::Configuration
111
+ def self.duplicate(mod, &blk)
112
+ dupe.tap do |duplicated|
113
+ duplicated.configure(&blk) if block_given?
114
+ duplicates << duplicated
115
+ end
116
+ end
117
+
118
+ # Duplicate Lotus::Assets in order to create a new separated instance
119
+ # of the framework.
120
+ #
121
+ # The new instance of the framework will be completely decoupled from the
122
+ # original. It will inherit the configuration, but all the changes that
123
+ # happen after the duplication, won't be reflected on the other copies.
124
+ #
125
+ # @return [Module] a copy of Lotus::Assets
126
+ #
127
+ # @since 0.1.0
128
+ # @api private
129
+ def self.dupe
130
+ dup.tap do |duplicated|
131
+ duplicated.configuration = configuration.duplicate
132
+ end
133
+ end
134
+
135
+ # Keep track of duplicated frameworks
136
+ #
137
+ # @return [Array] a collection of duplicated frameworks
138
+ #
139
+ # @since 0.1.0
140
+ # @api private
141
+ #
142
+ # @see Lotus::Assets#duplicate
143
+ # @see Lotus::Assets#dupe
144
+ def self.duplicates
145
+ synchronize do
146
+ @@duplicates ||= Array.new
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # @since 0.1.0
153
+ # @api private
154
+ def self.synchronize(&blk)
155
+ Mutex.new.synchronize(&blk)
156
+ end
6
157
  end
7
158
  end
@@ -0,0 +1,173 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'json'
4
+
5
+ module Lotus
6
+ module Assets
7
+ # Bundle assets from a single application.
8
+ #
9
+ # @since 0.1.0
10
+ # @api private
11
+ class Bundler
12
+ # @since 0.1.0
13
+ # @api private
14
+ DEFAULT_PERMISSIONS = 0644
15
+
16
+ # @since 0.1.0
17
+ # @api private
18
+ JAVASCRIPT_EXT = '.js'.freeze
19
+
20
+ # @since 0.1.0
21
+ # @api private
22
+ STYLESHEET_EXT = '.css'.freeze
23
+
24
+ # @since 0.1.0
25
+ # @api private
26
+ WILDCARD_EXT = '.*'.freeze
27
+
28
+ # @since 0.1.0
29
+ # @api private
30
+ URL_SEPARATOR = '/'.freeze
31
+
32
+ # @since 0.1.0
33
+ # @api private
34
+ URL_REPLACEMENT = ''.freeze
35
+
36
+ # Return a new instance
37
+ #
38
+ # @param configuration [Lotus::Assets::Configuration] a single application configuration
39
+ #
40
+ # @param duplicates [Array<Lotus::Assets>] the duplicated frameworks
41
+ # (one for each application)
42
+ #
43
+ # @return [Lotus::Assets::Bundler] a new instance
44
+ #
45
+ # @since 0.1.0
46
+ # @api private
47
+ def initialize(configuration, duplicates)
48
+ @manifest = Hash.new
49
+ @configuration = configuration
50
+ @configurations = if duplicates.empty?
51
+ [@configuration]
52
+ else
53
+ duplicates.map(&:configuration)
54
+ end
55
+ end
56
+
57
+ # Start the process.
58
+ #
59
+ # For each asset contained in the sources and third party gems, it will:
60
+ #
61
+ # * Compress
62
+ # * Create a checksum version
63
+ #
64
+ # At the end it will generate a digest manifest
65
+ #
66
+ # @see Lotus::Assets::Configuration#digest
67
+ # @see Lotus::Assets::Configuration#manifest
68
+ # @see Lotus::Assets::Configuration#manifest_path
69
+ def run
70
+ assets.each do |asset|
71
+ next if ::File.directory?(asset)
72
+
73
+ compress(asset)
74
+ checksum(asset)
75
+ end
76
+
77
+ generate_manifest
78
+ end
79
+
80
+ private
81
+
82
+ # @since 0.1.0
83
+ # @api private
84
+ def assets
85
+ Dir.glob("#{ public_directory }#{ ::File::SEPARATOR }**#{ ::File::SEPARATOR }*")
86
+ end
87
+
88
+ # @since 0.1.0
89
+ # @api private
90
+ def compress(asset)
91
+ case File.extname(asset)
92
+ when JAVASCRIPT_EXT then _compress(compressor(:js, asset), asset)
93
+ when STYLESHEET_EXT then _compress(compressor(:css, asset), asset)
94
+ end
95
+ end
96
+
97
+ # @since 0.1.0
98
+ # @api private
99
+ def checksum(asset)
100
+ digest = Digest::MD5.file(asset)
101
+ filename, ext = ::File.basename(asset, WILDCARD_EXT), ::File.extname(asset)
102
+ directory = ::File.dirname(asset)
103
+ target = [directory, "#{ filename }-#{ digest }#{ ext }"].join(::File::SEPARATOR)
104
+
105
+ FileUtils.cp(asset, target)
106
+ _set_permissions(target)
107
+
108
+ store_manifest(asset, target)
109
+ end
110
+
111
+ # @since 0.1.0
112
+ # @api private
113
+ def generate_manifest
114
+ _write(@configuration.manifest_path, JSON.dump(@manifest))
115
+ end
116
+
117
+ # @since 0.1.0
118
+ # @api private
119
+ def store_manifest(asset, target)
120
+ @manifest[_convert_to_url(::File.expand_path(asset))] = _convert_to_url(::File.expand_path(target))
121
+ end
122
+
123
+ # @since 0.1.0
124
+ # @api private
125
+ def compressor(type, asset)
126
+ _configuration_for(asset).__send__(:"#{ type }_compressor")
127
+ end
128
+
129
+ # @since 0.1.0
130
+ # @api private
131
+ def _compress(compressor, asset)
132
+ _write(asset, compressor.compress(asset))
133
+ rescue => e
134
+ warn "Skipping compression of: `#{ asset }'\nReason: #{ e }\n\t#{ e.backtrace.join("\n\t") }\n\n"
135
+ end
136
+
137
+ # @since 0.1.0
138
+ # @api private
139
+ def _convert_to_url(path)
140
+ path.sub(public_directory.to_s, URL_REPLACEMENT).
141
+ gsub(File::SEPARATOR, URL_SEPARATOR)
142
+ end
143
+
144
+ # @since 0.1.0
145
+ # @api private
146
+ def _write(path, content)
147
+ Pathname.new(path).dirname.mkpath
148
+ ::File.write(path, content)
149
+
150
+ _set_permissions(path)
151
+ end
152
+
153
+ # @since 0.1.0
154
+ # @api private
155
+ def _set_permissions(path)
156
+ ::File.chmod(DEFAULT_PERMISSIONS, path)
157
+ end
158
+
159
+ def _configuration_for(asset)
160
+ url = _convert_to_url(asset)
161
+
162
+ @configurations.find {|config| url.start_with?(config.prefix) } ||
163
+ @configuration
164
+ end
165
+
166
+ # @since 0.1.0
167
+ # @api private
168
+ def public_directory
169
+ @configuration.public_directory
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,58 @@
1
+ require 'thread'
2
+
3
+ module Lotus
4
+ module Assets
5
+ # Store assets references when compile mode is on.
6
+ #
7
+ # This is expecially useful in development mode, where we want to compile
8
+ # only the assets that were changed from last browser refresh.
9
+ #
10
+ # @since 0.1.0
11
+ # @api private
12
+ class Cache
13
+ # Return a new instance
14
+ #
15
+ # @return [Lotus::Assets::Cache] a new instance
16
+ def initialize
17
+ @data = Hash.new{|h,k| h[k] = 0 }
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Check if the given file is fresh or changed from last check.
22
+ #
23
+ # @param file [String,Pathname] the file path
24
+ #
25
+ # @return [TrueClass,FalseClass] the result of the check
26
+ #
27
+ # @since 0.1.0
28
+ # @api private
29
+ def fresh?(file)
30
+ @mutex.synchronize do
31
+ @data[file.to_s] < mtime(file)
32
+ end
33
+ end
34
+
35
+ # Store the given file reference
36
+ #
37
+ # @param file [String,Pathname] the file path
38
+ #
39
+ # @return [TrueClass,FalseClass] the result of the check
40
+ #
41
+ # @since 0.1.0
42
+ # @api private
43
+ def store(file)
44
+ @mutex.synchronize do
45
+ @data[file.to_s] = mtime(file)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # @since 0.1.0
52
+ # @api private
53
+ def mtime(file)
54
+ file.mtime.utc.to_i
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,212 @@
1
+ module Lotus
2
+ module Assets
3
+ class MissingAsset < Error
4
+ def initialize(name, sources)
5
+ sources = sources.map(&:to_s).join(', ')
6
+ super("Missing asset: `#{ name }' (sources: #{ sources })")
7
+ end
8
+ end
9
+
10
+ class UnknownAssetEngine < Error
11
+ def initialize(source)
12
+ super("No asset engine registered for `#{ ::File.basename(source) }'")
13
+ end
14
+ end
15
+
16
+ # Assets compiler
17
+ #
18
+ # It compiles assets that needs to be preprocessed (eg. Sass or ES6) into
19
+ # the destination directory.
20
+ #
21
+ # Vanilla javascripts or stylesheets are just copied over.
22
+ #
23
+ # @since 0.1.0
24
+ # @api private
25
+ class Compiler
26
+ # @since 0.1.0
27
+ # @api private
28
+ DEFAULT_PERMISSIONS = 0644
29
+
30
+ # @since 0.1.0
31
+ # @api private
32
+ COMPILE_PATTERN = '*.*.*'.freeze # Example hello.js.es6
33
+
34
+ # @since 0.1.0
35
+ # @api private
36
+ EXTENSIONS = {'.js' => true, '.css' => true}.freeze
37
+
38
+ # @since 0.1.0
39
+ # @api private
40
+ SASS_CACHE_LOCATION = Pathname(Lotus.respond_to?(:root) ?
41
+ Lotus.root : Dir.pwd).join('tmp', 'sass-cache')
42
+
43
+ # Compile the given asset
44
+ #
45
+ # @param configuration [Lotus::Assets::Configuration] the application
46
+ # configuration associated with the given asset
47
+ #
48
+ # @param name [String] the asset path
49
+ #
50
+ # @since 0.1.0
51
+ # @api private
52
+ def self.compile(configuration, name)
53
+ return unless configuration.compile
54
+
55
+ require 'tilt'
56
+ require 'lotus/assets/cache'
57
+ new(configuration, name).compile
58
+ end
59
+
60
+ # Assets cache
61
+ #
62
+ # @since 0.1.0
63
+ # @api private
64
+ #
65
+ # @see Lotus::Assets::Cache
66
+ def self.cache
67
+ @@cache ||= Assets::Cache.new
68
+ end
69
+
70
+ # Return a new instance
71
+ #
72
+ # @param configuration [Lotus::Assets::Configuration] the application
73
+ # configuration associated with the given asset
74
+ #
75
+ # @param name [String] the asset path
76
+ #
77
+ # @return [Lotus::Assets::Compiler] a new instance
78
+ #
79
+ # @since 0.1.0
80
+ # @api private
81
+ def initialize(configuration, name)
82
+ @configuration = configuration
83
+ @name = Pathname.new(name)
84
+ end
85
+
86
+ # Compile the asset
87
+ #
88
+ # @raise [Lotus::Assets::MissingAsset] if the asset can't be found in
89
+ # sources
90
+ #
91
+ # @since 0.1.0
92
+ # @api private
93
+ def compile
94
+ raise MissingAsset.new(@name, @configuration.sources) unless exist?
95
+ return unless fresh?
96
+
97
+ if compile?
98
+ compile!
99
+ else
100
+ copy!
101
+ end
102
+
103
+ cache!
104
+ end
105
+
106
+ private
107
+
108
+ # @since 0.1.0
109
+ # @api private
110
+ def source
111
+ @source ||= begin
112
+ @name.absolute? ? @name :
113
+ @configuration.find(@name)
114
+ end
115
+ end
116
+
117
+ # @since 0.1.0
118
+ # @api private
119
+ def destination
120
+ @destination ||= @configuration.destination_directory.join(basename)
121
+ end
122
+
123
+ # @since 0.1.0
124
+ # @api private
125
+ def basename
126
+ result = ::File.basename(@name)
127
+
128
+ if compile?
129
+ result.scan(/\A[[[:alnum:]][\-\_]]*\.[[\w]]*/).first || result
130
+ else
131
+ result
132
+ end
133
+ end
134
+
135
+ # @since 0.1.0
136
+ # @api private
137
+ def exist?
138
+ !source.nil? &&
139
+ source.exist?
140
+ end
141
+
142
+ # @since 0.1.0
143
+ # @api private
144
+ def fresh?
145
+ !destination.exist? ||
146
+ cache.fresh?(source)
147
+ end
148
+
149
+ # @since 0.1.0
150
+ # @api private
151
+ def compile?
152
+ @compile ||= ::File.fnmatch(COMPILE_PATTERN, source.to_s) &&
153
+ !EXTENSIONS[::File.extname(source.to_s)]
154
+ end
155
+
156
+ # @since 0.1.0
157
+ # @api private
158
+ def compile!
159
+ # NOTE `:load_paths' is useful only for Sass engine, to make `@include' directive to work.
160
+ # For now we don't want to maintan a specialized Compiler version for Sass.
161
+ #
162
+ # If in the future other precompilers will need special treatment,
163
+ # we can consider to maintain several specialized versions in order to
164
+ # don't add a perf tax to all the other preprocessors who "just work".
165
+ #
166
+ # Example: if Less "just works", we can keep it in the general `Compiler',
167
+ # but have a `SassCompiler` if it requires more than `:load_paths'.
168
+ #
169
+ # NOTE: We need another option to pass for Sass: `:cache_location'.
170
+ #
171
+ # This is needed to don't create a `.sass-cache' directory at the root of the project,
172
+ # but to have it under `tmp/sass-cache'.
173
+ write { Tilt.new(source, nil, load_paths: @configuration.sources.to_a, cache_location: sass_cache_location).render }
174
+ rescue RuntimeError
175
+ raise UnknownAssetEngine.new(source)
176
+ end
177
+
178
+ # @since 0.1.0
179
+ # @api private
180
+ def copy!
181
+ write { source.read }
182
+ end
183
+
184
+ # @since 0.1.0
185
+ # @api private
186
+ def cache!
187
+ cache.store(source)
188
+ end
189
+
190
+ # @since 0.1.0
191
+ # @api private
192
+ def write
193
+ destination.dirname.mkpath
194
+ destination.open(File::WRONLY|File::CREAT, DEFAULT_PERMISSIONS) do |file|
195
+ file.write(yield)
196
+ end
197
+ end
198
+
199
+ # @since 0.1.0
200
+ # @api private
201
+ def cache
202
+ self.class.cache
203
+ end
204
+
205
+ # @since 0.1.0
206
+ # @api private
207
+ def sass_cache_location
208
+ SASS_CACHE_LOCATION
209
+ end
210
+ end
211
+ end
212
+ end