syntropy 0.33.0 → 0.34.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/cmd/console.rb +18 -7
  4. data/cmd/serve.rb +26 -18
  5. data/cmd/test.rb +37 -24
  6. data/examples/blog/.gitignore +1 -0
  7. data/examples/blog/app/_lib/database.rb +13 -0
  8. data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
  9. data/examples/blog/app/posts/[id]/edit.rb +2 -2
  10. data/examples/blog/app/posts/[id]/index.rb +4 -4
  11. data/examples/blog/app/posts/index.rb +4 -4
  12. data/examples/blog/app/posts/new.rb +1 -1
  13. data/examples/blog/config/development.rb +5 -0
  14. data/examples/blog/config/production.rb +4 -0
  15. data/examples/blog/config/test.rb +5 -0
  16. data/examples/blog/test/test_posts.rb +65 -0
  17. data/examples/mcp-oauth/app/oauth/token.rb +1 -1
  18. data/lib/syntropy/app.rb +48 -40
  19. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
  20. data/lib/syntropy/db/schema.rb +1 -1
  21. data/lib/syntropy/db/store.rb +2 -0
  22. data/lib/syntropy/errors.rb +6 -2
  23. data/lib/syntropy/http/client.rb +1 -0
  24. data/lib/syntropy/http/server_connection.rb +0 -4
  25. data/lib/syntropy/json_api.rb +27 -1
  26. data/lib/syntropy/logger.rb +81 -27
  27. data/lib/syntropy/markdown.rb +61 -32
  28. data/lib/syntropy/mime_types.rb +9 -5
  29. data/lib/syntropy/module_loader.rb +20 -9
  30. data/lib/syntropy/papercraft_extensions.rb +2 -2
  31. data/lib/syntropy/request/mock_adapter.rb +10 -8
  32. data/lib/syntropy/request/request_info.rb +91 -0
  33. data/lib/syntropy/request/response.rb +1 -12
  34. data/lib/syntropy/request/validation.rb +1 -0
  35. data/lib/syntropy/request.rb +51 -19
  36. data/lib/syntropy/routing_tree.rb +27 -28
  37. data/lib/syntropy/session.rb +198 -0
  38. data/lib/syntropy/side_run.rb +25 -2
  39. data/lib/syntropy/test.rb +105 -10
  40. data/lib/syntropy/utils.rb +53 -18
  41. data/lib/syntropy/version.rb +1 -1
  42. data/lib/syntropy.rb +44 -10
  43. data/test/bm_router_proc.rb +4 -4
  44. data/test/fixtures/app/class_instance.rb +5 -0
  45. data/test/fixtures/app/http.rb +5 -0
  46. data/test/fixtures/app/post_ct.rb +5 -0
  47. data/test/fixtures/app/singleton.rb +3 -0
  48. data/test/test_app.rb +13 -52
  49. data/test/test_caching.rb +2 -2
  50. data/test/test_db_schema.rb +1 -1
  51. data/test/test_http_server_connection.rb +3 -3
  52. data/test/test_module_loader.rb +5 -2
  53. data/test/test_response.rb +0 -19
  54. data/test/test_routing_tree.rb +69 -69
  55. data/test/test_server.rb +5 -9
  56. data/test/test_test.rb +70 -0
  57. metadata +52 -42
  58. data/examples/blog/app/_setup.rb +0 -4
  59. data/lib/syntropy/request/session.rb +0 -113
  60. /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
  61. /data/test/{app → fixtures/app}/_hook.rb +0 -0
  62. /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
  63. /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
  64. /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
  65. /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
  66. /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
  67. /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
  68. /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
  69. /data/test/{app → fixtures/app}/about/_error.rb +0 -0
  70. /data/test/{app → fixtures/app}/about/foo.md +0 -0
  71. /data/test/{app → fixtures/app}/about/index.rb +0 -0
  72. /data/test/{app → fixtures/app}/about/raise.rb +0 -0
  73. /data/test/{app → fixtures/app}/api+.rb +0 -0
  74. /data/test/{app → fixtures/app}/assets/style.css +0 -0
  75. /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
  76. /data/test/{app → fixtures/app}/bar.rb +0 -0
  77. /data/test/{app → fixtures/app}/baz.rb +0 -0
  78. /data/test/{app → fixtures/app}/by_method.rb +0 -0
  79. /data/test/{app → fixtures/app}/deps.rb +0 -0
  80. /data/test/{app → fixtures/app}/index.html +0 -0
  81. /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
  82. /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
  83. /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
  84. /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
  85. /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
  86. /data/test/{app → fixtures/app}/rss.rb +0 -0
  87. /data/test/{app → fixtures/app}/tmp.rb +0 -0
  88. /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
  89. /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
  90. /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
  91. /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
  92. /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
  93. /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
  94. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
  95. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
  96. /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
  97. /data/test/{schema → fixtures/schema}/2026-05-30-bar.rb +0 -0
data/lib/syntropy/app.rb CHANGED
@@ -11,35 +11,52 @@ require 'syntropy/routing_tree'
11
11
  require 'syntropy/mime_types'
12
12
 
13
13
  module Syntropy
14
+ # The App implements a Syntropy application. It is responsible for handling
15
+ # incoming HTTP requests, routing them to the correct handler, and maintaining
16
+ # application state.
14
17
  class App
15
18
  class << self
19
+ # Creates an app instance based on the given environment hash.
20
+ #
21
+ # @param env [Hash] environment hash
22
+ # @return [Syntropy::App]
16
23
  def load(env)
17
24
  site_file_app(env) || default_app(env)
18
25
  end
19
26
 
20
27
  private
21
28
 
22
- # for apps with a _site.rb file
29
+ # Creates a multi-hostname app if a _site.rb file is detected.
30
+ #
31
+ # @param env [Hash] environment hash
32
+ # @return [Syntropy::App]
23
33
  def site_file_app(env)
24
- fn = File.join(env[:root_dir], '_site.rb')
34
+ fn = File.join(env[:app_root], '_site.rb')
25
35
  return nil if !File.file?(fn)
26
36
 
27
37
  loader = Syntropy::ModuleLoader.new(env)
28
38
  loader.load('_site')
29
39
  end
30
40
 
31
- # default app
41
+ # Creates a normal Syntropy app.
42
+ #
43
+ # @param env [Hash] environment hash
44
+ # @return [Syntropy::App]
32
45
  def default_app(env)
33
46
  new(**env)
34
47
  end
35
48
  end
36
49
 
37
- attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :env
50
+ attr_reader :module_loader, :routing_tree, :app_root, :mount_path, :env
38
51
  attr_accessor :raise_on_internal_server_error
39
52
 
53
+ # Initializes the app instance.
54
+ #
55
+ # @param env [Hash] environment hash
56
+ # @return [void]
40
57
  def initialize(**env)
41
58
  @machine = env[:machine]
42
- @root_dir = File.expand_path(env[:root_dir])
59
+ @app_root = File.expand_path(env[:app_root])
43
60
  @mount_path = env[:mount_path]
44
61
  @env = env
45
62
  @logger = env[:logger]
@@ -77,12 +94,14 @@ module Syntropy
77
94
  proc = route[:proc] ||= compute_route_proc(route)
78
95
  proc.(req)
79
96
  rescue ScriptError, StandardError => e
80
- @logger&.error(
81
- message: "Error while serving request: #{e.message}",
82
- method: req.method,
83
- path: path,
84
- error: e
85
- ) if Error.log_error?(e)
97
+ if Error.log_error?(e)
98
+ @logger&.error(
99
+ message: "Error while serving request: #{e.message}",
100
+ method: req.method,
101
+ path: path,
102
+ error: e
103
+ )
104
+ end
86
105
  error_handler = get_error_handler(route)
87
106
  error_handler.(req, e)
88
107
  end
@@ -102,21 +121,6 @@ module Syntropy
102
121
  route
103
122
  end
104
123
 
105
- def setup_db(db_path:, schema_root: '_schema')
106
- @env[:db_path] = db_path
107
- @env[:schema_root] = schema_root
108
-
109
- class << self
110
- def connection_pool
111
- @connection_pool ||= DB::ConnectionPool.new(@machine, @env[:db_path], 4)
112
- end
113
-
114
- def schema
115
- @schema ||= DB::Schema.new(module_loader: @module_loader, schema_root: @env[:schema_root])
116
- end
117
- end
118
- end
119
-
120
124
  private
121
125
 
122
126
  # Handles a not found error, taking into account hooks up the tree from the
@@ -134,7 +138,10 @@ module Syntropy
134
138
  end
135
139
  end
136
140
 
137
- # Returns the find
141
+ # Finds the first up-tree route entry for the given path.
142
+ #
143
+ # @param path [String] path
144
+ # @return [Hash] route entry
138
145
  def find_first_uptree_route(path)
139
146
  route = @router_proc.(path, {})
140
147
  if !route && path != '/'
@@ -149,7 +156,7 @@ module Syntropy
149
156
  # @return [void]
150
157
  def setup_routing_tree
151
158
  @routing_tree = Syntropy::RoutingTree.new(
152
- root_dir: @root_dir, mount_path: @mount_path, **@env
159
+ app_root: @app_root, mount_path: @mount_path, **@env
153
160
  )
154
161
  mount_builtin_applet if @env[:builtin_applet_path]
155
162
  @router_proc = @routing_tree.router_proc
@@ -164,6 +171,10 @@ module Syntropy
164
171
  @routing_tree.mount_applet(path, @builtin_applet)
165
172
  end
166
173
 
174
+ # Sets and returns the not found proc for the given route entry.
175
+ #
176
+ # @param route [Hash] route entry
177
+ # @return [Proc] not found proc
167
178
  def route_not_found_proc(route)
168
179
  route[:not_found_proc] ||= compose_up_tree_hooks(route, ->(req) {
169
180
  raise Syntropy::Error.not_found('Not found')
@@ -233,7 +244,7 @@ module Syntropy
233
244
  req.validate_cache(**cache_opts) {
234
245
  req.respond(target[:content], 'Content-Type' => target[:mime_type])
235
246
  }
236
- rescue => e
247
+ rescue StandardError => e
237
248
  p e
238
249
  p e.backtrace
239
250
  exit!
@@ -299,7 +310,7 @@ module Syntropy
299
310
  # @param route [Hash] route entry
300
311
  # @return [String] rendered HTML
301
312
  def render_markdown(route)
302
- atts, md = Syntropy.parse_markdown_file(route[:target][:fn], @env)
313
+ atts, md = Syntropy::Markdown.parse(route[:target][:fn], @env)
303
314
 
304
315
  layout = compute_markdown_layout(route, atts)
305
316
  Papercraft.html(layout, md:, **atts)
@@ -337,7 +348,7 @@ module Syntropy
337
348
  }
338
349
  body {
339
350
  markdown md
340
- auto_refresh! if @env[:dev_mode]
351
+ auto_refresh! if Syntropy.dev_mode
341
352
  }
342
353
  }
343
354
  }
@@ -482,6 +493,9 @@ module Syntropy
482
493
  req.respond(msg, ':status' => status) rescue nil
483
494
  }
484
495
 
496
+ # Returns the default error handler for the app.
497
+ #
498
+ # @return [Proc] error handler
485
499
  def default_error_handler
486
500
  @default_error_handler ||= begin
487
501
  if @builtin_applet
@@ -507,7 +521,7 @@ module Syntropy
507
521
  @machine.sleep 0.1
508
522
  route_count = @routing_tree.static_map.size + @routing_tree.dynamic_map.size
509
523
  @logger&.info(
510
- message: "Serving from #{@root_dir} (#{route_count} routes found)"
524
+ message: "Serving from #{@app_root} (#{route_count} routes found)"
511
525
  )
512
526
 
513
527
  file_watcher_loop if @env[:watch_files]
@@ -519,18 +533,12 @@ module Syntropy
519
533
  #
520
534
  # @return [void]
521
535
  def file_watcher_loop
522
- @machine.file_watch(@root_dir, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) { |e|
536
+ @machine.file_watch(@app_root, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) { |e|
523
537
  fn = e[:fn]
524
538
  @logger&.info(message: 'File change detected', fn: fn)
525
539
  @module_loader.invalidate_fn(fn)
526
540
  debounce_file_change
527
541
  }
528
-
529
- # Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
530
- # @logger&.info(message: 'File change detected', fn: fn)
531
- # @module_loader.invalidate_fn(fn)
532
- # debounce_file_change
533
- # end
534
542
  rescue Exception => e
535
543
  p e
536
544
  p e.backtrace
@@ -564,7 +572,7 @@ module Syntropy
564
572
 
565
573
  watcher_mod = watcher_route[:proc]
566
574
  watcher_mod.signal!
567
- rescue => e
575
+ rescue StandardError => e
568
576
  @logger&.error(
569
577
  message: 'Unexpected error while signalling auto refresh watcher',
570
578
  error: e
@@ -36,7 +36,7 @@ rescue Timeout::Error
36
36
  req.send_chunk("retry: 0\n\n", done: true) rescue nil
37
37
  rescue SystemCallError
38
38
  # ignore
39
- rescue => e
39
+ rescue StandardError => e
40
40
  @logger&.error(
41
41
  message: 'Unexpected error encountered while serving auto refresh watcher',
42
42
  error: e
@@ -53,7 +53,7 @@ module Syntropy
53
53
  connection_pool.with_db do |db|
54
54
  current_version = get_schema_version(db)
55
55
  migrations_keys = @migrations.keys.sort
56
- migrations_keys.select { it > current_version } if current_version
56
+ migrations_keys.select! { it > current_version } if current_version
57
57
 
58
58
  migrations_keys.each do |key|
59
59
  db.transaction do
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Syntropy
4
4
  module DB
5
+ # The Store class represents a data store based on one or more tables, and
6
+ # connected to a database connection pool.
5
7
  class Store
6
8
  def initialize(connection_pool)
7
9
  @connection_pool = connection_pool
@@ -43,8 +43,6 @@ module Syntropy
43
43
  # @return [Syntropy::Error]
44
44
  def self.teapot(msg = 'I\'m a teapot') = new(msg, HTTP::TEAPOT)
45
45
 
46
- attr_reader :http_status
47
-
48
46
  # Initializes a Syntropy error with the given HTTP status and message.
49
47
  #
50
48
  # @param http_status [Integer, String] HTTP status
@@ -70,21 +68,27 @@ module Syntropy
70
68
  end
71
69
  end
72
70
 
71
+ # ProtocolError is raised when an HTTP protocol error is encountered.
73
72
  class ProtocolError < Error
74
73
  def http_status
75
74
  HTTP::BAD_REQUEST
76
75
  end
77
76
  end
78
77
 
78
+ # UnsupportedHTTPVersionError is raised when an invalid protocol specified in
79
+ # a request's request line.
79
80
  class UnsupportedHTTPVersionError < ProtocolError
80
81
  def http_status
81
82
  HTTP::HTTP_VERSION_NOT_SUPPORTED
82
83
  end
83
84
  end
84
85
 
86
+ # BadRequestError is raised when a request contains invalid information.
85
87
  class BadRequestError < Error
86
88
  end
87
89
 
90
+ # InvalidRequestContentTypeError is raised when a request has an invalid
91
+ # content type.
88
92
  class InvalidRequestContentTypeError < Error
89
93
  def http_status
90
94
  HTTP::UNSUPPORTED_MEDIA_TYPE
@@ -5,6 +5,7 @@ require 'uri'
5
5
 
6
6
  module Syntropy
7
7
  module HTTP
8
+ # HTTP Client class.
8
9
  class Client
9
10
  def initialize(machine)
10
11
  @machine = machine
@@ -156,7 +156,6 @@ module Syntropy
156
156
 
157
157
  formatted_headers = format_headers(headers, body)
158
158
  @response_headers = headers
159
- request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
160
159
  if body
161
160
  chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
162
161
  @machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
@@ -175,7 +174,6 @@ module Syntropy
175
174
  # @return [void]
176
175
  def send_headers(request, headers, empty_response: false)
177
176
  formatted_headers = format_headers(headers, !empty_response)
178
- request.tx_incr(formatted_headers.bytesize)
179
177
  @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
180
178
  @response_headers = headers
181
179
  end
@@ -193,7 +191,6 @@ module Syntropy
193
191
  data << EMPTY_CHUNK if done
194
192
  return if data.empty?
195
193
 
196
- request.tx_incr(data.bytesize)
197
194
  @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
198
195
  return if @done || !done
199
196
 
@@ -205,7 +202,6 @@ module Syntropy
205
202
  # default headers are sent using #send_headers.
206
203
  # @return [void]
207
204
  def finish(request)
208
- request.tx_incr(EMPTY_CHUNK_LEN)
209
205
  @machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
210
206
  return if @done
211
207
 
@@ -4,11 +4,20 @@ require 'syntropy/errors'
4
4
  require 'json'
5
5
 
6
6
  module Syntropy
7
+ # JSONAPI is a controller that implements a JSON API.
7
8
  class JSONAPI
9
+ # Initializes the controller.
10
+ #
11
+ # @param env [Hash] app environment
12
+ # @return [void]
8
13
  def initialize(env)
9
14
  @env = env
10
15
  end
11
16
 
17
+ # Processes the given request.
18
+ #
19
+ # @param req [Syntropy::Request]
20
+ # @return [void]
12
21
  def call(req)
13
22
  response, status = __invoke__(req)
14
23
  req.respond(
@@ -20,6 +29,9 @@ module Syntropy
20
29
 
21
30
  private
22
31
 
32
+ # Processes the request by invoking the corresponding object method.
33
+ #
34
+ # @param req [Syntropy::Request]
23
35
  def __invoke__(req)
24
36
  q = req.validate_param(:q, String).to_sym
25
37
  response = case req.method
@@ -31,7 +43,7 @@ module Syntropy
31
43
  raise Syntropy::Error.method_not_allowed
32
44
  end
33
45
  [{ status: 'OK', response: response }, HTTP::OK]
34
- rescue => e
46
+ rescue StandardError => e
35
47
  if !e.is_a?(Syntropy::Error)
36
48
  p e
37
49
  p e.backtrace
@@ -39,6 +51,11 @@ module Syntropy
39
51
  __error_response__(e)
40
52
  end
41
53
 
54
+ # Processes a GET request.
55
+ #
56
+ # @param sym [Symbol] object method
57
+ # @param req [Syntropy::Request] request
58
+ # @return [any] method call return value
42
59
  def __invoke_get__(sym, req)
43
60
  return send(sym, req) if respond_to?(sym)
44
61
 
@@ -46,6 +63,11 @@ module Syntropy
46
63
  raise err
47
64
  end
48
65
 
66
+ # Processes a POST request.
67
+ #
68
+ # @param sym [Symbol] object method
69
+ # @param req [Syntropy::Request] request
70
+ # @return [any] method call return value
49
71
  def __invoke_post__(sym, req)
50
72
  sym_post = :"#{sym}!"
51
73
  return send(sym_post, req) if respond_to?(sym_post)
@@ -54,6 +76,10 @@ module Syntropy
54
76
  raise err
55
77
  end
56
78
 
79
+ # Generates an error response in case of exception.
80
+ #
81
+ # @param err [Exception] raised Exception
82
+ # @return [Hash] error response
57
83
  def __error_response__(err)
58
84
  http_status = err.respond_to?(:http_status) ? err.http_status : HTTP::INTERNAL_SERVER_ERROR
59
85
  error_name = err.class.name.split('::').last
@@ -3,29 +3,51 @@
3
3
  require 'json'
4
4
 
5
5
  module Syntropy
6
+ # The Logger class implements a logger with support for structured logging.
6
7
  class Logger
8
+ # Initializes the logger.
9
+ #
10
+ # @param machine [UringMachine] machine instance
11
+ # @param fd [Integer] file descriptor for writing log messages
12
+ # @param opts [Hash] logger options
13
+ # @return [void]
7
14
  def initialize(machine, fd = $stdout.fileno, **opts)
8
15
  @machine = machine
9
16
  @fd = fd
10
17
  @opts = opts
11
18
  end
12
19
 
20
+ # Logs an INFO entry.
21
+ #
22
+ # @param o [Hash] log entry
23
+ # @return [void]
13
24
  def info(o)
14
25
  call(:INFO, o)
15
26
  end
16
27
 
28
+ # Logs an WARN entry.
29
+ #
30
+ # @param o [Hash] log entry
31
+ # @return [void]
17
32
  def warn(o)
18
33
  call(:WARN, o)
19
34
  end
20
35
 
36
+ # Logs an ERROR entry.
37
+ #
38
+ # @param o [Hash] log entry
39
+ # @return [void]
21
40
  def error(o)
22
41
  call(:ERROR, o)
23
42
  end
24
43
 
25
44
  private
26
45
 
27
- # @param level <Symbol> log level
28
- # @param o <Hash> hash
46
+ # Writes a log entry.
47
+ #
48
+ # @param level [Symbol] log level
49
+ # @param o [Hash] entry
50
+ # @return [void]
29
51
  def call(level, o)
30
52
  emit(make_entry(level, o))
31
53
  rescue StandardError => e
@@ -35,10 +57,20 @@ module Syntropy
35
57
  exit
36
58
  end
37
59
 
60
+ # Emits an entry to the associated output.
61
+ #
62
+ # @param entry [Hash] log entry
63
+ # @return [void]
38
64
  def emit(entry)
39
65
  @machine.write_async(@fd, "#{entry.to_json}\n")
40
66
  end
41
67
 
68
+ # Transforms raw entry into a log entry. Additional information is added
69
+ # dependending on the kind of entry.
70
+ #
71
+ # @param level [Symbol] log level
72
+ # @param o [Hash] raw entry
73
+ # @return [Hash] log entry
42
74
  def make_entry(level, o)
43
75
  if o[:request]
44
76
  make_request_entry(level, o)
@@ -49,52 +81,74 @@ module Syntropy
49
81
  end
50
82
  end
51
83
 
84
+ # Makes an error log entry.
85
+ #
86
+ # @param level [Symbol] log level
87
+ # @param o [Hash] input entry
88
+ # @return [Hash] output entry
52
89
  def make_error_entry(level, o)
53
90
  err = o[:error]
91
+ t = Time.now
54
92
  {
55
- level: level.to_s,
56
- ts: (t = Time.now; t.to_i),
57
- ts_s: t.iso8601
58
- }
59
- .merge(o)
60
- .merge(
93
+ level: level.to_s,
94
+ ts: t.to_i,
95
+ ts_s: t.iso8601
96
+ }.merge(o).merge(
61
97
  error: "#{err.class}: #{err.message}",
62
98
  backtrace: err.backtrace
63
99
  )
64
100
  end
65
101
 
102
+ # Makes a request log entry.
103
+ #
104
+ # @param level [Symbol] log level
105
+ # @param o [Hash] input entry
106
+ # @return [Hash] output entry
66
107
  def make_request_entry(level, o)
67
108
  request = o[:request]
68
109
  request_headers = request.headers
69
110
  response_headers = o[:response_headers]
70
111
  elapsed = monotonic_clock - request.start_stamp
112
+ t = Time.now
71
113
  {
72
- level: level.to_s,
73
- ts: (t = Time.now; t.to_i),
74
- ts_s: t.iso8601,
75
- message: o[:message] || 'HTTP request done',
76
- client_ip: request.forwarded_for || '?',
77
- http_method: request_headers[':method'].upcase,
78
- user_agent: request_headers['user-agent'],
79
- uri: full_uri(request_headers),
80
- status: response_headers[':status'] || '200',
81
- elapsed: elapsed
114
+ level: level.to_s,
115
+ ts: t.to_i,
116
+ ts_s: t.iso8601,
117
+ message: o[:message] || 'HTTP request done',
118
+ client_ip: request.forwarded_for || '?',
119
+ http_method: request_headers[':method'].upcase,
120
+ user_agent: request_headers['user-agent'],
121
+ uri: full_uri(request_headers),
122
+ status: response_headers[':status'] || '200',
123
+ elapsed: elapsed
82
124
  }
83
125
  end
84
126
 
85
- def monotonic_clock
86
- ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
87
- end
88
-
127
+ # Makes a request log entry.
128
+ #
129
+ # @param level [Symbol] log level
130
+ # @param o [Hash] input entry
131
+ # @return [Hash] output entry
89
132
  def make_hash_entry(level, hash)
133
+ t = Time.now
90
134
  {
91
- level: level.to_s,
92
- ts: (t = Time.now; t.to_i),
93
- ts_s: t.iso8601
94
- }
95
- .merge(hash)
135
+ level: level.to_s,
136
+ ts: t.to_i,
137
+ ts_s: t.iso8601
138
+ }.merge(hash)
139
+ end
140
+
141
+ # Returns the monotonic clock.
142
+ #
143
+ # @return [Float]
144
+ def monotonic_clock
145
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
96
146
  end
97
147
 
148
+ # Returns the full request URI for the given request headers.
149
+ #
150
+ # @param headers [Hash] request headers
151
+ # @return [String] request URI
98
152
  def full_uri(headers)
99
153
  format(
100
154
  '%<scheme>s://%<host>s%<path>s',
@@ -3,39 +3,68 @@
3
3
  require 'yaml'
4
4
 
5
5
  module Syntropy
6
- DATE_REGEXP = /(\d{4}-\d{2}-\d{2})/
7
- FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
8
- YAML_OPTS = {
9
- permitted_classes: [Date],
10
- symbolize_names: true
11
- }
12
-
13
- # Parses the markdown file at the given path.
14
- #
15
- # @param path [String] file path
16
- # @return [Array] an tuple containing properties<Hash>, contents<String>
17
- def self.parse_markdown_file(path, env)
18
- content = IO.read(path) || ''
19
- atts = {}
20
-
21
- # Parse date from file name
22
- m = path.match(DATE_REGEXP)
23
- atts[:date] ||= Date.parse(m[1]) if m
24
-
25
- if (m = content.match(FRONT_MATTER_REGEXP))
26
- front_matter = m[1]
27
- content = m.post_match
28
-
29
- yaml = YAML.safe_load(front_matter, **YAML_OPTS)
30
- atts = atts.merge(yaml)
31
- end
6
+ # Markdown parsing.
7
+ module Markdown
8
+ FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
9
+ YAML_OPTS = {
10
+ permitted_classes: [Date],
11
+ symbolize_names: true
12
+ }.freeze
32
13
 
33
- if env[:root_dir]
34
- atts[:url] = path
35
- .gsub(/#{env[:root_dir]}/, '')
36
- .gsub(/\.md$/, '')
37
- end
14
+ class << self
15
+ # Parses the markdown file at the given path.
16
+ #
17
+ # @param path [String] file path
18
+ # @return [Array] an tuple containing properties<Hash>, contents<String>
19
+ def parse(path, env)
20
+ content = IO.read(path) || ''
21
+ atts = {}
22
+
23
+ parse_date(path, atts)
24
+ content = parse_content(content, atts)
25
+ atts[:url] = path_to_url(path, env[:app_root]) if env[:app_root]
26
+
27
+ [atts, content]
28
+ end
29
+
30
+ private
38
31
 
39
- [atts, content]
32
+ # Parses date information from the given path.
33
+ #
34
+ # @param path [String] file path
35
+ # @param atts [Hash] file attributes
36
+ # @return [void]
37
+ def parse_date(path, atts)
38
+ # Parse date from file name
39
+ if (m = path.match(/(\d{4}-\d{2}-\d{2})/))
40
+ atts[:date] ||= Date.parse(m[1])
41
+ end
42
+ end
43
+
44
+ # Parses the markdown content and front matter attributes from the given content.
45
+ #
46
+ # @param content [String] file content
47
+ # @param atts [Hash] file attributes
48
+ # @return [String] parsed markdown content
49
+ def parse_content(content, atts)
50
+ if (m = content.match(FRONT_MATTER_REGEXP))
51
+ front_matter = m[1]
52
+ content = m.post_match
53
+
54
+ yaml = YAML.safe_load(front_matter, **YAML_OPTS)
55
+ atts.merge!(yaml)
56
+ end
57
+ content
58
+ end
59
+
60
+ # Converts the markdown file path to URL
61
+ #
62
+ # @param path [String] file path
63
+ # @param app_root [String] app root directory
64
+ # @return [String] url
65
+ def path_to_url(path, app_root)
66
+ path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
67
+ end
68
+ end
40
69
  end
41
70
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- # File extension to MIME type mapping
4
+ # The MimeTypes module maps file extensions to MIME types.
5
5
  module MimeTypes
6
6
  TYPES = {
7
7
  'html' => 'text/html',
@@ -21,16 +21,20 @@ module Syntropy
21
21
 
22
22
  EXT_REGEXP = /\.?([^\.]+)$/.freeze
23
23
 
24
- def self.[](ref)
25
- case ref
24
+ # Returns the mime type for the given file extension.
25
+ #
26
+ # @param ext [String, Symbol] file extension
27
+ # @return [String, nil] MIME type
28
+ def self.[](ext)
29
+ case ext
26
30
  when Symbol
27
- TYPES[ref.to_s]
31
+ TYPES[ext.to_s]
28
32
  when EXT_REGEXP
29
33
  TYPES[Regexp.last_match(1)]
30
34
  when ''
31
35
  nil
32
36
  else
33
- raise "Invalid argument #{ref.inspect}"
37
+ raise "Invalid argument #{ext.inspect}"
34
38
  end
35
39
  end
36
40
  end