utopia 1.4.0 → 1.5.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +5 -1
  4. data/Gemfile +3 -0
  5. data/README.md +11 -0
  6. data/benchmarks/hash_vs_openstruct.rb +52 -0
  7. data/benchmarks/struct_vs_class.rb +89 -0
  8. data/bin/utopia +4 -5
  9. data/lib/utopia.rb +1 -0
  10. data/lib/utopia/content.rb +24 -15
  11. data/lib/utopia/content/node.rb +69 -3
  12. data/lib/utopia/content/processor.rb +32 -5
  13. data/lib/utopia/content/transaction.rb +138 -147
  14. data/lib/utopia/content_length.rb +50 -0
  15. data/lib/utopia/controller.rb +4 -0
  16. data/lib/utopia/controller/variables.rb +1 -1
  17. data/lib/utopia/http.rb +2 -0
  18. data/lib/utopia/localization.rb +4 -8
  19. data/lib/utopia/path.rb +13 -13
  20. data/lib/utopia/static.rb +25 -14
  21. data/lib/utopia/tags/environment.rb +1 -1
  22. data/lib/utopia/tags/override.rb +1 -1
  23. data/lib/utopia/version.rb +1 -1
  24. data/setup/server/git/hooks/post-receive +32 -24
  25. data/setup/site/Gemfile +8 -0
  26. data/setup/site/Rakefile +29 -6
  27. data/setup/site/config.ru +8 -8
  28. data/setup/site/pages/_heading.xnode +1 -1
  29. data/setup/site/pages/_page.xnode +2 -2
  30. data/spec/utopia/content_spec.rb +3 -3
  31. data/spec/utopia/controller/sequence_spec.rb +1 -1
  32. data/spec/utopia/controller/variables_spec.rb +1 -1
  33. data/spec/utopia/pages/node/index.xnode +1 -1
  34. data/spec/utopia/performance_spec.rb +90 -0
  35. data/spec/utopia/performance_spec/cache/head/readme.txt +1 -0
  36. data/spec/utopia/performance_spec/cache/meta/readme.txt +1 -0
  37. data/spec/utopia/performance_spec/config.ru +39 -0
  38. data/spec/utopia/performance_spec/lib/readme.txt +1 -0
  39. data/spec/utopia/performance_spec/pages/_heading.xnode +2 -0
  40. data/spec/utopia/performance_spec/pages/_page.xnode +26 -0
  41. data/spec/utopia/performance_spec/pages/api/controller.rb +7 -0
  42. data/spec/utopia/performance_spec/pages/errors/exception.xnode +5 -0
  43. data/spec/utopia/performance_spec/pages/errors/file-not-found.xnode +5 -0
  44. data/spec/utopia/performance_spec/pages/links.yaml +2 -0
  45. data/spec/utopia/performance_spec/pages/welcome/index.xnode +17 -0
  46. data/spec/utopia/rack_helper.rb +5 -2
  47. data/spec/utopia/setup_spec.rb +93 -0
  48. data/utopia.gemspec +1 -1
  49. metadata +34 -5
  50. data/lib/utopia/mail_exceptions.rb +0 -136
@@ -24,13 +24,9 @@ module Utopia
24
24
  # If you request a URL which has localized content, a localized redirect would be returned based on the content requested.
25
25
  class Localization
26
26
  # A wrapper to provide easy access to locale related data in the request.
27
- class RequestWrapper
28
- def initialize(request)
29
- if request.is_a? Rack::Request
30
- @env = request.env
31
- else
32
- @env = request
33
- end
27
+ class Wrapper
28
+ def initialize(env)
29
+ @env = env
34
30
  end
35
31
 
36
32
  def localization
@@ -54,7 +50,7 @@ module Utopia
54
50
  end
55
51
 
56
52
  def self.[] request
57
- RequestWrapper.new(request)
53
+ Wrapper.new(request.env)
58
54
  end
59
55
 
60
56
  RESOURCE_NOT_FOUND = [400, {}, []].freeze
@@ -203,14 +203,16 @@ module Utopia
203
203
  end
204
204
 
205
205
  def +(other)
206
- if other.kind_of? Array
207
- return join(other)
208
- elsif other.kind_of? Path
206
+ if other.kind_of? Path
209
207
  if other.absolute?
210
208
  return other
211
209
  else
212
210
  return join(other.components)
213
211
  end
212
+ elsif other.kind_of? Array
213
+ return join(other)
214
+ elsif other.kind_of? String
215
+ return join(other.split(SEPARATOR, -1))
214
216
  else
215
217
  return join([other.to_s])
216
218
  end
@@ -268,27 +270,25 @@ module Utopia
268
270
  def descend(&block)
269
271
  return to_enum(:descend) unless block_given?
270
272
 
271
- parent_path = []
273
+ components = []
272
274
 
273
275
  @components.each do |component|
274
- parent_path << component
276
+ components << component
275
277
 
276
- yield self.class.new(parent_path.dup)
278
+ yield self.class.new(components.dup)
277
279
  end
278
280
  end
279
281
 
280
282
  def ascend(&block)
281
283
  return to_enum(:ascend) unless block_given?
282
284
 
283
- next_parent = self
285
+ components = self.components.dup
284
286
 
285
- begin
286
- parent = next_parent
287
+ while components.any?
288
+ yield self.class.new(components.dup)
287
289
 
288
- yield parent
289
-
290
- next_parent = parent.dirname
291
- end until next_parent.eql?(parent)
290
+ components.pop
291
+ end
292
292
  end
293
293
 
294
294
  def split(at)
@@ -144,10 +144,12 @@ module Utopia
144
144
  File.mtime(full_path).httpdate
145
145
  end
146
146
 
147
- def size
147
+ def bytesize
148
148
  File.size(full_path)
149
149
  end
150
150
 
151
+ alias size bytesize
152
+
151
153
  def each
152
154
  File.open(full_path, "rb") do |file|
153
155
  file.seek(@range.begin)
@@ -176,6 +178,9 @@ module Utopia
176
178
  return true
177
179
  end
178
180
 
181
+ CONTENT_LENGTH = Rack::CONTENT_LENGTH
182
+ CONTENT_RANGE = 'Content-Range'.freeze
183
+
179
184
  def serve(env, response_headers)
180
185
  ranges = Rack::Utils.byte_ranges(env, size)
181
186
  response = [200, response_headers, self]
@@ -186,7 +191,7 @@ module Utopia
186
191
  # No ranges, or multiple ranges (which we don't support).
187
192
  # TODO: Support multiple byte-ranges, for now just send entire file:
188
193
  response[0] = 200
189
- response[1]["Content-Length"] = size.to_s
194
+ response[1][CONTENT_LENGTH] = size.to_s
190
195
  @range = 0...size
191
196
  else
192
197
  # Partial content:
@@ -194,25 +199,25 @@ module Utopia
194
199
  partial_size = @range.count
195
200
 
196
201
  response[0] = 206
197
- response[1]["Content-Length"] = partial_size.to_s
198
- response[1]["Content-Range"] = "bytes #{@range.min}-#{@range.max}/#{size}"
202
+ response[1][CONTENT_LENGTH] = partial_size.to_s
203
+ response[1][CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}"
199
204
  end
200
-
201
- # puts "Serving file #{full_path.inspect}, range #{@range.inspect}"
202
-
205
+
203
206
  return response
204
207
  end
205
208
  end
206
209
 
207
210
  public
208
211
 
212
+ DEFAULT_CACHE_CONTROL = 'public, max-age=3600'.freeze
213
+
209
214
  def initialize(app, **options)
210
215
  @app = app
211
216
  @root = (options[:root] || Utopia::default_root).freeze
212
217
 
213
218
  @extensions = MimeTypeLoader.extensions_for(options[:types] || MIME_TYPES[:default])
214
219
 
215
- @cache_control = (options[:cache_control] || "public, max-age=3600")
220
+ @cache_control = (options[:cache_control] || DEFAULT_CACHE_CONTROL)
216
221
 
217
222
  self.freeze
218
223
  end
@@ -237,7 +242,13 @@ module Utopia
237
242
  end
238
243
 
239
244
  attr :extensions
240
-
245
+
246
+ LAST_MODIFIED = 'Last-Modified'.freeze
247
+ CONTENT_TYPE = HTTP::CONTENT_TYPE
248
+ CACHE_CONTROL = HTTP::CACHE_CONTROL
249
+ ETAG = 'ETag'.freeze
250
+ ACCEPT_RANGES = 'Accept-Ranges'.freeze
251
+
241
252
  def call(env)
242
253
  path_info = env[Rack::PATH_INFO]
243
254
  extension = File.extname(path_info)
@@ -251,11 +262,11 @@ module Utopia
251
262
 
252
263
  if file = fetch_file(path)
253
264
  response_headers = {
254
- "Last-Modified" => file.mtime_date,
255
- "Content-Type" => @extensions[extension],
256
- "Cache-Control" => @cache_control,
257
- "ETag" => file.etag,
258
- "Accept-Ranges" => "bytes"
265
+ LAST_MODIFIED => file.mtime_date,
266
+ CONTENT_TYPE => @extensions[extension],
267
+ CACHE_CONTROL => @cache_control,
268
+ ETAG => file.etag,
269
+ ACCEPT_RANGES => "bytes"
259
270
  }
260
271
 
261
272
  if file.modified?(env)
@@ -33,7 +33,7 @@ module Utopia
33
33
  only = state[:only].split(",").collect(&:to_sym) rescue []
34
34
 
35
35
  if defined?(@environment) and only.include?(@environment)
36
- transaction.parse_xml(state.content)
36
+ transaction.parse_markup(state.content)
37
37
  end
38
38
  end
39
39
  end
@@ -26,7 +26,7 @@ module Utopia
26
26
  end
27
27
 
28
28
  def self.call(transaction, state)
29
- transaction.parse_xml(state.content)
29
+ transaction.parse_markup(state.content)
30
30
  end
31
31
  end
32
32
  end
@@ -19,5 +19,5 @@
19
19
  # THE SOFTWARE.
20
20
 
21
21
  module Utopia
22
- VERSION = "1.4.0"
22
+ VERSION = "1.5.0"
23
23
  end
@@ -26,43 +26,51 @@ require 'etc'
26
26
  # Users in group wheel can execute all commands as user http with no password.
27
27
  # %wheel ALL=(http) NOPASSWD: ALL
28
28
 
29
- def sh(*args)
30
- puts args.join(' ')
31
- system(*args)
32
- end
29
+ GIT_WORK_TREE = `git config core.worktree`.chomp
33
30
 
34
- working_tree = `git config core.worktree`.chomp
31
+ # We convert GIT_DIR to an absolute path:
32
+ ENV['GIT_DIR'] = File.join(Dir.pwd, ENV['GIT_DIR'])
35
33
 
36
- puts "Updating Site #{working_tree}..."
34
+ # We deploy the site as the user and group of the directory for the working tree:
35
+ File.stat(GIT_WORK_TREE).tap do |stat|
36
+ ENV['DEPLOY_USER'] = DEPLOY_USER = Etc.getpwuid(stat.uid).name
37
+ ENV['DEPLOY_GROUP'] = DEPLOY_GROUP = Etc.getgrgid(stat.gid).name
38
+ end
37
39
 
38
- # Figure out the user and group of the working tree:
39
- working_tree_stat = File.stat(working_tree)
40
- deploy_user = Etc.getpwuid(working_tree_stat.uid).name
41
- deploy_group = Etc.getgrgid(working_tree_stat.gid).name
40
+ WHOAMI = `whoami`.chomp!
42
41
 
43
- puts "Updating permissions..."
42
+ # We should find out if we need to use sudo or not:
43
+ SUDO = if WHOAMI != DEPLOY_USER
44
+ ["sudo", "-u", "DEPLOY_USER"]
45
+ end
44
46
 
45
- Dir.chdir(working_tree) do
46
- sh("chmod -Rf ug+rwX .")
47
- sh("chown -Rf #{deploy_user}:#{deploy_group} .")
47
+ def sh(command)
48
+ puts command.join(' ')
49
+ system(*command) or abort("Deployment failed!")
50
+ end
48
51
 
49
- puts "Updating site #{Dir.pwd} as #{deploy_user}:#{deploy_group}..."
52
+ def sudo(command)
53
+ sh([*SUDO, *command])
54
+ end
55
+
56
+ puts "Deploying to #{GIT_WORK_TREE} as #{DEPLOY_USER}:#{DEPLOY_GROUP}..."
57
+ Dir.chdir(GIT_WORK_TREE) do
58
+ # Pass on our rights to the files we just uploaded to the deployment user/group:
59
+ sh %W{chmod -Rf ug+rwX .}
60
+ sh %W{chown -Rf #{DEPLOY_USER}:#{DEPLOY_GROUP} .}
50
61
 
51
- sh("sudo -u #{deploy_user} git checkout -f")
52
- sh("sudo -u #{deploy_user} git submodule update -i")
62
+ sudo %W{git checkout -f}
63
+ sudo %W{git submodule update -i}
53
64
 
54
65
  if File.exist? 'Gemfile'
55
- sh("sudo -u #{deploy_user} bundle install --deployment") or abort("Could not setup bundle!")
66
+ sudo %W{bundle install --deployment --clean --jobs=4 --retry=2 --quiet}
56
67
  end
57
68
 
58
- ENV['DEPLOY_USER'] = deploy_user
59
- ENV['DEPLOY_GROUP'] = deploy_group
60
-
61
69
  if File.exist? 'Rakefile'
62
- sh("sudo -u #{deploy_user} bundle exec rake deploy") or abort("Deploy task failed!")
70
+ sudo %W{bundle exec rake deploy}
63
71
  end
64
72
 
65
73
  puts "Restarting server..."
66
- sh("sudo -u #{deploy_user} mkdir -p tmp") unless File.exist?('tmp')
67
- sh("sudo -u #{deploy_user} touch tmp/restart.txt")
74
+ sudo %W{mkdir -p tmp} unless File.exist?('tmp')
75
+ sudo %W{touch tmp/restart.txt}
68
76
  end
@@ -5,6 +5,14 @@ gem "utopia", "~> $UTOPIA_VERSION"
5
5
  # gem "utopia-tags-gallery"
6
6
  # gem "utopia-tags-google-analytics"
7
7
 
8
+ gem "rake"
9
+ gem "bundler"
10
+
8
11
  group :development do
12
+ # For `rake server`:
9
13
  gem "puma"
14
+
15
+ # For `rake console`:
16
+ gem "pry"
17
+ gem "rack-test"
10
18
  end
@@ -1,9 +1,32 @@
1
1
 
2
- task :server do
3
- system('puma')
4
- end
5
-
2
+ desc 'Run by git post-update hook when deployed to a web server'
6
3
  task :deploy do
7
4
  # This task is typiclly run after the site is updated but before the server is restarted.
8
- puts "Preparing to deploy site in #{Dir.pwd.inspect}..."
9
- end
5
+ end
6
+
7
+ desc 'Set up the environment for running your web application'
8
+ task :environment do
9
+ RACK_ENV = (ENV['RACK_ENV'] ||= 'development').to_sym
10
+ end
11
+
12
+ desc 'Run a server for testing your web application'
13
+ task :server => :environment do
14
+ port = ENV.fetch('SERVER_PORT', 9292)
15
+ system('puma', '-p', port)
16
+ end
17
+
18
+ desc 'Start an interactive console for your web application'
19
+ task :console => :environment do
20
+ require 'pry'
21
+ require 'rack/test'
22
+
23
+ include Rack::Test::Methods
24
+
25
+ def app
26
+ @app ||= Rack::Builder.parse_file(File.expand_path("config.ru", __dir__)).first
27
+ end
28
+
29
+ Pry.start
30
+ end
31
+
32
+ task :default => :server
@@ -14,23 +14,28 @@ require 'utopia'
14
14
  require 'rack/cache'
15
15
 
16
16
  if RACK_ENV == :production
17
+ # Handle exceptions in production with a error page and send an email notification:
17
18
  use Utopia::Exceptions::Handler
18
19
  use Utopia::Exceptions::Mailer
19
- elsif RACK_ENV == :development
20
- use Rack::ShowExceptions
20
+ else
21
+ # We want to propate exceptions up when running tests:
22
+ use Rack::ShowExceptions unless RACK_ENV == :test
23
+
24
+ # Serve the public directory in a similar way to the web server:
21
25
  use Utopia::Static, root: 'public'
22
26
  end
23
27
 
24
28
  use Rack::Sendfile
25
29
 
26
30
  if RACK_ENV == :production
31
+ # Cache dynamically generated content where possible:
27
32
  use Rack::Cache,
28
33
  metastore: "file:#{Utopia::default_root("cache/meta")}",
29
34
  entitystore: "file:#{Utopia::default_root("cache/body")}",
30
35
  verbose: RACK_ENV == :development
31
36
  end
32
37
 
33
- use Rack::ContentLength
38
+ use Utopia::ContentLength
34
39
 
35
40
  use Utopia::Redirection::Rewrite,
36
41
  '/' => '/welcome/index'
@@ -50,11 +55,6 @@ use Utopia::Controller,
50
55
 
51
56
  use Utopia::Static
52
57
 
53
- if RACK_ENV != :production
54
- # Serve static files from public/ when not running in a production environment:
55
- use Utopia::Static, root: 'public'
56
- end
57
-
58
58
  # Serve dynamic content
59
59
  use Utopia::Content,
60
60
  cache_templates: (RACK_ENV == :production),
@@ -1,2 +1,2 @@
1
- <?r @title ||= content ?>
1
+ <?r transaction.attributes[:title] ||= content ?>
2
2
  <h1><content/></h1>
@@ -4,7 +4,7 @@
4
4
  <?r response.content_type = "text/html; charset=utf-8" ?>
5
5
  <?r response.cache! ?>
6
6
 
7
- <?r if title = (attributes["title"] || @title) ?>
7
+ <?r if title = self[:title] ?>
8
8
  <title>#{title.gsub(/<.*?>/, "")} - Utopia</title>
9
9
  <?r else ?>
10
10
  <title>Utopia</title>
@@ -14,7 +14,7 @@
14
14
  <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
15
15
  </head>
16
16
 
17
- <body class="#{attributes['class']}">
17
+ <body class="#{attributes[:class]}">
18
18
  <div id="header">
19
19
  <img src="/_static/utopia.svg" />
20
20
  </div>
@@ -79,15 +79,15 @@ module Utopia::ContentSpec
79
79
  expect(output.string).to be == '<h1>Hello World</h1>'
80
80
  end
81
81
 
82
- it "should fetch xml and use cache" do
82
+ it "should fetch template and use cache" do
83
83
  node_path = File.expand_path('../pages/index.xnode', __FILE__)
84
84
 
85
- template = content.fetch_xml(node_path)
85
+ template = content.fetch_template(node_path)
86
86
 
87
87
  expect(template).to be_kind_of Trenni::Template
88
88
 
89
89
  # Check that the same object is returned:
90
- expect(template).to be content.fetch_xml(node_path)
90
+ expect(template).to be content.fetch_template(node_path)
91
91
  end
92
92
  end
93
93
  end
@@ -93,7 +93,7 @@ module Utopia::Controller::SequenceSpec
93
93
 
94
94
  result = controller.process!(request, Utopia::Path["/variable"])
95
95
  expect(result).to be == nil
96
- expect(variables.to_hash).to be == {"variable" => :value}
96
+ expect(variables.to_hash).to be == {:variable => :value}
97
97
  end
98
98
 
99
99
  it "should call direct controller methods" do
@@ -53,6 +53,6 @@ RSpec.describe Utopia::Controller::Variables do
53
53
  it "should convert to hash" do
54
54
  subject << a << b
55
55
 
56
- expect(subject.to_hash).to be == {'x' => 10, 'y' => 20}
56
+ expect(subject.to_hash).to be == {x: 10, y: 20}
57
57
  end
58
58
  end
@@ -1 +1 @@
1
- #{local_path}
1
+ #{current.node.local_path}
@@ -0,0 +1,90 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative 'rack_helper'
22
+
23
+ require 'benchmark/ips' if ENV['BENCHMARK']
24
+ require 'ruby-prof' if ENV['PROFILE']
25
+ require 'flamegraph' if ENV['FLAMEGRAPH']
26
+
27
+ RSpec.describe "Utopia Performance" do
28
+ include_context "rack app", "performance_spec/config.ru"
29
+
30
+ if defined? Benchmark
31
+ def benchmark(name = nil)
32
+ Benchmark.ips do |benchmark|
33
+ benchmark.report(name) do |i|
34
+ yield i
35
+ end
36
+
37
+ benchmark.compare!
38
+ end
39
+ end
40
+ elsif defined? RubyProf
41
+ def benchmark(name)
42
+ result = RubyProf.profile do
43
+ yield 1000
44
+ end
45
+
46
+ result.eliminate_methods!([/^((?!Utopia).)*$/])
47
+ printer = RubyProf::FlatPrinter.new(result)
48
+ printer.print($stderr, min_percent: 1.0)
49
+
50
+ printer = RubyProf::GraphHtmlPrinter.new(result)
51
+ filename = name.gsub('/', '_') + '.html'
52
+ File.open(filename, "w") do |file|
53
+ printer.print(file)
54
+ end
55
+ end
56
+ elsif defined? Flamegraph
57
+ def benchmark(name)
58
+ filename = name.gsub('/', '_') + '.html'
59
+ Flamegraph.generate(filename) do
60
+ yield 1
61
+ end
62
+ end
63
+ else
64
+ def benchmark(name)
65
+ yield 1
66
+ end
67
+ end
68
+
69
+ it "should be fast to access basic page" do
70
+ env = Rack::MockRequest.env_for("/welcome/index")
71
+ status, headers, response = app.call(env)
72
+
73
+ expect(status).to be == 200
74
+
75
+ benchmark("/welcome/index") do |i|
76
+ i.times { app.call(env) }
77
+ end
78
+ end
79
+
80
+ it "should be fast to invoke a controller" do
81
+ env = Rack::MockRequest.env_for("/api/fetch")
82
+ status, headers, response = app.call(env)
83
+
84
+ expect(status).to be == 200
85
+
86
+ benchmark("/api/fetch") do |i|
87
+ i.times { app.call(env) }
88
+ end
89
+ end
90
+ end