pinion 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.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ Pinion
2
+ ======
3
+
4
+ Pinion is a Rack application that serves assets, possibly transforming them in the process. It is generally
5
+ useful for serving Javascript and CSS, and things that compile to Javascript and CSS such as Coffeescript and
6
+ Sass.
7
+
8
+ Goals
9
+ =====
10
+
11
+ There are a lot of tools that accomplish very similar things in this space. Pinion is meant to be a very
12
+ simple and lightweight solution. It is driven by these core goals (bold goals are implemented):
13
+
14
+ * **Simple configuration and usage.**
15
+ * **No added syntax to your assets (e.g. no `//= require my_other_asset`)**
16
+ * **Recompile all compiled assets when they change (or dependencies change) in development and set mtimes**
17
+ * Recompile asynchronously from requests (no polling allowed)
18
+ * Compile assets one time in production
19
+
20
+ Installation
21
+ ============
22
+
23
+ $ gem install pinion
24
+
25
+ Usage
26
+ =====
27
+
28
+ The easiest way to use Pinion is to map your desired asset mount point to a `Pinion::Server` instance in your
29
+ `config.ru`.
30
+
31
+ ``` ruby
32
+ require "pinion"
33
+ require "your_app.rb"
34
+
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"
47
+ # Boom
48
+ run server
49
+ end
50
+
51
+ map "/" do
52
+ run Your::App.new
53
+ end
54
+ ```
55
+
56
+ Notes
57
+ -----
58
+
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
+ * Currently, Pinion sidesteps the dependency question by invalidating its cache of each file of a particular
62
+ type (say, all `.scss` files) when any such source file is changed.
63
+ * The order that paths are added to the watch list is a priority order in case of conflicting assets. (For
64
+ 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`.
66
+
67
+ You can see an example app using Pinion and Sinatra in the `example/` directory.
68
+
69
+ Authors
70
+ =======
71
+
72
+ Pinion was written by Caleb Spare ([cespare](https://github.com/cespare)). Inspiration from
73
+ [sprockets](https://github.com/sstephenson/sprockets) and [rerun](https://github.com/alexch/rerun).
74
+
75
+ License
76
+ =======
77
+
78
+ Pinion is released under [the MIT License](http://www.opensource.org/licenses/mit-license.php).
@@ -0,0 +1,78 @@
1
+ require "pinion/error"
2
+
3
+ module Pinion
4
+ # A conversion describes how to convert certain types of files and create asset links for them.
5
+ # Conversions.create() provides a tiny DSL for defining new conversions
6
+ class Conversion
7
+ @@conversions = {}
8
+ def self.[](from_and_to) @@conversions[from_and_to] end
9
+ def self.conversions_for(to) @@conversions.values.select { |c| c.to_type == to } end
10
+ def self.create(from_and_to, &block)
11
+ unless from_and_to.is_a?(Hash) && from_and_to.size == 1
12
+ raise Error, "Unexpected argument to Conversion.create: #{from_and_to.inspect}"
13
+ end
14
+ conversion = Conversion.new *from_and_to.to_a[0]
15
+ conversion.instance_eval &block
16
+ conversion.verify
17
+ @@conversions[conversion.signature] = conversion
18
+ end
19
+
20
+ attr_reader :from_type, :to_type, :gem_required
21
+
22
+ def initialize(from_type, to_type)
23
+ @from_type = from_type
24
+ @to_type = to_type
25
+ @gem_required = nil
26
+ @conversion_fn = nil
27
+ end
28
+
29
+ # DSL methods
30
+ def require_gem(gem_name) @gem_required = gem_name end
31
+ def render(&block) @conversion_fn = block end
32
+
33
+ # Instance methods
34
+ def signature() { @from_type => @to_type } end
35
+ def content_type
36
+ case @to_type
37
+ when :css then "text/css"
38
+ when :js then "application/javascript"
39
+ else
40
+ raise Error, "No known content-type for #{@to_type}."
41
+ end
42
+ end
43
+ def convert(file_contents) @conversion_fn.call(file_contents) end
44
+ def require_dependency
45
+ return unless @gem_required
46
+ begin
47
+ require @gem_required
48
+ rescue LoadError => e
49
+ raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
50
+ end
51
+ end
52
+
53
+ def verify
54
+ unless [@from_type, @to_type].all? { |s| s.is_a? Symbol }
55
+ raise Error, "Expecting symbol key/value but got #{from_and_to.inspect}"
56
+ end
57
+ unless @conversion_fn
58
+ raise Error, "Must provide a conversion function with convert { |file_contents| ... }."
59
+ end
60
+ end
61
+ end
62
+
63
+ # Define built-in conversions
64
+ Conversion.create :scss => :css do
65
+ require_gem "sass"
66
+ render { |file_contents| Sass::Engine.new(file_contents, :syntax => :scss).render }
67
+ end
68
+
69
+ Conversion.create :sass => :css do
70
+ require_gem "sass"
71
+ render { |file_contents| Sass::Engine.new(file_contents, :syntax => :sass).render }
72
+ end
73
+
74
+ Conversion.create :coffee => :js do
75
+ require_gem "coffee-script"
76
+ render { |file_contents| CoffeeScript.compile(file_contents) }
77
+ end
78
+ end
@@ -0,0 +1,3 @@
1
+ module Pinion
2
+ class Error < RuntimeError; end
3
+ end
@@ -0,0 +1,171 @@
1
+ #require "fssm"
2
+
3
+ require "pinion/error"
4
+ require "pinion/conversion"
5
+ require "time"
6
+ require "set"
7
+
8
+ module Pinion
9
+ class Server
10
+ #CachedFile = Struct.new :from_path, :to_path, :compiled_contents, :mtime
11
+ Asset = Struct.new :from_path, :to_path, :from_type, :to_type, :compiled_contents, :length, :mtime,
12
+ :content_type
13
+ Watch = Struct.new :path, :from_type, :to_type, :conversion
14
+
15
+ def initialize
16
+ @running = false
17
+ @watch_directories = []
18
+ @watches = []
19
+ @cached_assets = {}
20
+ @conversions_used = Set.new
21
+ end
22
+
23
+ def convert(from_and_to, &block)
24
+ unless from_and_to.is_a?(Hash) && from_and_to.size == 1
25
+ raise Error, "Unexpected argument to convert: #{from_and_to.inspect}"
26
+ end
27
+ from, to = from_and_to.to_a[0]
28
+ unless [from, to].all? { |s| s.is_a? Symbol }
29
+ raise Error, "Expecting symbols in this hash #{from_and_to.inspect}"
30
+ end
31
+ if block_given?
32
+ # Save new conversion type (this might overwrite an implicit or previously defined conversion)
33
+ Conversion.create(from_and_to) do
34
+ render { |file_contents| block.call(file_contents) }
35
+ end
36
+ else
37
+ unless Conversion[from_and_to]
38
+ raise Error, "No immplicit conversion for #{from_and_to.inspect}. Must provide a conversion block."
39
+ end
40
+ end
41
+ end
42
+
43
+ def watch(path)
44
+ raise Error, "#{path} is not a directory." unless File.directory? path
45
+ @watch_directories << path
46
+ end
47
+
48
+ # Boilerplate mostly stolen from sprockets
49
+ # https://github.com/sstephenson/sprockets/blob/master/lib/sprockets/server.rb
50
+ def call(env)
51
+ puts "********************** CALLING CALL"
52
+ puts "\033[01;34m>>>> env['PATH_INFO']: #{env["PATH_INFO"].inspect}\e[m"
53
+ puts "************************************"
54
+ start unless @running
55
+
56
+ # Avoid modifying the session state, don't set cookies, etc
57
+ env["rack.session.options"] ||= {}
58
+ env["rack.session.options"].merge! :defer => true, :skip => true
59
+
60
+ path = env["PATH_INFO"].to_s.sub(%r[^/], "")
61
+
62
+ if path.include? ".."
63
+ return with_content_length([403, { "Content-Type" => "text/plain" }, ["Forbidden"]])
64
+ end
65
+
66
+ asset = get_asset(path)
67
+ if asset
68
+ headers = {
69
+ "Content-Type" => asset.content_type,
70
+ "Content-Length" => asset.length.to_s,
71
+ # TODO: set a long cache in prod mode when implemented
72
+ "Cache-Control" => "public, must-revalidate",
73
+ "Last-Modified" => asset.mtime.httpdate,
74
+ }
75
+ return [200, headers, []] if env["REQUEST_METHOD"] == "HEAD"
76
+ [200, headers, asset.compiled_contents]
77
+ else
78
+ with_content_length([404, { "Content-Type" => "text/plain" }, ["Not found"]])
79
+ end
80
+ rescue Exception => e
81
+ # TODO: logging
82
+ STDERR.puts "Error compiling #{path}:"
83
+ STDERR.puts "#{e.class.name}: #{e.message}"
84
+ # TODO: render nice custom errors in the browser
85
+ raise
86
+ end
87
+
88
+ private
89
+
90
+ def get_asset(to_path)
91
+ asset = @cached_assets[to_path]
92
+ if asset
93
+ mtime = asset.mtime
94
+ latest = latest_mtime_of_type(asset.from_type)
95
+ if latest > mtime
96
+ invalidate_all_assets_of_type(asset.from_type)
97
+ return get_asset(to_path)
98
+ end
99
+ puts "\033[01;34m>>>> Using cached asset at to_path: #{to_path.inspect}\e[m"
100
+ else
101
+ begin
102
+ asset = get_uncached_asset(to_path)
103
+ rescue Error
104
+ return nil
105
+ end
106
+ @cached_assets[to_path] = asset
107
+ end
108
+ asset
109
+ end
110
+
111
+ def latest_mtime_of_type(type)
112
+ latest = Time.at(0)
113
+ @watch_directories.each do |directory|
114
+ Dir[File.join(directory, "**/*.#{type}")].each do |file|
115
+ mtime = File.stat(file).mtime
116
+ latest = mtime if mtime > latest
117
+ end
118
+ end
119
+ latest
120
+ end
121
+
122
+ def get_uncached_asset(to_path)
123
+ from_path, conversion = find_source_file_and_conversion(to_path)
124
+ # If we reach this point we've found the asset we're going to compile
125
+ conversion.require_dependency unless @conversions_used.include? conversion
126
+ @conversions_used << conversion
127
+ # TODO: log at info: compiling asset ...
128
+ contents = conversion.convert(File.read(from_path))
129
+ length = File.stat(from_path).size
130
+ mtime = latest_mtime_of_type(conversion.from_type)
131
+ content_type = conversion.content_type
132
+ return Asset.new from_path, to_path, conversion.from_type, conversion.to_type,
133
+ [contents], contents.length, mtime, content_type
134
+ end
135
+
136
+ def find_source_file_and_conversion(to_path)
137
+ path, dot, suffix = to_path.rpartition(".")
138
+ conversions = Conversion.conversions_for(suffix.to_sym)
139
+ raise Error, "No conversion for for #{to_path}" if conversions.empty?
140
+ @watch_directories.each do |directory|
141
+ conversions.each do |conversion|
142
+ Dir[File.join(directory, "#{path}.#{conversion.from_type}")].each do |from_path|
143
+ return [from_path, conversion]
144
+ end
145
+ end
146
+ end
147
+ raise Error, "No source file found for #{to_path}"
148
+ end
149
+
150
+ def invalidate_all_assets_of_type(type)
151
+ @cached_assets.delete_if { |to_path, asset| asset.from_type == type }
152
+ end
153
+
154
+ def with_content_length(response)
155
+ status, headers, body = response
156
+ [status, headers.merge({ "Content-Length" => Rack::Utils.bytesize(body).to_s }), body]
157
+ end
158
+
159
+ def update_asset(asset)
160
+ end
161
+
162
+ def start
163
+ @running = true
164
+ # TODO: mad threadz
165
+ # Start a thread with an FSSM watch on each directory. Upon detecting a change to a compiled file that
166
+ # is a dependency of any asset in @required_assets, call update_asset for each affected asset.
167
+ #
168
+ # There are some tricky threading issues here.
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,3 @@
1
+ module Pinion
2
+ VERSION = "0.1.0"
3
+ end
data/lib/pinion.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "pinion/version"
2
+ require "pinion/server"
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pinion
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Caleb Spare
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-27 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: &22405020 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '1.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *22405020
25
+ description: ! 'Pinion is a Rack application that you can use to compile and serve
26
+ assets (such as Javascript and CSS).
27
+
28
+ '
29
+ email:
30
+ - cespare@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - README.md
36
+ - lib/pinion/conversion.rb
37
+ - lib/pinion/error.rb
38
+ - lib/pinion/server.rb
39
+ - lib/pinion/version.rb
40
+ - lib/pinion.rb
41
+ homepage: https://github.com/ooyala/pinion
42
+ licenses: []
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ none: false
49
+ requirements:
50
+ - - ! '>='
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ none: false
55
+ requirements:
56
+ - - ! '>='
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubyforge_project: pinion
61
+ rubygems_version: 1.8.10
62
+ signing_key:
63
+ specification_version: 3
64
+ summary: Pinion compiles and serves your assets
65
+ test_files: []