diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71fbc63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +test/app/public/ +*.gem +.bundle/ +bin/ +vendor/ +vendor.noindex/ +coverage/ +Gemfile.lock +doc/ +.yardoc/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..16f9cdb --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--format documentation diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..31944b2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: ruby +bundler_args: "--without development" +rvm: + - 2.0.0-p647 + - 2.1.7 + - 2.2.3 +# see note about issue at https://docs.travis-ci.com/user/languages/ruby/#Choosing-Ruby-versions-and-implementations-to-test-against + - rbx-2 +# - jruby-19mode + - jruby-head + - ruby-head +matrix: + allow_failures: + - rvm: rbx-2 +# - jruby-19mode + - rvm: jruby-head + - rvm: ruby-head \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f8f078 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 1.0.1 - 2013-03-17 + +* Add missing rake & rack-test depencendies + +## 1.0.0 - 2013-03-17 + +* You must now register Sinatra::Export extension. +* Sinatra::AdvancedRoutes is now auto loaded. (#5) +* Renamed build! method export!. + +## 0.9.5 + +* Improvement : signed gem. + +## 0.9.4 + +* Now set files mtime according to response Last-Modified header + +## 0.9.3 + +* Removed unnecessary development dependencies. + +## 0.9.2 + +* Bug fix: Correctly support path with file extension (.json, .csv, etc.). Issue #1 + +## 0.9.1 + +* API CHANGE: Now calling .build! without path parameter, using Sinatra application :public_folder setting. +* Feature: Added a rake task sinatra:export \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7cd1c57 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +> If I have seen further, it is by standing on the shoulders of giants. - Isaac Newton + +We love to work with people around the globe and make earth a better place for mankind! + +Please follow theses simples steps to contribute to this project : + +1. Fork the repo. + +2. Run the tests before doing anything. + +3. Add test(s) for your code modification. + +4. Rebase your code onto upstream master (this repository) if not up to date. + +5. Squash or fixup your commits to achieve a clean commit log. + +6. Submit a pull request and ensure tests passes on travis-ci for all supported ruby versions. + +## Running the tests + +As simple as running the `rake` command. + +The default `rake` task is `rake test`. + +## Syntax + +Follow [Ruby Styleguide](https://github.com/styleguide/ruby) by Github and existing code. \ No newline at end of file diff --git a/Gemfile b/Gemfile index eb3befe..8f0b386 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,21 @@ -source :rubygems -gem 'sinatra' -gem 'sinatra-advanced-routes' -gem 'term-ansicolor' -gem 'rack' -gem 'rack-test' +source 'https://rubygems.org' + +group :development do + gem "term-ansicolor" + gem "pry-byebug" + gem "pry-stack_explorer" +end + +group :development, :test do + gem 'yard' +end + +group :test do + gem 'rack-test' + gem 'rake' + gem 'rspec' + gem 'rspec-its' + gem 'simplecov' +end + +gemspec \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 9ae4f16..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,34 +0,0 @@ -GEM - remote: http://rubygems.org/ - specs: - backports (2.3.0) - monkey-lib (0.5.4) - backports - rack (1.3.4) - rack-protection (1.1.4) - rack - rack-test (0.6.1) - rack (>= 1.0) - sinatra (1.3.1) - rack (>= 1.3.4, ~> 1.3) - rack-protection (>= 1.1.2, ~> 1.1) - tilt (>= 1.3.3, ~> 1.3) - sinatra-advanced-routes (0.5.1) - monkey-lib (~> 0.5.0) - sinatra (~> 1.0) - sinatra-sugar (~> 0.5.0) - sinatra-sugar (0.5.1) - monkey-lib (~> 0.5.0) - sinatra (~> 1.0) - term-ansicolor (1.0.7) - tilt (1.3.3) - -PLATFORMS - ruby - -DEPENDENCIES - rack - rack-test - sinatra - sinatra-advanced-routes - term-ansicolor diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9b314ef --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +# LICENSE + +The MIT License + +Copyright (c) 2011-2013 Paul Asmuth, Jean-Philippe Doyle, Hookt Studios inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3c5364 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# sinatra-export + +> Exports all your Sinatra application routes to static files in your public folder. + +[![Build Status](https://travis-ci.org/hooktstudios/sinatra-export.png?branch=master)](https://travis-ci.org/hooktstudios/sinatra-export) +[![Dependency Status](https://gemnasium.com/hooktstudios/sinatra-export.png)](https://gemnasium.com/hooktstudios/sinatra-export) +[![Code Climate](https://codeclimate.com/github/hooktstudios/sinatra-export.png)](https://codeclimate.com/github/hooktstudios/sinatra-export) +[![Gem Version](https://badge.fury.io/rb/sinatra-export.png)](https://rubygems.org/gems/sinatra-export) + +## Installation + +```ruby +# Gemfile +gem 'sinatra-export' +``` + +```ruby +# Rakefile +APP_FILE = 'app.rb' +APP_CLASS = 'Sinatra::Application' + +require 'sinatra/export/rake' +``` + +## Quick Start + +Sample Sinatra application building static pages : + +```ruby +require 'sinatra' +require 'sinatra/export' + +get '/' do + "

My homepage

" +end + +get '/contact' do + "

My contact page

" +end + +get '/data.json' do + "{test: 'ok'}" +end +``` + +Running your app ex. `rake sinatra:export` will automatically generate theses files : + + public/index.html -> "

My homepage

" + public/contact/index.html -> "

My contact page

" + public/data.json -> "{test: 'ok'}" + +## Usage + + $ rake sinatra:export + +Or invoke it manually within ruby code : + +````ruby +Sinatra::Application.export! +``` + +## Advanced usage ## + +### Supplied paths ### + +If you wish to specify specific paths to be visited (only): + +````ruby +Sinatra::Application.export! paths: ["/", "/contact"] +``` + +Only the homepage and the contact page would be visited (these would be visited anyway, but lets start off simple!) If you wanted the paths you specify *and* any paths that Sinatra::AdvancedRoutes can find then you could use: + +````ruby +Sinatra::Application.export! paths: ["/", "/contact"], use_routes: true +``` + +Now all the routes listed above would be found. But what if you have some routes with wildcards or named captures? + +````ruby +get '/articles/:slug' do + # an article is retrieved via params["slug"] + # but we'll stub one in for this example: + markdown("# I have wonderful news! #\n\nYou can use wildcard routes now.\n") +end +``` + +You could access that route as well via: + +````ruby +Sinatra::Application.export! paths: ["/articles/i-have-wonderful-news"], use_routes: true +``` + +### Supplying statuses ### + +Perhaps you would like a static 404 page. + +````ruby +not_found do + halt 404, haml(:not_found) +end +``` + +By default, Sinatra Export will only use routes that return an HTTP status code of 200. If you want non 200 pages then supply the path with the expected status in an array, for example: + +````ruby +Sinatra::Application.export! paths: ["/articles/i-have-wonderful-news",["/404.html",400]], use_routes: true +``` + +Among the static files output you will find 404.html. + +### Skipping pages ### + +If you want to ignore certain pages no matter what, supply them via the `skip` keyword in a list: + +````ruby +Sinatra::Application.export! skips: ["/contact","/data.json"] +``` + +Only the "/" route will be output. This will work with supplied paths or routes found via `use_routes`. + +### Non standard directory for output ### + +By default, Sinatra Export will place the generated static files into the Sinatra app's public folder. If you want to put them somewhere else then you can use the `EXPORT_BUILD_DIR` environment variable. For example: + +````ruby +ENV["EXPORT_BUILD_DIR"] = File.join ENV["HOME"], "projects/static" +Sinatra::Application.export! +``` + +The files would be in "~/projects/static" + +## Super advanced usage ## + +### Error handling ### + +By default, Sinatra Export will skip routes that are non 200 status unless you supply the expected status for a page. When it hits an unexpected status it will output an error in red text to the terminal and continue processing. If you want to change this, you can supply your own error handler. For example, to stop processing when you hit an unexpected status code: + +````ruby +Sinatra::Application.export! paths: ["/this-path-doesnt-exist"], error_handler: ->(desc){ fail "Didn't expect that! #{desc}" } +``` + +All that's needed is something that responds to `call` - so a proc, block or lambda - that takes 1 argument, a description string of the error. + +### Supplying a process block ### + +`export!` can take a block that will be run for every page that is processed. Inside the block, and instance of the `Builder` class (the one that does all the work, see the API docs via `rake yard` for more) will be accessible. For example, let's add a path during the processing: + +````ruby +get '/this-route-has-an-internal-link' do + "Follow this link!" +end +``` + +Now to find that link: + +````ruby +require 'hpricot' # nokogiri is available too +Sinatra::Application.export! do |builder| + doc = Hpricot(builder.last_response.body) + (doc/"a").map{|elem| URI( elem.attributes["href"] ) } + .map(&:path).each do |path| + builder.paths.push path unless builder.paths.include? path + end +end +``` + +You'd probably want to check the links weren't external too. + +**Note!** If you know something about arrays and some of the set like methods available then you'll think that the last block given to each could've been made shorter by using `|=` instead of `push` with `unless`. Be warned that under the hood the Builder is using an Enumerator to check each of the `paths`, and by using `|=` the paths will somehow become disassociated with the enumerator and your work will be in vain! + +There's other stuff you could do in that block, the builder gives you access to `paths`, `skips` (both read/write); `visited` (a list of the paths visited so far), `errored` (a list of the paths that have called the error handler), the `last_path` (which inside the block will be the current path) and the `last_response`, so you can access things like the `last_response.status` and `last_response.body`. + +Another example, filtering while processing: + +````ruby +Sinatra::Application.export! do |builder| + # set it using an array because Rack::Response#body is actually + # an array that is joined to output a string + builder.last_response.body = [builder.last_response.body.upcase!] +end +``` + +Now all the output would be upcased. There is more on filtering below, but as you can see, you can process things on the fly. + +### Filtering ### + +If you want to apply a filter to every path that is written then you can supply those via the `filters` keyword: + +````ruby +require 'hpricot' # nokogiri is available too +Sinatra::Application.export! filters: [->(text){ text.upcase }] +``` + +That would upcase everything. If you wanted you could do things like remove mentions of "localhost" or whatever. + +````ruby +require 'hpricot' # nokogiri is available too +Sinatra::Application.export! filters: [->(text){ text.gsub("localhost", "example.org" }, ->(text){ text.gsub("http://", "https://" }] +``` + +`filter` takes an array, each item should respond to `call` and take 1 argument, the text to be filtered. Each filter will be applied in the order of the array. + +## Other resources + +* [capistrano-s3](http://github.com/hooktstudios/capistrano-s3) : build and deploy a static website to Amazon S3 +* [sinatra-assetpack](https://github.com/rstacruz/sinatra-assetpack) : package your assets transparently in Sinatra +* [sinatra-static-bp](https://github.com/hooktstudios/sinatra-static-bp) : boilerplate to setup complete static website + +## Contributing + +See [CONTRIBUTING.md](https://github.com/hooktstudios/sinatra-export/blob/master/CONTRIBUTING.md) for more details on contributing and running test. + +## Credits + +![hooktstudios](http://hooktstudios.com/logo.png) + +[sinatra-export](https://rubygems.org/gems/sinatra-export) is maintained and funded by [hooktstudios](https://github.com/hooktstudios) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..988d86a --- /dev/null +++ b/Rakefile @@ -0,0 +1,16 @@ +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task :default => :spec +task :test => :spec + +begin + require 'yard' + YARD::Rake::YardocTask.new do |t| + t.files = ['lib/**/*.rb'] # optional + t.options = ['--any', '--extra', '--opts'] # optional + t.stats_options = ['--list-undoc'] # optional + end +rescue LoadError +end \ No newline at end of file diff --git a/UPGRADING b/UPGRADING new file mode 100644 index 0000000..6913362 --- /dev/null +++ b/UPGRADING @@ -0,0 +1,14 @@ +################################################## +# NOTE FOR UPGRADING FROM PRE-1.0 VERSION # +################################################## + +Sinatra::Export is now a Sinatra extension +and advanced routes are auto-loaded. + +To upgrade, replace in your application : + + register Sinatra::AdvancedRoutes + +with : + + register Sinatra::Export \ No newline at end of file diff --git a/certs/j15e.pem b/certs/j15e.pem new file mode 100644 index 0000000..9ac7d0d --- /dev/null +++ b/certs/j15e.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRjCCAi6gAwIBAgIBADANBgkqhkiG9w0BAQUFADBJMRswGQYDVQQDDBJqZWFu +cGhpbGlwcGUuZG95bGUxFTATBgoJkiaJk/IsZAEZFgVnbWFpbDETMBEGCgmSJomT +8ixkARkWA2NvbTAeFw0xMzAzMTEyMjM5MjNaFw0xNDAzMTEyMjM5MjNaMEkxGzAZ +BgNVBAMMEmplYW5waGlsaXBwZS5kb3lsZTEVMBMGCgmSJomT8ixkARkWBWdtYWls +MRMwEQYKCZImiZPyLGQBGRYDY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEApQtzOyMoi48Pc0BRF++BXc2ulECpOypwYqnzIdbE5+iLownixYB3KkKm +VxIlo9pYWIp5JR+Uphv/P9vKJAIjfldtlus6o8xFT0HujXyEMMbFMGHH01PvDYkx +vnMw5O6YVdtbsKvjknB4JOcOmxQAeXBunLBJk0huOLALlDW38UZF3AKNVPpR+Gst +gTZCIAPq22wB9I0HYfyCitjrMVK4jTHgjPjNNQn4BhuhkqEWr5JkVtipGCUFyl45 +2ISskOPckLW/qwCq4vyjVK6JIZb2Ei9DHtip44u7KjFEX5QdvHyu81xatMmtKvYn +WWKZ/2HCdWRRiPSuHmBR/QlMlUN69QIDAQABozkwNzAJBgNVHRMEAjAAMB0GA1Ud +DgQWBBQk1vhL7od7qZLEfBssQNqzht1wwDALBgNVHQ8EBAMCBLAwDQYJKoZIhvcN +AQEFBQADggEBADglXYkTD1Btadr2ehKDOSATfLVeh9ThwZ6PeKP8bPWKOrHPbTN8 +Aandvv6g6samg7p/viRwsE29MTAcfqCdFp8v35ase2PyiXrJ0QJP7r3CBo+nwGVO +hybD84/vTA9CSBaFX2Xtj0dC5o6eW6/vWoVeJUcMNX/O/r8lRWn24WhFkEzW9n1R +2CVeZs78AXWTyU0l0dyswpav0AczXg4UK3Y2M9oYyBmdfem6m614SOrOFBIGjmF4 +kzgF4O2OL+8O23we4E1LvfRn5gV77Dij6s9V4HHzMBuLwnNb8T+6lOnUWbtiIddD +e8c8i7PlrzhVJ/8sXUJsCkyE8d2MyRyjlxM= +-----END CERTIFICATE----- diff --git a/lib/sinatra/export.rb b/lib/sinatra/export.rb new file mode 100644 index 0000000..dd69d47 --- /dev/null +++ b/lib/sinatra/export.rb @@ -0,0 +1,306 @@ +require 'sinatra/base' +require 'sinatra/advanced_routes' +require 'rack/test' +require 'term/ansicolor' +require 'pathname' + +module Sinatra + + # Export a Sinatra app to static files! + module Export + + # required for all Sinatra Extensions, see http://www.sinatrarb.com/extensions.html + def self.registered(app) + if app.extensions.nil? or !app.extensions.include?(Sinatra::AdvancedRoutes) + app.register Sinatra::AdvancedRoutes + end + app.set :export_extensions, %w(css js xml json html csv) + app.extend ClassMethods + app.set :builder, nil + end + + # These will get extended onto the Sinatra app + module ClassMethods + + # The entry method. Run this to export the app to files. + # @example + # # The default: Will use the paths from Sinatra Namespace + # app.export! + # + # # Skip a path (or paths) + # app.export! skips: ["/admin"] + # + # # Only visit the homepage and the site map + # app.export! paths: ["/", "/site-map"] + # + # # Visit the 404 error page by supplying the expected + # # status code (so as not to trigger an error) + # app.export! paths: ["/", ["/404.html",404]] + # + # # Filter out mentions of localhost:4567 + # filter = ->(content){ content.gsub("localhost:4567, "example.org") } + # app.export! filters: [filter] + # + # # Use routes found by Sinatra AdvancedRoutes *and* + # # ones supplied via `paths` + # app.export! paths: ["/crazy/deep/page/path"], use_routes: true + # + # # Supply a path and scan the output for an internal link + # # adding it to the list of paths to be visited + # app.export! paths: "/" do |builder| + # if builder.last_response.body.include? "/echo-1" + # builder.paths << "/echo-1" + # end + # end + # + # @param [Array,Array] paths Paths that will be requested by the exporter. + # @param [Array] skips: Paths that will be ignored by the exporter. + # @param [TrueClass] use_routes Whether to use Sinatra AdvancedRoutes to look for paths to send to the builder. + # @param [Array<#call>] filters Filters will be applied to every file as it is written in the order given. + # @param [#call] error_handler Define your own error handling. Takes one argument, a description of the error. + # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited. + # @note By default the output files with be written to the public folder. Set the EXPORT_BUILD_DIR env var to choose a different location. + def export! paths: nil, skips: [], filters: [], use_routes: nil, error_handler: nil, &block + @builder ||= + if self.builder + self.builder + else + Builder.new(self, paths: paths, skips: skips, filters: filters, use_routes: use_routes, error_handler: error_handler ) + end + @builder.build! &block + end + end + + + # Visits the paths and builds pages from the output + class Builder + include Rack::Test::Methods + + # Default error handler + # @yieldparam [String] desc Description of the error. + DEFAULT_ERROR_HANDLER = ->(desc) { + puts ColorString.new("failed: #{desc}").red; + } + + class ColorString < String + include Term::ANSIColor + end + + + # @param [Sinatra::Base] app The Sinatra app + # @param (see ClassMethods#export!) + # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited. + def initialize(app, paths: nil, skips: nil, use_routes: nil, filters: [], error_handler: nil ) + @app = app + @use_routes = paths.nil? && use_routes.nil? ? true : use_routes + @paths = paths || [] + @skips = skips || [] + @enums = [] + @filters = filters + @visited = [] + @errored = [] + @error_handler = error_handler || DEFAULT_ERROR_HANDLER + end + + # @!attribute [r] last_response + # @return [Rack::MockResponse] The last page requested's response + attr_reader :last_response + + # @!attribute [r] last_path + # @return [String] The last path requested + attr_reader :last_path + + # @!attribute [r] visited + # @return [Array] List of paths visited by the builder + attr_reader :visited + + # @!attribute [r] errored + # @return [Array] List of paths visited by the builder that called the error handler + attr_reader :errored + + # @!attribute [w] error_handler + # Error handler (see ClassMethods#export!) + # @return [nil] + attr_writer :error_handler + + # @!attribute paths + # Paths to visit (see ClassMethods#export!) + # @return [Array] + attr_accessor :paths + + # @!attribute skips + # Paths to be skipped (see ClassMethods#export!) + # @return [Array] + attr_accessor :skips + + + def app + @app + end + + + # Processes the routes and builds the output files. + # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited. + # @return [self] + def build!(&block) + dir = Pathname( ENV["EXPORT_BUILD_DIR"] || app.public_folder ) + handle_error_dir_not_found!(dir) unless dir.exist? && dir.directory? + + if @use_routes + @enums.push self.send(:route_paths).to_enum + end + @enums.push @paths.to_enum + + catch(:no_more_paths) do + enum = @enums.shift + while true + begin + @last_path, status = enum.next + @last_path = @last_path.respond_to?(:path) ? + @last_path.path : + @last_path.to_s + next unless route_path_usable?(@last_path) + next if @skips.include? @last_path + @last_path = @last_path.chop if @last_path.end_with? "?" + desc = catch(:status_error) { + @last_response = get_path(@last_path, status) + block.call self if block + file_path = build_path(path: @last_path, dir: dir, response: last_response) + nil + } + desc ? + @errored |= [@last_path] : + @visited |= [@last_path] + rescue StopIteration + retry if enum = @enums.shift + throw(:no_more_paths) + end + end + end + self + end + + private + + # a convenience wrapper for throwing status errors + def status_error desc + throw :status_error, desc + end + + # A convenience method to keep this logic together + # and reusable + # @param [String,Regexp] path + # @return [TrueClass] Whether the path is a straightforward path (i.e. usable) or it's a regex or path with named captures / wildcards (i.e. unusable). + def route_path_usable? path + res = path.respond_to?( :~ ) || # skip regex + path =~ /(?:\:\w+)|\*/ || # keys and splats + path =~ /[\%\\]/ || # special chars + path[0..-2].include?("?") # an ending ? is acceptable, it'll be chomped + !res + end + + + # Wrapper around Sinatra::AdvancedRoutes#each_route + # to filter what comes through. + # @return [Array] + def route_paths + route_paths = [] + app.each_route do |route| + next if route.verb != 'GET' + next unless route_path_usable?(route.path) + route_paths << route.path + end + route_paths + end + + + # Builds the output dirs and file + # based on the response. + # @param [String] path + # @param [Pathname,String] dir + # @param [Rack::MockResponse] response + # @return [String] file_path + def build_path(path: nil, dir: nil, response: nil) + # These argument checks are for Ruby v2.0 as it + # doesn't support required keyword args. + fail ArgumentError, "'path' is a required argument to build_path" if path.nil? + fail ArgumentError, "'dir' is a required argument to build_path" if dir.nil? + fail ArgumentError, "'response' is a required argument to build_path" if response.nil? + + body = response.body + mtime = response.headers.key?("Last-Modified") ? + Time.httpdate(response.headers["Last-Modified"]) : Time.now + + pattern = %r{ + [^/\.]+ + \. + ( + #{app.settings.export_extensions.join("|")} + ) + $}x + file_path = Pathname( File.join dir, path ) + file_path = file_path.join( 'index.html' ) unless path.match(pattern) + ::FileUtils.mkdir_p( file_path.dirname ) + write_path content: body, path: file_path + ::FileUtils.touch(file_path, :mtime => mtime) + file_path + end + + + # Write the response to file. + # Uses whatever filters were set, on the content. + # @param [String] content + # @param [Pathname,String] path + def write_path( content: nil, path: nil ) + # These argument checks are for Ruby v2.0 as it + # doesn't support required keyword args. + fail ArgumentError, "'content' is a required argument to write_path" if content.nil? + fail ArgumentError, "'path' is a required argument to write_path" if path.nil? + + if @filters && !@filters.empty? + content = @filters.inject(content) do |current_content,filter| + filter.call current_content + end + end + ::File.open(path, 'w+') do |f| + f.write(content) + end + end + + + # Wrapper around Rack::Test's `get` + # @param [String] path + # @param [Integer] status The expected response status code. Anything different and the error handler is called. Defaults to 200. + # @return [Rack::MockResponse] + def get_path(path, status=nil) + status ||= 200 + get(path).tap do |resp| + handle_error_incorrect_status!(path,expected: status, actual: resp.status) unless resp.status == status + end + end + + + def handle_error_dir_not_found!(dir) + @error_handler.call("can't find output directory: #{dir.to_s}") + end + + + # Handles the error caused by a mismatch in status code expectations. + # @param [String] path The route path. + # @param [#to_s] expected The status code that was expected. + # @param [#to_s] actual The actual status code received. + def handle_error_incorrect_status!(path, expected: nil,actual: nil) + # These argument checks are for Ruby v2.0 as it + # doesn't support required keyword args. + fail ArgumentError, "'expected' is a required argument to handle_error_incorrect_status!" if expected.nil? + fail ArgumentError, "'actual' is a required argument to handle_error_incorrect_status!" if actual.nil? + + desc = "GET #{path} returned #{actual} status code instead of #{expected}" + @error_handler.call(desc) + status_error desc + end + end + end + + register Sinatra::Export +end \ No newline at end of file diff --git a/lib/sinatra/export/rake.rb b/lib/sinatra/export/rake.rb new file mode 100644 index 0000000..7ba0d92 --- /dev/null +++ b/lib/sinatra/export/rake.rb @@ -0,0 +1,32 @@ +# Rake task based on sinatra-assetpack test helper +# © 2011, Rico Sta. Cruz. Released under the MIT License +# @link http://www.opensource.org/licenses/mit-license.php +# @link https://github.com/rstacruz/sinatra-assetpack + +unless defined?(APP_FILE) && defined?(APP_CLASS) + $stderr.write "Error: Please set APP_FILE, APP_CLASS before setting up Sinatra::Export rake tasks.\n" + $stderr.write "Example:\n" + $stderr.write " APP_FILE = 'app.rb'\n" + $stderr.write " APP_CLASS = 'App'\n" + $stderr.write " require 'sinatra/export/rake'\n" + $stderr.write "\n" + exit +end + +def class_from_string(str) + str.split('::').inject(Object) do |mod, class_name| + mod.const_get(class_name) + end +end + +def app + require File.expand_path(APP_FILE, Dir.pwd) + class_from_string(APP_CLASS) +end + +namespace :sinatra do + desc "Export static application" + task :export do + app.export! + end +end \ No newline at end of file diff --git a/lib/sinatra_static.rb b/lib/sinatra_static.rb deleted file mode 100644 index b39c214..0000000 --- a/lib/sinatra_static.rb +++ /dev/null @@ -1,86 +0,0 @@ -class SinatraStatic - - @@file_extensions = %w(css js xml json html csv) - - attr_accessor :app - - include Rack::Test::Methods - - require 'term/ansicolor' - class ColorString < String - include Term::ANSIColor - end - - def initialize(app) - @app = app - end - - def build!(dir) - handle_error_no_each_route! unless @app.respond_to?(:each_route) - handle_error_dir_not_found!(dir) unless dir_exists?(dir) - build_routes(dir) - end - -private - - def build_routes(dir) - @app.each_route do |route| - next unless route.verb == 'GET' - build_path(route.path, dir) - end - end - - def build_path(path, dir) - ::FileUtils.mkdir_p(dir_for_path(path, dir)) - ::File.open(file_for_path(path, dir), 'w+') do |f| - f.write(get_path(path).body) - end - end - - def get_path(path) - self.get(path).tap do |resp| - handle_error_non_200!(path) unless resp.status == 200 - end - end - - def file_for_path(path, dir) - if path.match(/[^\/\.]+.(#{file_extensions.join("|")})$/) - ::File.join(dir, path) - else - ::File.join(dir, path, 'index.html') - end - end - - def dir_exists?(dir) - ::File.exists?(dir) && ::File.directory?(dir) - end - - def dir_for_path(path, dir) - file_for_path(path, dir).match(/(.*)\/[^\/]+$/)[1] - end - - def file_extensions - @@file_extensions - end - - def env - ENV['RACK_ENV'] - end - - def handle_error_no_each_route! - handle_error!("can't call app.each_route, did you include sinatra-advanced-routes?") - end - - def handle_error_dir_not_found!(dir) - handle_error!("can't find output directory: #{dir}") - end - - def handle_error_non_200!(path) - handle_error!("GET #{path} returned non-200 status code...") - end - - def handle_error!(desc) - puts ColorString.new("failed: #{desc}").red; exit! - end - -end diff --git a/readme.rdoc b/readme.rdoc deleted file mode 100644 index dd5da1d..0000000 --- a/readme.rdoc +++ /dev/null @@ -1,74 +0,0 @@ -= sinatra-static - -export your sinatra app to a directory of static files. requires "sinatra-advanced-routes". get requests and response-status 200 only (no redirects). you also have to copy the public-dir yourself (if you're using it). - - -== usage - - require 'sinatra_static' - - builder = SinatraStatic.new(MySinatraApp) - builder.build!('/Users/paul/my_static_site') - - -= example - -this simple app: - - require 'sinatra' - require "sinatra/advanced_routes" - require 'sinatra_static' - - class MyApp::App < Sinatra::Base - - get '/' do - "my homepage" - end - - get '/blog' do - "blog index" - end - - get '/blog/ajax.html' do - "blog index w/o layout" - end - - %w(page1 page2 page3).each do |page| - get "/blog/#{page}" do - "blog page: #{page}" - end - end - - get '/dynamic.js' do - "my generated javascript" - end - - get '/data.json' do - "my generated json" - end - - end - - builder = SinatraStatic.new(MyApp:App) - builder.build!('/Users/paul/my_static_site') - - -will generate this output: - - ~/my_static_site/index.html -> "my homepage" - ~/my_static_site/blog/index.html -> "blog index" - ~/my_static_site/blog/ajax.html -> "blog index w/o layout" - ~/my_static_site/blog/page1/index.html -> "blog page: page1" - ~/my_static_site/blog/page2/index.html -> "blog page: page2" - ~/my_static_site/blog/page3/index.html -> "blog page: page3" - ~/my_static_site/dynamic.js -> "my generated javascript" - ~/my_static_site/data.json -> "my generated json" - - -= installation - - gem install sinatra-static - -or in your Gemfile - - gem 'sinatra-static', '>= 0.1.1' diff --git a/sinatra-export.gemspec b/sinatra-export.gemspec new file mode 100644 index 0000000..dbcb036 --- /dev/null +++ b/sinatra-export.gemspec @@ -0,0 +1,37 @@ +Gem::Specification.new do |s| + s.name = 'sinatra-export' + s.version = '1.0.1' + + s.authors = ['Jean-Philippe Doyle', 'Paul Asmuth'] + s.description = 'Exports all your Sinatra application routes to static files in your public folder' + s.summary = 'Sinatra static export.' + s.email = 'jeanphilippe.doyle@hooktstudios.com' + s.cert_chain = ['certs/j15e.pem'] + s.signing_key = File.expand_path('~/.gem/private_key.pem') if $0 =~ /gem\z/ + s.files = [ + 'Gemfile', + 'Gemfile.lock', + 'sinatra-export.gemspec', + 'lib/sinatra/export.rb', + 'lib/sinatra/export/rake.rb', + 'README.md', + 'LICENSE', + 'UPGRADING' + ] + s.homepage = 'http://github.com/hooktstudios/sinatra-export' + s.license = 'MIT' + s.required_ruby_version = '>= 1.8.7' + + if File.exists?('UPGRADING') + s.post_install_message = File.read("UPGRADING") + end + + s.add_runtime_dependency 'term-ansicolor' + s.add_runtime_dependency 'sinatra' + s.add_runtime_dependency 'sinatra-advanced-routes' + s.add_runtime_dependency 'rack' + s.add_runtime_dependency 'rake' + s.add_runtime_dependency 'rack-test' + s.add_development_dependency 'rack-test' +end + diff --git a/sinatra-static.gemspec b/sinatra-static.gemspec deleted file mode 100644 index 83c7675..0000000 --- a/sinatra-static.gemspec +++ /dev/null @@ -1,46 +0,0 @@ -Gem::Specification.new do |s| - s.name = "sinatra-static" - s.version = "0.1.1" - - s.authors = ["Paul Asmuth"] - s.date = "2011-10-16" - s.description = "export your sinatra app to a directory of static files" - s.email = "paul@paulasmuth.com" - s.files = [ - "Gemfile", - "Gemfile.lock", - "sinatra-static.gemspec", - "lib/sinatra_static.rb", - "readme.rdoc" - ] - s.homepage = "http://github.com/paulasmuth/sinatra-static" - s.licenses = ["MIT"] - s.require_paths = ["lib"] - s.rubygems_version = "1.8.10" - s.summary = "export your sinatra app to a directory of static files" - - if s.respond_to? :specification_version then - s.specification_version = 3 - - if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then - s.add_runtime_dependency(%q, [">= 0"]) - s.add_runtime_dependency(%q, [">= 0"]) - s.add_runtime_dependency(%q, [">= 0"]) - s.add_runtime_dependency(%q, [">= 0"]) - s.add_runtime_dependency(%q, [">= 0"]) - else - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - end - else - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - end -end - diff --git a/spec/export_spec.rb b/spec/export_spec.rb new file mode 100644 index 0000000..0c7184a --- /dev/null +++ b/spec/export_spec.rb @@ -0,0 +1,420 @@ +require 'spec_helper' +require 'sinatra' +require_relative "../lib/sinatra/export.rb" + +describe "Sinatra Export" do + + shared_context "app" do + def app + Sinatra.new do + register Sinatra::Export + + configure do + set :root, File.join(__dir__, "support/fixtures", "app") + enable :raise_errors + disable :show_exceptions + end + + get '/' do + "

homepage

echo-1

" + end + + get '/contact/?' do + "contact" + end + + get '/data.json' do + "{test: 'ok'}" + end + + get '/yesterday' do + last_modified Time.local(2002, 10, 31) + "old content" + end + + get "/echo-:this" do |this| + this.to_s + end + + not_found do + 'This is nowhere to be found.' + end + + get "/this-will-send-non-200/*" do + halt 401, "No thanks!" + end + end + end + end + + shared_examples "Server is up" do + before { get "/" } + subject { last_response } + it { should be_ok } + end + + + context "Using the default settings" do + include_context "app" + include_examples "Server is up" + + describe "Straightfoward exporting" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + @builder = app.export! + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'homepage' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'contact' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{test: 'ok'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'old content' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + describe "visited" do + subject { @builder.visited } + it { should =~ ["/", "/contact/", "/data.json", "/yesterday"] } + end + + describe "Raising errors" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + end + + context "this-will-send-non-200/for-sure" do + context "Using the default error handler" do + subject { @builder = app.export! paths: ["/this-will-send-non-200/for-sure"] } + its(:errored) { should =~ ["/this-will-send-non-200/for-sure"] } + end + context "Supplying an error handler" do + it "should raise error" do + expect { + @builder = app.export! paths: ["/this-will-send-non-200/for-sure"], error_handler: ->(desc){ fail "Please stop" } + }.to raise_error + end + end + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + end + end + + describe "Given paths" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.export! paths: ["/", "/contact", ["/404.html", 404]] + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'homepage' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'contact' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.exist?(subject).should be_falsy } + end + context "yesterday" do + subject { + File.join(app.public_folder, 'yesterday/index.html') + } + it { File.exist?(subject).should be_falsy } + end + + context "404" do + subject { + File.join(app.public_folder, '404.html') + } + it { File.read(subject).should include 'This is nowhere to be found.' } + end + end + + context "Given skips" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.export! skips: ["/", "/contact/?"] + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.exist?(subject).should be_falsy } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.exist?(subject).should be_falsy } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{test: 'ok'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'old content' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + end + + context "Using a block" do + context "To add a path" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.export! do |builder| + if builder.last_response.body.include? "/echo-1" + builder.paths << "/echo-1" + end + end + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'homepage' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'contact' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{test: 'ok'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'old content' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + + context "named parameters" do + subject { + File.join(app.public_folder, 'echo-1/index.html') + } + it { File.read(subject).should include '1' } + end + end + + context "To filter output" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.export! do |builder| + builder.last_response.body = [builder.last_response.body.upcase!] + end + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'HOMEPAGE' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'CONTACT' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{TEST: 'OK'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'OLD CONTENT' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + end + end + + context "Given a builder" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.builder = Sinatra::Export::Builder.new(app,paths: ["/", "/contact"]) + app.export! + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'homepage' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'contact' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{test: 'ok'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'old content' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + end + + context "Given filters" do + before :all do + FileUtils.mkdir_p File.join(__dir__, "support/fixtures", "app/public") + app.export! filters: [->(text){ text.upcase }] + end + + after :all do + FileUtils.rm_rf File.join(__dir__, "support/fixtures", "app" ) + end + + context "index" do + subject { + File.join(app.public_folder, 'index.html') + } + it { File.read(subject).should include 'HOMEPAGE' } + end + context "contact" do + subject { + File.join(app.public_folder, 'contact/index.html') + } + it { File.read(subject).should include 'CONTACT' } + end + context "data.json" do + subject { + File.join(app.public_folder, 'data.json') + } + it { File.read(subject).should include "{TEST: 'OK'}" } + end + context "yesterday" do + subject { + File.new File.join(app.public_folder, 'yesterday/index.html') + } + it { subject.read.should include 'OLD CONTENT' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + end + end + + context "Using an env var" do + before :all do + ENV["EXPORT_BUILD_DIR"] = File.expand_path("support/fixtures/static/001", __dir__ ) + end + + include_context "app" + include_examples "Server is up" + + describe "Exporting" do + before :all do + FileUtils.mkdir_p ENV["EXPORT_BUILD_DIR"] + app.export! + end + + context "index" do + subject { + File.join(ENV["EXPORT_BUILD_DIR"], 'index.html') + } + it { File.read(subject).should include 'homepage' } + end + context "contact" do + subject { + File.join(ENV["EXPORT_BUILD_DIR"], 'contact/index.html') + } + it { File.read(subject).should include 'contact' } + end + context "data.json" do + subject { + File.join(ENV["EXPORT_BUILD_DIR"], 'data.json') + } + it { File.read(subject).should include "{test: 'ok'}" } + end + context "yesterday" do + subject { + File.new File.join(ENV["EXPORT_BUILD_DIR"], 'yesterday/index.html') + } + it { subject.read.should include 'old content' } + its(:mtime) { should == Time.local(2002, 10, 31) } + end + + after :all do + FileUtils.rm_rf ENV["EXPORT_BUILD_DIR"] + ENV["EXPORT_BUILD_DIR"] = nil + end + end + + + end + +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..664b528 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,39 @@ +# encoding: UTF-8 + +require 'rspec' +require 'rspec/its' +require 'pry-byebug' if ENV["WITH_PRY"] +Spec_dir = File.expand_path( File.dirname __FILE__ ) + + +# code coverage +require 'simplecov' +SimpleCov.start do + add_filter "/vendor.noindex/" + add_filter "/bin/" + add_filter "/spec/" +end + +require "rack/test" +ENV['RACK_ENV'] ||= 'test' +ENV["EXPECT_WITH"] ||= "racktest" + + +require "logger" +logger = Logger.new STDOUT +logger.level = Logger::DEBUG +logger.datetime_format = '%a %d-%m-%Y %H%M ' +LOgger = logger + + +Dir[ File.join( Spec_dir, "/support/**/*.rb")].each do |f| + logger.info "requiring #{f}" + require f +end + + +RSpec.configure do |config| + config.include Rack::Test::Methods + config.expect_with(:rspec) { |c| c.syntax = [ :should , :expect ] } +end +