syntropy 0.10.1 → 0.12

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/lib/syntropy/app.rb CHANGED
@@ -8,7 +8,9 @@ require 'p2'
8
8
 
9
9
  require 'syntropy/errors'
10
10
  require 'syntropy/file_watch'
11
+
11
12
  require 'syntropy/module'
13
+ require 'syntropy/routing_tree'
12
14
 
13
15
  module Syntropy
14
16
  class App
@@ -19,139 +21,172 @@ module Syntropy
19
21
 
20
22
  private
21
23
 
24
+ # for apps with a _site.rb file
22
25
  def site_file_app(opts)
23
- site_fn = File.join(opts[:location], '_site.rb')
26
+ site_fn = File.join(opts[:root_dir], '_site.rb')
24
27
  return nil if !File.file?(site_fn)
25
28
 
26
- loader = Syntropy::ModuleLoader.new(opts[:location], opts)
29
+ loader = Syntropy::ModuleLoader.new(opts[:root_dir], opts)
27
30
  loader.load('_site')
28
31
  end
29
32
 
33
+ # default app
30
34
  def default_app(opts)
31
- new(opts[:machine], opts[:location], opts[:mount_path] || '/', opts)
35
+ new(**opts)
32
36
  end
33
37
  end
34
38
 
35
- def initialize(machine, location, mount_path, opts = {})
36
- @machine = machine
37
- @location = File.expand_path(location)
39
+ attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :opts
40
+ def initialize(root_dir:, mount_path:, **opts)
41
+ @machine = opts[:machine]
42
+ @root_dir = root_dir
38
43
  @mount_path = mount_path
39
44
  @opts = opts
40
45
 
41
- @module_loader = Syntropy::ModuleLoader.new(@location, @opts)
42
- @router = Syntropy::Router.new(@opts, @module_loader)
43
-
44
- @machine.spin do
45
- # we do startup stuff asynchronously, in order to first let TP2 do its
46
- # setup tasks
47
- @machine.sleep 0.15
48
- @opts[:logger]&.info(
49
- message: "Serving from #{File.expand_path(@location)}"
50
- )
51
- @router.start_file_watcher if opts[:watch_files]
52
- end
46
+ @module_loader = Syntropy::ModuleLoader.new(@root_dir, opts)
47
+ setup_routing_tree
48
+ start_app
53
49
  end
54
50
 
51
+ # Processes an incoming HTTP request. Requests are processed by first
52
+ # looking up the route for the request path, then calling the route proc. If
53
+ # the route proc is not set, it is computed according to the route target,
54
+ # and composed recursively into hooks encountered up the routing tree.
55
+ #
56
+ # Normal exceptions (StandardError and descendants) are trapped and passed
57
+ # to route's error handler. If no such handler is found, the default error
58
+ # handler is used, which simply generates a textual response containing the
59
+ # error message, and with the appropriate HTTP status code, according to the
60
+ # type of error.
61
+ #
62
+ # @param req [Qeweney::Request] HTTP request
63
+ # @return [void]
55
64
  def call(req)
56
- entry = @router[req.path]
57
- render_entry(req, entry)
58
- rescue Syntropy::Error => e
59
- msg = e.message
60
- req.respond(msg.empty? ? nil : msg, ':status' => e.http_status)
65
+ route = @router_proc.(req.path, req.route_params)
66
+ raise Syntropy::Error.not_found('Not found') if !route
67
+
68
+ req.route = route
69
+ proc = route[:proc] ||= compute_route_proc(route)
70
+ proc.(req)
61
71
  rescue StandardError => e
62
- p e
63
- p e.backtrace
64
- req.respond(e.message, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
72
+ error_handler = get_error_handler(route)
73
+ error_handler.(req, e)
65
74
  end
66
75
 
67
76
  private
68
77
 
69
- def render_entry(req, entry)
70
- kind = entry[:kind]
71
- return respond_not_found(req) if kind == :not_found
72
-
73
- entry[:proc] ||= calculate_route_proc(entry)
74
- entry[:proc].(req)
78
+ # Instantiates a routing tree with the app settings, and generates a router
79
+ # proc.
80
+ #
81
+ # @return [void]
82
+ def setup_routing_tree
83
+ @routing_tree = Syntropy::RoutingTree.new(
84
+ root_dir: @root_dir, mount_path: @mount_path, **@opts
85
+ )
86
+ @router_proc = @routing_tree.router_proc
75
87
  end
76
88
 
77
- def calculate_route_proc(entry)
78
- render_proc = route_render_proc(entry)
79
- @router.calc_route_proc_with_hooks(entry, render_proc)
89
+ # Computes the route proc for the given route, wrapping it in hooks found up
90
+ # the routing tree.
91
+ #
92
+ # @param route [Hash] route entry
93
+ # @return [Proc] route proc
94
+ def compute_route_proc(route)
95
+ pure = pure_route_proc(route)
96
+ compose_up_tree_hooks(route, pure)
80
97
  end
81
98
 
82
- def route_render_proc(entry)
83
- case entry[:kind]
99
+ def pure_route_proc(route)
100
+ case (kind = route[:target][:kind])
84
101
  when :static
85
- ->(req) { respond_static(req, entry) }
102
+ static_route_proc(route)
86
103
  when :markdown
87
- ->(req) { respond_markdown(req, entry) }
104
+ markdown_route_proc(route)
88
105
  when :module
89
- load_module(entry)
106
+ module_route_proc(route)
90
107
  else
91
- raise 'Invalid entry kind'
108
+ raise Syntropy::Error, "Invalid route kind: #{kind.inspect}"
92
109
  end
93
110
  end
94
111
 
95
- def respond_not_found(req)
96
- headers = { ':status' => Qeweney::Status::NOT_FOUND }
97
- case req.method
98
- when 'head'
99
- req.respond(nil, headers)
112
+ # Returns a proc rendering the given static route
113
+ def static_route_proc(route)
114
+ fn = route[:target][:fn]
115
+ headers = { 'Content-Type' => Qeweney::MimeTypes[File.extname(fn)] }
116
+
117
+ ->(req) {
118
+ req.respond_by_http_method(
119
+ 'head' => [nil, headers],
120
+ 'get' => -> { [IO.read(fn), headers] }
121
+ )
122
+ }
123
+ end
124
+
125
+ # Returns a proc rendering the given markdown route
126
+ def markdown_route_proc(route)
127
+ headers = { 'Content-Type' => 'text/html' }
128
+
129
+ ->(req) {
130
+ req.respond_by_http_method(
131
+ 'head' => [nil, headers],
132
+ 'get' => -> { [render_markdown(route), headers] }
133
+ )
134
+ }
135
+ end
136
+
137
+ def render_markdown(route)
138
+ atts, md = Syntropy.parse_markdown_file(route[:target][:fn], @opts)
139
+
140
+ if (layout = atts[:layout])
141
+ route[:applied_layouts] ||= {}
142
+ proc = route[:applied_layouts][layout] ||= markdown_layout_proc(layout)
143
+ html = proc.render(md: md, **atts)
100
144
  else
101
- req.respond('Not found', headers)
145
+ html = P2.markdown(md)
102
146
  end
147
+ html
103
148
  end
104
149
 
105
- def respond_static(req, entry)
106
- entry[:mime_type] ||= Qeweney::MimeTypes[File.extname(entry[:fn])]
107
- headers = { 'Content-Type' => entry[:mime_type] }
108
- req.respond_by_http_method(
109
- 'head' => [nil, headers],
110
- 'get' => -> { [IO.read(entry[:fn]), headers] }
111
- )
150
+ # returns a markdown template based on the given layout
151
+ def markdown_layout_proc(layout)
152
+ @layouts ||= {}
153
+ template = @module_loader.load("_layout/#{layout}")
154
+ @layouts[layout] = template.apply { |md:, **| markdown(md) }
112
155
  end
113
156
 
114
- def respond_markdown(req, entry)
115
- entry[:mime_type] ||= Qeweney::MimeTypes[File.extname(entry[:fn])]
116
- headers = { 'Content-Type' => entry[:mime_type] }
117
- req.respond_by_http_method(
118
- 'head' => [nil, headers],
119
- 'get' => -> { [render_markdown(entry[:fn]), headers] }
120
- )
157
+ def module_route_proc(route)
158
+ ref = @routing_tree.fn_to_rel_path(route[:target][:fn])
159
+ # ref = route[:target][:fn].sub(@mount_path, '')
160
+ mod = @module_loader.load(ref)
161
+ compute_module_proc(mod)
121
162
  end
122
163
 
123
- def respond_module(req, entry)
124
- entry[:proc] ||= load_module(entry)
125
- if entry[:proc] == :invalid
126
- req.respond(nil, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
127
- return
164
+ def compute_module_proc(mod)
165
+ case mod
166
+ when P2::Template
167
+ p2_template_proc(mod)
168
+ when Papercraft::Template
169
+ papercraft_template_proc(mod)
170
+ else
171
+ mod
128
172
  end
129
-
130
- entry[:proc].call(req)
131
- rescue Syntropy::Error => e
132
- req.respond(nil, ':status' => e.http_status)
133
- rescue StandardError => e
134
- p e
135
- p e.backtrace
136
- req.respond(nil, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
137
173
  end
138
174
 
139
- def load_module(entry)
140
- ref = entry[:fn].gsub(%r{^#{@location}/}, '').gsub(/\.rb$/, '')
141
- o = @module_loader.load(ref)
142
- o.is_a?(P2::Template) ? wrap_template(o) : o
143
- rescue Exception => e
144
- @opts[:logger]&.error(
145
- message: "Error while loading module #{ref}",
146
- error: e
147
- )
148
- :invalid
175
+ def p2_template_proc(template)
176
+ template = template.proc
177
+ headers = { 'Content-Type' => 'text/html' }
178
+
179
+ ->(req) {
180
+ req.respond_by_http_method(
181
+ 'head' => [nil, headers],
182
+ 'get' => -> { [template.render, headers] }
183
+ )
184
+ }
149
185
  end
150
186
 
151
- def wrap_template(wrapper)
152
- template = wrapper.proc
153
- lambda { |req|
154
- headers = { 'Content-Type' => 'text/html' }
187
+ def papercraft_template_proc(template)
188
+ headers = { 'Content-Type' => template.mime_type }
189
+ ->(req) {
155
190
  req.respond_by_http_method(
156
191
  'head' => [nil, headers],
157
192
  'get' => -> { [template.render, headers] }
@@ -159,16 +194,128 @@ module Syntropy
159
194
  }
160
195
  end
161
196
 
162
- def render_markdown(fn)
163
- atts, md = Syntropy.parse_markdown_file(fn, @opts)
197
+ # Composes the given proc into up tree hooks, recursively. Hooks have the
198
+ # signature `->(req, proc) { ... }` where proc is the pure route proc. Each
199
+ # hook therefore can decide whether to_ respond itself to the request, pass
200
+ # in additional parameters, perform any other kind of modification on the
201
+ # incoming reuqest, or capture the response from the route proc and modify
202
+ # it.
203
+ #
204
+ # Nested hooks will be invoked from the routing tree root down. For example
205
+ # `/site/_hook.rb` will wrap `/site/admin/_hook.rb` which wraps the route at
206
+ # `/site/admin/users.rb`.
207
+ #
208
+ # @param route [Hash] route entry
209
+ # @param proc [Proc] route proc
210
+ def compose_up_tree_hooks(route, proc)
211
+ hook_spec = route[:hook]
212
+ if hook_spec
213
+ orig_proc = proc
214
+ hook_proc = hook_spec[:proc] ||= load_aux_module(hook_spec)
215
+ proc = ->(req) { hook_proc.(req, orig_proc) }
216
+ end
164
217
 
165
- if atts[:layout]
166
- layout = @module_loader.load("_layout/#{atts[:layout]}")
167
- html = layout.apply(**atts) { emit_markdown(md) }.render
168
- else
169
- html = P2.markdown(md)
218
+ (parent = route[:parent]) ? compose_up_tree_hooks(parent, proc) : proc
219
+ end
220
+
221
+ def load_aux_module(hook_spec)
222
+ ref = @routing_tree.fn_to_rel_path(hook_spec[:fn])
223
+ @module_loader.load(ref)
224
+ end
225
+
226
+ DEFAULT_ERROR_HANDLER = ->(req, err) {
227
+ msg = err.message
228
+ msg = nil if msg.empty? || (req.method == 'head')
229
+ req.respond(msg, ':status' => Syntropy::Error.http_status(err))
230
+ }
231
+
232
+ # Returns an error handler for the given route. If route is nil, looks up
233
+ # the error handler for the routing tree root. If no handler is found,
234
+ # returns the default error handler.
235
+ #
236
+ # @param route [Hash] route entry
237
+ # @return [Proc] error handler proc
238
+ def get_error_handler(route)
239
+ route_error_handler(route || @routing_tree.root) || DEFAULT_ERROR_HANDLER
240
+ end
241
+
242
+ # Returns the given route's error handler, caching the result.
243
+ #
244
+ # @param route [Hash] route entry
245
+ # @return [Proc] error handler proc
246
+ def route_error_handler(route)
247
+ route[:error_handler] ||= compute_error_handler(route)
248
+ end
249
+
250
+ # Finds and loads the error handler for the given route.
251
+ #
252
+ # @param route [Hash] route entry
253
+ # @return [Proc, nil] error handler proc or nil
254
+ def compute_error_handler(route)
255
+ error_target = find_error_handler(route)
256
+ return nil if !error_target
257
+
258
+ load_aux_module(error_target)
259
+ end
260
+
261
+ # Finds the closest error handler for the given route. If no error handler
262
+ # is defined for the route, searches for an error handler up the routing
263
+ # tree.
264
+ #
265
+ # @param route [Hash] route entry
266
+ # @return [Hash, nil] error handler target or nil
267
+ def find_error_handler(route)
268
+ return route[:error] if route[:error]
269
+
270
+ route[:parent] && find_error_handler(route[:parent])
271
+ end
272
+
273
+ # Performs app start up, creating a log message and starting the file
274
+ # watcher according to app options.
275
+ #
276
+ # @return [void]
277
+ def start_app
278
+ @machine.spin do
279
+ # we do startup stuff asynchronously, in order to first let TP2 do its
280
+ # setup tasks
281
+ @machine.sleep 0.2
282
+ @opts[:logger]&.info(
283
+ message: "Serving from #{File.expand_path(@location)}"
284
+ )
285
+ file_watcher_loop if opts[:watch_files]
286
+ end
287
+ end
288
+
289
+ # Runs the file watcher loop. When a file change is encountered, invalidates
290
+ # the corresponding module, and triggers recomputation of the routing tree.
291
+ #
292
+ # @return [void]
293
+ def file_watcher_loop
294
+ wf = @opts[:watch_files]
295
+ period = wf.is_a?(Numeric) ? wf : 0.1
296
+ Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
297
+ @module_loader.invalidate(fn)
298
+ debounce_file_change
299
+ end
300
+ rescue Exception => e
301
+ p e
302
+ p e.backtrace
303
+ exit!
304
+ end
305
+
306
+ # Delays responding to a file change, then reloads the routing tree.
307
+ #
308
+ # @return [void]
309
+ def debounce_file_change
310
+ if @routing_tree_reloader
311
+ @machine.schedule(@routing_tree_reloader, UM::Terminate.new)
312
+ end
313
+
314
+ @routing_tree_reloader = @machine.spin do
315
+ @machine.sleep(0.1)
316
+ setup_routing_tree
317
+ @routing_tree_reloader = nil
170
318
  end
171
- html
172
319
  end
173
320
  end
174
321
  end
@@ -3,29 +3,57 @@
3
3
  require 'qeweney'
4
4
 
5
5
  module Syntropy
6
+ # The base Syntropy error class
6
7
  class Error < StandardError
7
8
  Status = Qeweney::Status
8
9
 
10
+ # By default, the HTTP status for errors is 500 Internal Server Error
11
+ DEFAULT_STATUS = Qeweney::Status::INTERNAL_SERVER_ERROR
12
+
13
+ # Returns the HTTP status for the given exception
14
+ #
15
+ # @param err [Exception] exception
16
+ # @return [Integer, String] HTTP status
17
+ def self.http_status(err)
18
+ err.respond_to?(:http_status) ? err.http_status : DEFAULT_STATUS
19
+ end
20
+
21
+ # Creates an error with status 404 Not Found
22
+ #
23
+ # @return [Syntropy::Error]
24
+ def self.not_found(msg = '') = new(Status::NOT_FOUND, msg)
25
+
26
+ # Creates an error with status 405 Method Not Allowed
27
+ #
28
+ # @return [Syntropy::Error]
29
+ def self.method_not_allowed(msg = '') = new(Status::METHOD_NOT_ALLOWED, msg)
30
+
31
+ # Creates an error with status 418 I'm a teapot
32
+ #
33
+ # @return [Syntropy::Error]
34
+ def self.teapot(msg = '') = new(Status::TEAPOT, msg)
35
+
9
36
  attr_reader :http_status
10
37
 
11
- def initialize(status, msg = '')
38
+ # Initializes a Syntropy error with the given HTTP status and message.
39
+ #
40
+ # @param http_status [Integer, String] HTTP status
41
+ # @param msg [String] error message
42
+ # @return [void]
43
+ def initialize(http_status = DEFAULT_STATUS, msg = '')
12
44
  super(msg)
13
- @http_status = status || Qeweney::Status::INTERNAL_SERVER_ERROR
45
+ @http_status = http_status
14
46
  end
15
47
 
16
- class << self
17
- # Create class methods for common errors
18
- {
19
- not_found: Status::NOT_FOUND,
20
- method_not_allowed: Status::METHOD_NOT_ALLOWED,
21
- teapot: Status::TEAPOT
22
- }
23
- .each { |k, v|
24
- define_method(k) { |msg = ''| new(v, msg) }
25
- }
48
+ # Returns the HTTP status for the error.
49
+ #
50
+ # @return [Integer, String] HTTP status
51
+ def http_status
52
+ @http_status || Qeweney::Status::INTERNAL_SERVER_ERROR
26
53
  end
27
54
  end
28
55
 
56
+ # ValidationError is raised when a validation has failed.
29
57
  class ValidationError < Error
30
58
  def initialize(msg)
31
59
  super(Qeweney::Status::BAD_REQUEST, msg)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
4
+
3
5
  module Syntropy
4
6
  DATE_REGEXP = /(\d{4}-\d{2}-\d{2})/
5
7
  FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
@@ -28,9 +30,9 @@ module Syntropy
28
30
  atts = atts.merge(yaml)
29
31
  end
30
32
 
31
- if opts[:location]
33
+ if opts[:root_dir]
32
34
  atts[:url] = path
33
- .gsub(/#{opts[:location]}/, '')
35
+ .gsub(/#{opts[:root_dir]}/, '')
34
36
  .gsub(/\.md$/, '')
35
37
  end
36
38
 
@@ -36,7 +36,6 @@ module Syntropy
36
36
  mod_ctx.module_eval(mod_body, fn, 1)
37
37
 
38
38
  export_value = mod_ctx.__export_value__
39
-
40
39
  wrap_module(mod_ctx, export_value)
41
40
  end
42
41
 
@@ -50,10 +49,10 @@ module Syntropy
50
49
  ->(req) { o.send(export_value, req) }
51
50
  when String
52
51
  ->(req) { req.respond(export_value) }
53
- when Proc, P2::Template
54
- export_value
55
- else
52
+ when Class
56
53
  export_value.new(@env)
54
+ else
55
+ export_value
57
56
  end
58
57
  end
59
58
  end
@@ -85,20 +84,20 @@ module Syntropy
85
84
  def template(proc = nil, &block)
86
85
  proc ||= block
87
86
  raise "No template block/proc given" if !proc
88
-
87
+
89
88
  P2::Template.new(proc)
90
89
  end
91
90
  alias_method :html, :template
92
91
 
93
92
  def route_by_host(map = nil)
94
- root = @env[:location]
93
+ root = @env[:root_dir]
95
94
  sites = Dir[File.join(root, '*')]
96
95
  .reject { File.basename(it) =~ /^_/ }
97
96
  .select { File.directory?(it) }
98
97
  .each_with_object({}) { |fn, h|
99
98
  name = File.basename(fn)
100
- opts = @env.merge(location: fn)
101
- h[name] = Syntropy::App.new(opts[:machine], opts[:location], opts[:mount_path], opts)
99
+ opts = @env.merge(root_dir: fn)
100
+ h[name] = Syntropy::App.new(**opts)
102
101
  }
103
102
 
104
103
  map&.each do |k, v|
@@ -112,7 +111,7 @@ module Syntropy
112
111
  end
113
112
 
114
113
  def page_list(ref)
115
- full_path = File.join(@env[:location], ref)
114
+ full_path = File.join(@env[:root_dir], ref)
116
115
  raise 'Not a directory' if !File.directory?(full_path)
117
116
 
118
117
  Dir[File.join(full_path, '*.md')].sort.map {
@@ -121,11 +120,11 @@ module Syntropy
121
120
  }
122
121
  end
123
122
 
124
- def app(location = nil, mount_path = nil)
125
- location ||= @env[:location]
123
+ def app(root_dir = nil, mount_path = nil)
124
+ root_dir ||= @env[:root_dir]
126
125
  mount_path ||= @env[:mount_path]
127
- opts = @env.merge(location:, mount_path:)
128
- Syntropy::App.new(opts[:machine], opts[:location], opts[:mount_path], opts)
126
+ opts = @env.merge(root_dir:, mount_path:)
127
+ Syntropy::App.new(**opts)
129
128
  end
130
129
  end
131
130
  end