pinion 0.1.2 → 0.1.3

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