syntropy 0.11 → 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,150 +21,162 @@ 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)
100
- else
101
- req.respond('Not found', headers)
102
- end
103
- end
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)] }
104
116
 
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
- )
117
+ ->(req) {
118
+ req.respond_by_http_method(
119
+ 'head' => [nil, headers],
120
+ 'get' => -> { [IO.read(fn), headers] }
121
+ )
122
+ }
112
123
  end
113
124
 
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), headers] }
120
- )
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
+ }
121
135
  end
122
136
 
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
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)
144
+ else
145
+ html = P2.markdown(md)
128
146
  end
147
+ html
148
+ end
129
149
 
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)
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) }
137
155
  end
138
156
 
139
- def load_module(entry)
140
- ref = entry[:fn].gsub(%r{^#{@location}/}, '').gsub(/\.rb$/, '')
141
- o = @module_loader.load(ref)
142
- wrap_module(o)
143
- rescue Exception => e
144
- @opts[:logger]&.error(
145
- message: "Error while loading module #{ref}",
146
- error: e
147
- )
148
- :invalid
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)
149
162
  end
150
163
 
151
- def wrap_module(mod)
164
+ def compute_module_proc(mod)
152
165
  case mod
153
166
  when P2::Template
154
- wrap_p2_template(mod)
167
+ p2_template_proc(mod)
155
168
  when Papercraft::Template
156
- wrap_papercraft_template(mod)
169
+ papercraft_template_proc(mod)
157
170
  else
158
171
  mod
159
172
  end
160
173
  end
161
174
 
162
- def wrap_p2_template(wrapper)
163
- template = wrapper.proc
164
- lambda { |req|
165
- headers = { 'Content-Type' => 'text/html' }
175
+ def p2_template_proc(template)
176
+ template = template.proc
177
+ headers = { 'Content-Type' => 'text/html' }
178
+
179
+ ->(req) {
166
180
  req.respond_by_http_method(
167
181
  'head' => [nil, headers],
168
182
  'get' => -> { [template.render, headers] }
@@ -170,36 +184,138 @@ module Syntropy
170
184
  }
171
185
  end
172
186
 
173
- def wrap_papercraft_template(template)
174
- lambda { |req|
187
+ def papercraft_template_proc(template)
175
188
  headers = { 'Content-Type' => template.mime_type }
189
+ ->(req) {
176
190
  req.respond_by_http_method(
177
191
  'head' => [nil, headers],
178
192
  'get' => -> { [template.render, headers] }
179
193
  )
180
194
  }
181
-
182
195
  end
183
196
 
184
- def render_markdown(entry)
185
- atts, md = Syntropy.parse_markdown_file(entry[: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
186
217
 
187
- if (layout = atts[:layout])
188
- entry[:applied_layouts] ||= {}
189
- proc = entry[:applied_layouts][layout] ||= markdown_layout_proc(layout)
190
- html = proc.render(md: md, **atts)
191
- else
192
- 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]
193
286
  end
194
- html
195
287
  end
196
288
 
197
- def markdown_layout_proc(layout)
198
- layout = @module_loader.load("_layout/#{layout}")
199
- puts '*' * 40
200
- puts layout.proc.compiled_code
201
- puts
202
- layout.apply { |md:, **atts| markdown(md) }
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
318
+ end
203
319
  end
204
320
  end
205
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
 
@@ -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