syntropy 0.11 → 0.13

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