lotus-assets 0.0.0 → 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,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