syntropy 0.25 → 0.27

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8e03dfbaf126674e740e0ef3819967a181499962a2940dbcbb075c005d91f87
4
- data.tar.gz: aa26e63ce7f99be78e8a70b879a27ac039674947b833122533afb23a34bbdda2
3
+ metadata.gz: b84251781cfce3688f58c6f25a754b0699d97fe4a7c51f390b63635b45007960
4
+ data.tar.gz: 69e85137dcee6b399d3198303f9de4227b5276521a6c964eca5ac4954a9fb10f
5
5
  SHA512:
6
- metadata.gz: d2eac9d510c3d449fc507a7bcd457ac448d857121915b030730714a6fcaeadecc36f52c4b5b77becc1bbe6760d71ab59bb8d10e3ff0edcece136f55e4fe503f8
7
- data.tar.gz: a05435e23baa3b6d0ba34c7bc24ae0fc6652931e53313c63652ad84be955b1b15467e3d0abb0b4ce4eb152a72b5f20b2664392a7b4ea8bd4c61478f48d7e6903
6
+ metadata.gz: dd871276c35cd2a9c216f37ec1361794e34f489af71580a1da5cebef244830daf0e2bc4e418abe28623607e8da5693ead8074dd1f397ba9ea61afb0e4bcf7d96
7
+ data.tar.gz: 2b3ae1c3c4691d247357a8ec3d9f7e3da778c0fcdd7a94de84b2d83c94ffe75c7beb7ec7883976c2ca09b5c45225391b9322e28a32be9cc3a55acfc6d8c5ac9e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # 0.27 2025-10-21
2
+
3
+ - Use accept header (instead of user-agent) for rendering error page
4
+ - Add `Request#accept?` method
5
+ - Fix import path normalization, module method visibility
6
+ - Fix instantiation of Syntropy::Error
7
+ - Improve default error handler response
8
+ - Fix and enhance `Request#html_response`, `Request#json_response` methods
9
+
10
+ # 0.26 2025-10-21
11
+
12
+ - Add /.syntropy/req route for testing request headers
13
+ - Add default builtin error page / response
14
+ - Add `Request#browser?` method for detecting browser
15
+ - Change behaviour of import to support relative paths
16
+ - Update Papercraft
17
+
1
18
  # 0.25 2025-10-19
2
19
 
3
20
  - Upgrade Papercraft to version 3.0
data/examples/index.md CHANGED
@@ -5,4 +5,4 @@ title: Syntropy Examples
5
5
  # Syntropy Examples
6
6
 
7
7
  - [Template composition](/templates)
8
- - [JSON API](/counter)
8
+ - [JSON API](/counter)
data/lib/syntropy/app.rb CHANGED
@@ -168,7 +168,7 @@ module Syntropy
168
168
  end
169
169
 
170
170
  # Serves a static file from the given target hash with cache validation.
171
- #
171
+ #
172
172
  # @param req [Qeweney::Request] request
173
173
  # @param target [Hash] route target hash
174
174
  # @return [void]
@@ -190,7 +190,7 @@ module Syntropy
190
190
 
191
191
  # Validates and conditionally updates the file information for the given
192
192
  # target.
193
- #
193
+ #
194
194
  # @param target [Hash] route target hash
195
195
  # @return [void]
196
196
  def validate_static_file_info(target)
@@ -203,7 +203,7 @@ module Syntropy
203
203
  STATX_MASK = UM::STATX_MTIME | UM::STATX_SIZE
204
204
 
205
205
  # Updates the static file information for the given target
206
- #
206
+ #
207
207
  # @param target [Hash] route target hash
208
208
  # @param now [Time] current time
209
209
  # @return [void]
@@ -373,12 +373,6 @@ module Syntropy
373
373
  @module_loader.load(ref)
374
374
  end
375
375
 
376
- DEFAULT_ERROR_HANDLER = ->(req, err) {
377
- msg = err.message
378
- msg = nil if msg.empty? || (req.method == 'head')
379
- req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
380
- }
381
-
382
376
  # Returns an error handler for the given route. If route is nil, looks up
383
377
  # the error handler for the routing tree root. If no handler is found,
384
378
  # returns the default error handler.
@@ -386,7 +380,7 @@ module Syntropy
386
380
  # @param route [Hash] route entry
387
381
  # @return [Proc] error handler proc
388
382
  def get_error_handler(route)
389
- route_error_handler(route || @routing_tree.root) || DEFAULT_ERROR_HANDLER
383
+ route_error_handler(route || @routing_tree.root) || default_error_handler
390
384
  end
391
385
 
392
386
  # Returns the given route's error handler, caching the result.
@@ -420,6 +414,23 @@ module Syntropy
420
414
  route[:parent] && find_error_handler(route[:parent])
421
415
  end
422
416
 
417
+ RAW_DEFAULT_ERROR_HANDLER = ->(req, err) {
418
+ msg = err.message
419
+ msg = nil if msg.empty? || (req.method == 'head')
420
+ req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
421
+ }
422
+
423
+ def default_error_handler
424
+
425
+ @default_error_handler ||= begin
426
+ if @builtin_applet
427
+ @builtin_applet.module_loader.load('/default_error_handler')
428
+ else
429
+ RAW_DEFAULT_ERROR_HANDLER
430
+ end
431
+ end
432
+ end
433
+
423
434
  # Performs app start up, creating a log message and starting the file
424
435
  # watcher according to app options.
425
436
  #
@@ -0,0 +1,18 @@
1
+ body {
2
+ max-width: 800px;
3
+ margin: 4em auto;
4
+
5
+ font-family: Seravek, 'Gill Sans Nova', Ubuntu, Calibri, 'DejaVu Sans', source-sans-pro, sans-serif;
6
+
7
+ big {
8
+ display: block;
9
+ font-weight: bold;
10
+ font-size: 10em;
11
+ }
12
+ }
13
+
14
+ @media (max-width: 768px) {
15
+ body {
16
+ margin-inline: 1em;
17
+ }
18
+ }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ ErrorPage = ->(error:, status:, backtrace:) {
4
+ html {
5
+ head {
6
+ title "Syntropy error: #{error.message}"
7
+ meta charset: 'utf-8'
8
+ meta name: 'viewport', content: 'width=device-width, initial-scale=1.0'
9
+ link rel: 'stylesheet', type: 'text/css', href: '/.syntropy/default_error_handler/style.css'
10
+ }
11
+ body {
12
+ div {
13
+ big status
14
+ h2 error.message
15
+ if backtrace
16
+ p "Backtrace:"
17
+ ul {
18
+ backtrace.each {
19
+ li {
20
+ a(it[:entry], href: it[:url])
21
+ }
22
+ }
23
+ }
24
+ end
25
+ }
26
+ auto_refresh_watch!
27
+ }
28
+ }
29
+ }
30
+
31
+ def transform_backtrace(backtrace)
32
+ backtrace.map do
33
+ location = it.match(/^(.+:\d+):/)[1]
34
+ { entry: it, url: "vscode://file/#{location}" }
35
+ end
36
+ end
37
+
38
+ def error_response_html(req, error)
39
+ status = Syntropy::Error.http_status(error)
40
+ backtrace = transform_backtrace(error.backtrace)
41
+ html = Papercraft.html(ErrorPage, error:, status:, backtrace:)
42
+ req.html_response(html, ':status' => status)
43
+ end
44
+
45
+ def error_response_raw(req, error)
46
+ status = Syntropy::Error.http_status(error)
47
+ response = {
48
+ class: error.class.to_s,
49
+ message: error.message,
50
+ backtrace: error.backtrace
51
+ }
52
+ req.json_pretty_response(response, ':status' => status)
53
+ end
54
+
55
+ export ->(req, error) {
56
+ req.accept?('text/html') ?
57
+ error_response_html(req, error) : error_response_raw(req, error)
58
+ }
@@ -25,4 +25,4 @@ class JSONAPI {
25
25
  }
26
26
  }
27
27
 
28
- export default JSONAPI;
28
+ export default JSONAPI;
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ export ->(req) {
4
+ req.json_response({
5
+ headers: req.headers
6
+ })
7
+ }
@@ -21,17 +21,17 @@ module Syntropy
21
21
  # Creates an error with status 404 Not Found
22
22
  #
23
23
  # @return [Syntropy::Error]
24
- def self.not_found(msg = '') = new(Status::NOT_FOUND, msg)
24
+ def self.not_found(msg = 'Not found') = new(msg, Status::NOT_FOUND)
25
25
 
26
26
  # Creates an error with status 405 Method Not Allowed
27
27
  #
28
28
  # @return [Syntropy::Error]
29
- def self.method_not_allowed(msg = '') = new(Status::METHOD_NOT_ALLOWED, msg)
29
+ def self.method_not_allowed(msg = 'Method not allowed') = new(msg, Status::METHOD_NOT_ALLOWED)
30
30
 
31
31
  # Creates an error with status 418 I'm a teapot
32
32
  #
33
33
  # @return [Syntropy::Error]
34
- def self.teapot(msg = '') = new(Status::TEAPOT, msg)
34
+ def self.teapot(msg = 'I\'m a teapot') = new(msg, Status::TEAPOT)
35
35
 
36
36
  attr_reader :http_status
37
37
 
@@ -40,7 +40,7 @@ module Syntropy
40
40
  # @param http_status [Integer, String] HTTP status
41
41
  # @param msg [String] error message
42
42
  # @return [void]
43
- def initialize(http_status = DEFAULT_STATUS, msg = '')
43
+ def initialize(msg = 'Internal server error', http_status = DEFAULT_STATUS)
44
44
  super(msg)
45
45
  @http_status = http_status
46
46
  end
@@ -38,6 +38,8 @@ module Syntropy
38
38
  # @param ref [String] module reference
39
39
  # @return [any] export value
40
40
  def load(ref)
41
+ ref = "/#{ref}" if ref !~ /^\//
42
+
41
43
  entry = (@modules[ref] ||= load_module(ref))
42
44
  entry[:export_value]
43
45
  end
@@ -93,6 +95,7 @@ module Syntropy
93
95
  # @param ref [String] module reference
94
96
  # @return [Hash] module entry
95
97
  def load_module(ref)
98
+ ref = "/#{ref}" if ref !~ /^\//
96
99
  fn = File.expand_path(File.join(@root_dir, "#{ref}.rb"))
97
100
  raise Syntropy::Error, "File not found #{fn}" if !File.file?(fn)
98
101
 
@@ -112,8 +115,9 @@ module Syntropy
112
115
 
113
116
  def clean_ref(ref)
114
117
  return '/' if ref =~ /^index(\+)?$/
115
-
116
- ref.gsub(/\/index(?:\+)?$/, '')
118
+
119
+ clean = ref.gsub(/\/index(?:\+)?$/, '')
120
+ clean == '' ? '/' : clean
117
121
  end
118
122
 
119
123
  # Transforms the given export value. If the value is nil, an exception is
@@ -177,10 +181,21 @@ module Syntropy
177
181
  @app = env[:app]
178
182
  @ref = env[:ref]
179
183
  @logger = env[:logger]
184
+ @__dependencies__ = []
180
185
  singleton_class.const_set(:MODULE, self)
181
186
  end
182
187
 
183
- attr_reader :__export_value__
188
+ attr_reader :__export_value__, :__dependencies__
189
+
190
+ # Returns a list of pages found at the given ref.
191
+ #
192
+ # @param ref [String] directory reference
193
+ # @return [Array] array of pages found in directory
194
+ def page_list(ref)
195
+ Syntropy.page_list(@env, ref)
196
+ end
197
+
198
+ private
184
199
 
185
200
  # Exports the given value. This value will be used as the module's
186
201
  # entrypoint. It can be any Ruby value, but for a route module would
@@ -192,22 +207,23 @@ module Syntropy
192
207
  @__export_value__ = v
193
208
  end
194
209
 
195
- # Returns the list of module references imported by the module.
196
- #
197
- # @return [Array] array of module references
198
- def __dependencies__
199
- @__dependencies__ ||= []
200
- end
201
-
202
210
  # Imports the module corresponding to the given reference. The return value
203
211
  # is the module's export value.
204
212
  #
205
213
  # @param ref [String] module reference
206
214
  # @return [any] loaded dependency's export value
207
215
  def import(ref)
208
- @module_loader.load(ref).tap {
209
- __dependencies__ << ref
210
- }
216
+ ref = normalize_import_ref(ref)
217
+ @module_loader.load(ref).tap { __dependencies__ << ref }
218
+ end
219
+
220
+ def normalize_import_ref(ref)
221
+ base = @ref == '' ? '/' : @ref
222
+ if ref =~ /^\//
223
+ ref
224
+ else
225
+ File.expand_path(File.join(File.dirname(base), ref))
226
+ end
211
227
  end
212
228
 
213
229
  # Creates and returns a Papercraft template created with the given block.
@@ -238,19 +254,13 @@ module Syntropy
238
254
  raise
239
255
  end
240
256
 
241
- # Returns a list of pages found at the given ref.
242
- #
243
- # @param ref [String] directory reference
244
- # @return [Array] array of pages found in directory
245
- def page_list(ref)
246
- Syntropy.page_list(@env, ref)
247
- end
248
-
249
- # Creates and returns a Syntropy app for the given environment.
257
+ # Creates and returns a Syntropy app for the given environment. The app's
258
+ # environment is based on the module's env merged with the given parameters.
250
259
  #
251
260
  # @param env [Hash] environment
252
261
  def app(**env)
253
- Syntropy::App.new(**(@env.merge(env)))
262
+ env = @env.merge(env)
263
+ Syntropy::App.new(**env)
254
264
  end
255
265
  end
256
266
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'qeweney'
4
+ require 'json'
4
5
 
5
6
  module Syntropy
6
7
  # Extensions for the Qeweney::Request class
@@ -144,7 +145,7 @@ module Syntropy
144
145
  # matches the given etag or last_modified values, responds with a 304 Not
145
146
  # Modified status. Otherwise, yields to the given block for a normal
146
147
  # response, and sets cache control headers according to the given arguments.
147
- #
148
+ #
148
149
  # @param cache_control [String] value for Cache-Control header
149
150
  # @param etag [String, nil] Etag header value
150
151
  # @param last_modified [String, nil] Last-Modified header value
@@ -184,8 +185,53 @@ module Syntropy
184
185
  raise Syntropy::Error.new(Qeweney::Status::BAD_REQUEST, 'Invalid form data')
185
186
  end
186
187
 
188
+ def html_response(html, **headers)
189
+ respond(
190
+ html,
191
+ 'Content-Type' => 'text/html; charset=utf-8',
192
+ **headers
193
+ )
194
+ end
195
+
196
+ def json_response(obj, **headers)
197
+ respond(
198
+ JSON.dump(obj),
199
+ 'Content-Type' => 'application/json; charset=utf-8',
200
+ **headers
201
+ )
202
+ end
203
+
204
+ def json_pretty_response(obj, **headers)
205
+ respond(
206
+ JSON.pretty_generate(obj),
207
+ 'Content-Type' => 'application/json; charset=utf-8',
208
+ **headers
209
+ )
210
+ end
211
+
212
+ def browser?
213
+ user_agent = headers['user-agent']
214
+ user_agent && user_agent =~ /^Mozilla\//
215
+ end
216
+
217
+ # Returns true if the accept header includes the given MIME type
218
+ #
219
+ # @param mime_type [String] MIME type
220
+ # @return [bool]
221
+ def accept?(mime_type)
222
+ accept = headers['accept']
223
+ return nil if !accept
224
+
225
+ @accept_parts ||= parse_accept_parts(accept)
226
+ @accept_parts.include?(mime_type)
227
+ end
228
+
187
229
  private
188
230
 
231
+ def parse_accept_parts(accept)
232
+ accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
233
+ end
234
+
189
235
  BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
190
236
  BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
191
237
  INTEGER_REGEXP = /^[+-]?[0-9]+$/
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.25'
4
+ VERSION = '0.27'
5
5
  end
data/syntropy.gemspec CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |s|
23
23
 
24
24
  s.add_dependency 'extralite', '2.13'
25
25
  s.add_dependency 'json', '2.13.2'
26
- s.add_dependency 'papercraft', '3.0'
26
+ s.add_dependency 'papercraft', '3.0.1'
27
27
  s.add_dependency 'qeweney', '0.24'
28
28
  s.add_dependency 'tp2', '0.19'
29
29
  s.add_dependency 'uringmachine', '0.18'
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Klass = import '_lib/klass'
3
+ Klass = import './klass'
4
4
 
5
5
  def call(x)
6
6
  Klass.foo.to_s * x
data/test/app/_lib/dep.rb CHANGED
@@ -1,4 +1,4 @@
1
- Foo = import '_lib/self'
1
+ Foo = import '/_lib/self'
2
2
 
3
3
  def bar
4
4
  Foo.foo
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Klass = import '_lib/klass'
3
+ Klass = import '/_lib/klass'
4
4
 
5
5
  def call
6
6
  Klass.foo
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ export :foo
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ a1 = import './a'
4
+ a2 = import 'a'
5
+ foo = import '../foo/index'
6
+ callable = import '/_lib/callable'
7
+
8
+ export(a1:, a2:, foo:, callable:)
data/test/test_module.rb CHANGED
@@ -29,6 +29,17 @@ class ModuleTest < Minitest::Test
29
29
  assert_equal 43, mod.bar
30
30
  end
31
31
 
32
+ def test_import_paths
33
+ mod = @loader.load('/mod/path/b')
34
+ assert_kind_of Hash, mod
35
+ assert_equal [:a1, :a2, :foo, :callable], mod.keys
36
+
37
+ assert_equal :foo, mod[:a1]
38
+ assert_equal :foo, mod[:a2]
39
+ assert_kind_of Syntropy::Module, mod[:foo]
40
+ assert_equal 'barbarbar', mod[:callable].(3)
41
+ end
42
+
32
43
  def test_export_self
33
44
  mod = @loader.load('_lib/self')
34
45
  assert_kind_of Syntropy::Module, mod
@@ -39,23 +50,23 @@ class ModuleTest < Minitest::Test
39
50
  mod = @loader.load('_lib/env')
40
51
 
41
52
  assert_equal mod, mod.module_const
42
- assert_equal @env.merge(module_loader: @loader, ref: '_lib/env'), mod.env
53
+ assert_equal @env.merge(module_loader: @loader, ref: '/_lib/env'), mod.env
43
54
  assert_equal @machine, mod.machine
44
55
  assert_equal @loader, mod.module_loader
45
56
  assert_equal 42, mod.app
46
57
 
47
58
  assert_equal mod, mod.module_const
48
- assert_equal @env.merge(module_loader: @loader, ref: '_lib/env'), mod.env
59
+ assert_equal @env.merge(module_loader: @loader, ref: '/_lib/env'), mod.env
49
60
  assert_equal @machine, mod.machine
50
61
  assert_equal @loader, mod.module_loader
51
62
  assert_equal 42, mod.app
52
63
  end
53
64
 
54
65
  def test_dependency_invalidation
55
- mod = @loader.load('_lib/dep')
56
- assert_equal ['_lib/self', '_lib/dep'], @loader.modules.keys
66
+ _mod = @loader.load('_lib/dep')
67
+ assert_equal ['/_lib/self', '/_lib/dep'], @loader.modules.keys
57
68
 
58
- self_fn = @loader.modules['_lib/self'][:fn]
69
+ self_fn = @loader.modules['/_lib/self'][:fn]
59
70
  @loader.invalidate_fn(self_fn)
60
71
 
61
72
  assert_equal [], @loader.modules.keys
@@ -63,9 +74,9 @@ class ModuleTest < Minitest::Test
63
74
 
64
75
  def test_index_module_env
65
76
  mod = @loader.load('mod/bar/index+')
66
- assert_equal 'mod/bar', mod.env[:ref]
77
+ assert_equal '/mod/bar', mod.env[:ref]
67
78
 
68
79
  mod = @loader.load('mod/foo/index')
69
- assert_equal 'mod/foo', mod.env[:ref]
80
+ assert_equal '/mod/foo', mod.env[:ref]
70
81
  end
71
82
  end
@@ -570,5 +570,5 @@ class RoutingTreeWildcardIndexTest < Minitest::Test
570
570
  assert_nil route
571
571
  end
572
572
 
573
-
573
+
574
574
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syntropy
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.25'
4
+ version: '0.27'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: '3.0'
46
+ version: 3.0.1
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: '3.0'
53
+ version: 3.0.1
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: qeweney
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -203,8 +203,11 @@ files:
203
203
  - lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb
204
204
  - lib/syntropy/applets/builtin/debug/debug.css
205
205
  - lib/syntropy/applets/builtin/debug/debug.js
206
+ - lib/syntropy/applets/builtin/default_error_handler.rb
207
+ - lib/syntropy/applets/builtin/default_error_handler/style.css
206
208
  - lib/syntropy/applets/builtin/json_api.js
207
209
  - lib/syntropy/applets/builtin/ping.rb
210
+ - lib/syntropy/applets/builtin/req.rb
208
211
  - lib/syntropy/connection_pool.rb
209
212
  - lib/syntropy/dev_mode.rb
210
213
  - lib/syntropy/errors.rb
@@ -238,6 +241,8 @@ files:
238
241
  - test/app/index.html
239
242
  - test/app/mod/bar/index+.rb
240
243
  - test/app/mod/foo/index.rb
244
+ - test/app/mod/path/a.rb
245
+ - test/app/mod/path/b.rb
241
246
  - test/app/params/[foo].rb
242
247
  - test/app/rss.rb
243
248
  - test/app/tmp.rb