pinion 0.1.2 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -15,13 +15,15 @@ simple and lightweight solution. It is driven by these core goals (bold goals ar
15
15
  * **No added syntax to your assets (e.g. no `//= require my_other_asset`)**
16
16
  * **Recompile all compiled assets when they change (or dependencies change) in development and set mtimes**
17
17
  * Recompile asynchronously from requests (no polling allowed)
18
- * Compile assets one time in production
18
+ * **Compile assets one time in production**
19
19
 
20
20
  Installation
21
21
  ============
22
22
 
23
23
  $ gem install pinion
24
24
 
25
+ You should add pinion to your project's Gemfile.
26
+
25
27
  Usage
26
28
  =====
27
29
 
@@ -32,37 +34,50 @@ The easiest way to use Pinion is to map your desired asset mount point to a `Pin
32
34
  require "pinion"
33
35
  require "your_app.rb"
34
36
 
35
- map "/assets" do
36
- server = Pinion::Server.new
37
- # Tell Pinion each type of conversion it should perform
38
- server.convert :scss => :css # Sass and Coffeescript will just work if you have the gems installed
39
- server.convert :coffee => :js # Conversion types correspond to file extensions. .coffee -> .js
40
- server.convert :styl => :css do |file_contents|
41
- Stylus.compile file_contents # Requires the stylus gem
42
- end
43
- # Tell Pinion the paths to watch
44
- server.watch "public/javascripts"
45
- server.watch "public/scss"
46
- server.watch "public/stylus"
37
+ MOUNT_POINT = "/assets"
38
+ pinion = Pinion::Server.new(MOUNT_POINT)
39
+ # Tell Pinion each type of conversion it should perform
40
+ pinion.convert :scss => :css # Sass and Coffeescript will just work if you have the gems installed
41
+ pinion.convert :coffee => :js # Conversion types correspond to file extensions. .coffee -> .js
42
+ pinion.convert :styl => :css do |file_contents|
43
+ Stylus.compile file_contents # Requires the stylus gem
44
+ end
45
+ # Tell Pinion the paths to watch
46
+ pinion.watch "public/javascripts"
47
+ pinion.watch "public/scss"
48
+ pinion.watch "public/stylus"
49
+
50
+ map MOUNT_POINT do
47
51
  # Boom
48
- run server
52
+ run pinion
49
53
  end
50
54
 
51
55
  map "/" do
52
- run Your::App.new
56
+ # You should pass pinion into your app in order to use its helper methods.
57
+ run Your::App.new(pinion)
53
58
  end
54
59
  ```
55
60
 
61
+ In your app, you will use pinion's helper methods to construct urls:
62
+
63
+ ``` erb
64
+ <head>
65
+ <title>My App</title>
66
+ <link type="text/css" rel="stylesheet" href="<%= pinion.asset_url("/assets/style.css") %>" />
67
+ <!-- Shorthand equivalent -->
68
+ <%= pinion.css_url("style.css") %>
69
+ </head>
70
+ ```
71
+
56
72
  Notes
57
73
  -----
58
74
 
59
- * Everything in `/assets` (in the example) will be served as-is. No conversions will be performed on those
60
- files (unless you add `/assets` as a watch path).
61
75
  * Currently, Pinion sidesteps the dependency question by invalidating its cache of each file of a particular
62
76
  type (say, all `.scss` files) when any such source file is changed.
63
77
  * The order that paths are added to the watch list is a priority order in case of conflicting assets. (For
64
78
  intance, if `foo/bar` and `foo/baz` are both on the watch list, and both of the files `foo/bar/style.scss`
65
- and `foo/baz/style.scss` exist, then `foo/bar/style.scss` will be used if a request occurs for `/style.css`.
79
+ and `foo/baz/style.scss` exist, then `foo/bar/style.scss` will be used if a request occurs for
80
+ `/style.css`.)
66
81
 
67
82
  You can see an example app using Pinion and Sinatra in the `example/` directory.
68
83
 
@@ -0,0 +1,23 @@
1
+ require "digest/md5"
2
+
3
+ module Pinion
4
+ class Asset
5
+ attr_reader :uncompiled_path, :compiled_path, :from_type, :to_type, :compiled_contents, :length, :mtime,
6
+ :content_type, :checksum
7
+
8
+ def initialize(uncompiled_path, compiled_path, conversion, mtime)
9
+ @uncompiled_path = uncompiled_path
10
+ @compiled_path = compiled_path
11
+ @from_type = conversion.from_type
12
+ @to_type = conversion.to_type
13
+ @compiled_contents = conversion.convert(File.read(uncompiled_path))
14
+ @length = Rack::Utils.bytesize(@compiled_contents)
15
+ @mtime = mtime
16
+ @content_type = conversion.content_type
17
+ @checksum = Digest::MD5.hexdigest(@compiled_contents)
18
+ end
19
+
20
+ # Allow the Asset to be served as a rack response body
21
+ def each() yield @compiled_contents end
22
+ end
23
+ end
@@ -22,6 +22,7 @@ module Pinion
22
22
  attr_reader :from_type, :to_type, :gem_required
23
23
 
24
24
  def initialize(from_type, to_type)
25
+ @loaded = false
25
26
  @from_type = from_type
26
27
  @to_type = to_type
27
28
  @gem_required = nil
@@ -45,16 +46,11 @@ module Pinion
45
46
  raise Error, "No known content-type for #{@to_type}."
46
47
  end
47
48
  end
48
- def convert(file_contents) @conversion_fn.call(file_contents, @environment) end
49
- def add_watch_directory(path) @watch_fn.call(path, @environment) end
50
- def require_dependency
51
- return unless @gem_required
52
- begin
53
- require @gem_required
54
- rescue LoadError => e
55
- raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
56
- end
49
+ def convert(file_contents)
50
+ require_dependency
51
+ @conversion_fn.call(file_contents, @environment)
57
52
  end
53
+ def add_watch_directory(path) @watch_fn.call(path, @environment) end
58
54
 
59
55
  def verify
60
56
  unless [@from_type, @to_type].all? { |s| s.is_a? Symbol }
@@ -64,6 +60,19 @@ module Pinion
64
60
  raise Error, "Must provide a conversion function with convert { |file_contents| ... }."
65
61
  end
66
62
  end
63
+
64
+ private
65
+
66
+ def require_dependency
67
+ return if @loaded
68
+ @loaded = true
69
+ return unless @gem_required
70
+ begin
71
+ require @gem_required
72
+ rescue LoadError => e
73
+ raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
74
+ end
75
+ end
67
76
  end
68
77
 
69
78
  # Define built-in conversions
@@ -0,0 +1,37 @@
1
+ require "set"
2
+ require "time"
3
+
4
+ module Pinion
5
+ class DirectoryWatcher
6
+ def initialize(root = ".")
7
+ @root = root
8
+ @watch_directories = Set.new
9
+ end
10
+
11
+ def <<(directory) @watch_directories << File.join(@root, directory) end
12
+
13
+ def glob(pattern, &block)
14
+ enumerator = Enumerator.new do |yielder|
15
+ @watch_directories.each do |directory|
16
+ Dir.glob(File.join(directory, pattern)) { |filename| yielder.yield filename }
17
+ end
18
+ end
19
+ enumerator.each(&block)
20
+ end
21
+
22
+ def find(path)
23
+ @watch_directories.each do |directory|
24
+ result = File.join(directory, path)
25
+ return result if File.file? result
26
+ end
27
+ nil
28
+ end
29
+
30
+ def latest_mtime_with_suffix(suffix)
31
+ pattern = "**/*#{DirectoryWatcher.sanitize_for_glob(suffix)}"
32
+ glob(pattern).reduce(Time.at(0)) { |latest, path| [latest, File.stat(path).mtime].max }
33
+ end
34
+
35
+ def self.sanitize_for_glob(pattern) pattern.gsub(/[\*\?\[\]\{\}]/) { |match| "\\#{match}" } end
36
+ end
37
+ end
data/lib/pinion/server.rb CHANGED
@@ -1,23 +1,19 @@
1
- #require "fssm"
2
-
3
- require "pinion/error"
1
+ require "pinion/asset"
4
2
  require "pinion/conversion"
5
- require "time"
6
- require "set"
3
+ require "pinion/directory_watcher"
4
+ require "pinion/error"
7
5
 
8
6
  module Pinion
9
7
  class Server
10
- Asset = Struct.new :from_path, :to_path, :from_type, :to_type, :compiled_contents, :length, :mtime,
11
- :content_type
12
- Watch = Struct.new :path, :from_type, :to_type, :conversion
13
-
14
- def initialize
15
- @running = false
16
- @watch_directories = []
17
- @watches = []
8
+ # TODO: is there a way to figure out the mount point ourselves? The only way I can find would be to wait
9
+ # for a request and compare REQUEST_PATH to PATH_INFO, but that's super hacky and won't work anyway
10
+ # because we need that information before requests are handled due to #asset_url
11
+ def initialize(mount_point)
12
+ @mount_point = mount_point
13
+ @watcher = DirectoryWatcher.new
18
14
  @cached_assets = {}
19
- @conversions_used = Set.new
20
15
  @file_server = Rack::File.new(Dir.pwd)
16
+ @environment = (defined?(RACK_ENV) && RACK_ENV) || ENV["RACK_ENV"] || "development"
21
17
  end
22
18
 
23
19
  def convert(from_and_to, &block)
@@ -42,15 +38,13 @@ module Pinion
42
38
 
43
39
  def watch(path)
44
40
  raise Error, "#{path} is not a directory." unless File.directory? path
45
- @watch_directories << path
41
+ @watcher << path
46
42
  Conversion.add_watch_directory path
47
43
  end
48
44
 
49
45
  # Boilerplate mostly stolen from sprockets
50
46
  # https://github.com/sstephenson/sprockets/blob/master/lib/sprockets/server.rb
51
47
  def call(env)
52
- start unless @running
53
-
54
48
  # Avoid modifying the session state, don't set cookies, etc
55
49
  env["rack.session.options"] ||= {}
56
50
  env["rack.session.options"].merge! :defer => true, :skip => true
@@ -62,7 +56,12 @@ module Pinion
62
56
  return [403, { "Content-Type" => "text/plain", "Content-Length" => "9" }, ["Forbidden"]]
63
57
  end
64
58
 
65
- real_file = get_real_file(path)
59
+ # Pull out the md5sum if it's part of the given path
60
+ # e.g. foo/bar-a95c53a7a0f5f492a74499e70578d150.js -> a95c53a7a0f5f492a74499e70578d150
61
+ checksum_tag = path[/-([\da-f]{32})\..+$/, 1]
62
+ path.sub!("-#{checksum_tag}", "") if checksum_tag
63
+
64
+ real_file = @watcher.find path
66
65
  if real_file
67
66
  # Total hack; this is probably a big misuse of Rack::File but I don't want to have to reproduce a lot
68
67
  # of its logic
@@ -71,17 +70,23 @@ module Pinion
71
70
  return @file_server.call(env)
72
71
  end
73
72
 
74
- asset = get_asset(path)
73
+ asset = find_asset(path)
74
+
75
+ # If the ETag matches, give a 304
76
+ return [304, {}, []] if env["HTTP_IF_NONE_MATCH"] == %Q["#{asset.checksum}"]
77
+
75
78
  if asset
79
+ # Cache for a year in production; don't cache in dev
80
+ cache_policy = checksum_tag ? "max-age=31536000" : "must-revalidate"
76
81
  headers = {
77
82
  "Content-Type" => asset.content_type,
78
83
  "Content-Length" => asset.length.to_s,
79
- # TODO: set a long cache in prod mode when implemented
80
- "Cache-Control" => "public, must-revalidate",
84
+ "ETag" => %Q["#{asset.checksum}"],
85
+ "Cache-Control" => "public, #{cache_policy}",
81
86
  "Last-Modified" => asset.mtime.httpdate,
82
87
  }
83
88
  return [200, headers, []] if env["REQUEST_METHOD"] == "HEAD"
84
- [200, headers, asset.compiled_contents]
89
+ [200, headers, asset]
85
90
  else
86
91
  [404, { "Content-Type" => "text/plain", "Content-Length" => "9" }, ["Not found"]]
87
92
  end
@@ -93,28 +98,40 @@ module Pinion
93
98
  raise
94
99
  end
95
100
 
96
- private
101
+ # Helper methods for an application to generate urls (with fingerprints in production)
102
+ def asset_url(path)
103
+ path.sub!(%r[^(#{@mount_point})?/?], "")
104
+ mounted_path = "#{@mount_point}/#{path}"
97
105
 
98
- def get_real_file(path)
99
- @watch_directories.each do |directory|
100
- file = File.join(directory, path)
101
- return file if File.file? file
102
- end
103
- nil
106
+ # TODO: Change the real file behavior if I replace the use of Rack::File above
107
+ return mounted_path if @watcher.find(path)
108
+
109
+ return mounted_path unless @environment == "production"
110
+
111
+ # Add on a checksum tag in production
112
+ asset = find_asset(path)
113
+ raise "Error: no such asset available: #{path}" unless asset
114
+ mounted_path, dot, extension = mounted_path.rpartition(".")
115
+ return mounted_path if dot.empty?
116
+ "#{mounted_path}-#{asset.checksum}.#{extension}"
104
117
  end
118
+ def css_url(path) %Q{<link type="text/css" rel="stylesheet" href="#{asset_url(path)}" />} end
119
+ def js_url(path) %Q{<script type="application/javascript" src="#{asset_url(path)}"></script>} end
105
120
 
106
- def get_asset(to_path)
121
+ private
122
+
123
+ def find_asset(to_path)
107
124
  asset = @cached_assets[to_path]
108
125
  if asset
109
126
  mtime = asset.mtime
110
- latest = latest_mtime_of_type(asset.from_type)
127
+ latest = @watcher.latest_mtime_with_suffix(asset.from_type.to_s)
111
128
  if latest > mtime
112
129
  invalidate_all_assets_of_type(asset.from_type)
113
- return get_asset(to_path)
130
+ return find_asset(to_path)
114
131
  end
115
132
  else
116
133
  begin
117
- asset = get_uncached_asset(to_path)
134
+ asset = find_uncached_asset(to_path)
118
135
  rescue Error
119
136
  return nil
120
137
  end
@@ -123,41 +140,22 @@ module Pinion
123
140
  asset
124
141
  end
125
142
 
126
- def latest_mtime_of_type(type)
127
- latest = Time.at(0)
128
- @watch_directories.each do |directory|
129
- Dir[File.join(directory, "**/*.#{type}")].each do |file|
130
- mtime = File.stat(file).mtime
131
- latest = mtime if mtime > latest
132
- end
133
- end
134
- latest
135
- end
136
-
137
- def get_uncached_asset(to_path)
143
+ def find_uncached_asset(to_path)
138
144
  from_path, conversion = find_source_file_and_conversion(to_path)
139
145
  # If we reach this point we've found the asset we're going to compile
140
- conversion.require_dependency unless @conversions_used.include? conversion
141
- @conversions_used << conversion
142
146
  # TODO: log at info: compiling asset ...
143
- contents = conversion.convert(File.read(from_path))
144
- length = File.stat(from_path).size
145
- mtime = latest_mtime_of_type(conversion.from_type)
146
- content_type = conversion.content_type
147
- return Asset.new from_path, to_path, conversion.from_type, conversion.to_type,
148
- [contents], contents.length, mtime, content_type
147
+ mtime = @watcher.latest_mtime_with_suffix(conversion.to_type.to_s)
148
+ Asset.new from_path, to_path, conversion, mtime
149
149
  end
150
150
 
151
151
  def find_source_file_and_conversion(to_path)
152
152
  path, dot, suffix = to_path.rpartition(".")
153
153
  conversions = Conversion.conversions_for(suffix.to_sym)
154
154
  raise Error, "No conversion for for #{to_path}" if conversions.empty?
155
- @watch_directories.each do |directory|
156
- conversions.each do |conversion|
157
- Dir[File.join(directory, "#{path}.#{conversion.from_type}")].each do |from_path|
158
- return [from_path, conversion]
159
- end
160
- end
155
+ conversions.each do |conversion|
156
+ filename = "#{path}.#{conversion.from_type}"
157
+ from_path = @watcher.find filename
158
+ return [from_path, conversion] if from_path
161
159
  end
162
160
  raise Error, "No source file found for #{to_path}"
163
161
  end
@@ -170,17 +168,5 @@ module Pinion
170
168
  status, headers, body = response
171
169
  [status, headers.merge({ "Content-Length" => Rack::Utils.bytesize(body).to_s }), body]
172
170
  end
173
-
174
- def update_asset(asset)
175
- end
176
-
177
- def start
178
- @running = true
179
- # TODO: mad threadz
180
- # Start a thread with an FSSM watch on each directory. Upon detecting a change to a compiled file that
181
- # is a dependency of any asset in @required_assets, call update_asset for each affected asset.
182
- #
183
- # There are some tricky threading issues here.
184
- end
185
171
  end
186
172
  end
@@ -1,3 +1,3 @@
1
1
  module Pinion
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinion
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,11 +9,11 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-03-01 00:00:00.000000000Z
12
+ date: 2012-04-27 00:00:00.000000000Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
16
- requirement: &70301404900360 !ruby/object:Gem::Requirement
16
+ requirement: &23433260 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: '1.0'
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *70301404900360
24
+ version_requirements: *23433260
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: rake
27
- requirement: &70301404899940 !ruby/object:Gem::Requirement
27
+ requirement: &23432840 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ! '>='
@@ -32,7 +32,7 @@ dependencies:
32
32
  version: '0'
33
33
  type: :development
34
34
  prerelease: false
35
- version_requirements: *70301404899940
35
+ version_requirements: *23432840
36
36
  description: ! 'Pinion is a Rack application that you can use to compile and serve
37
37
  assets (such as Javascript and CSS).
38
38
 
@@ -44,9 +44,11 @@ extensions: []
44
44
  extra_rdoc_files: []
45
45
  files:
46
46
  - README.md
47
+ - lib/pinion/asset.rb
47
48
  - lib/pinion/conversion.rb
48
49
  - lib/pinion/error.rb
49
50
  - lib/pinion/server.rb
51
+ - lib/pinion/directory_watcher.rb
50
52
  - lib/pinion/version.rb
51
53
  - lib/pinion.rb
52
54
  homepage: https://github.com/ooyala/pinion
@@ -61,18 +63,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
61
63
  - - ! '>='
62
64
  - !ruby/object:Gem::Version
63
65
  version: '0'
64
- segments:
65
- - 0
66
- hash: -3405067065911020567
67
66
  required_rubygems_version: !ruby/object:Gem::Requirement
68
67
  none: false
69
68
  requirements:
70
69
  - - ! '>='
71
70
  - !ruby/object:Gem::Version
72
71
  version: '0'
73
- segments:
74
- - 0
75
- hash: -3405067065911020567
76
72
  requirements: []
77
73
  rubyforge_project: pinion
78
74
  rubygems_version: 1.8.10