syntropy 0.32.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/TODO.md +0 -39
  4. data/cmd/console.rb +18 -7
  5. data/cmd/serve.rb +26 -20
  6. data/cmd/test.rb +90 -21
  7. data/examples/blog/.gitignore +1 -0
  8. data/examples/blog/app/_lib/database.rb +13 -0
  9. data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
  10. data/examples/blog/app/posts/[id]/edit.rb +2 -2
  11. data/examples/blog/app/posts/[id]/index.rb +8 -5
  12. data/examples/blog/app/posts/index.rb +7 -5
  13. data/examples/blog/app/posts/new.rb +1 -1
  14. data/examples/blog/config/development.rb +5 -0
  15. data/examples/blog/config/production.rb +4 -0
  16. data/examples/blog/config/test.rb +5 -0
  17. data/examples/blog/test/test_posts.rb +65 -0
  18. data/examples/mcp-oauth/app/oauth/token.rb +1 -1
  19. data/examples/mcp-oauth/test/test_app.rb +2 -20
  20. data/examples/mcp-oauth/test/test_oauth.rb +93 -217
  21. data/lib/syntropy/app.rb +48 -40
  22. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
  23. data/lib/syntropy/db/schema.rb +1 -1
  24. data/lib/syntropy/db/store.rb +2 -0
  25. data/lib/syntropy/errors.rb +6 -2
  26. data/lib/syntropy/http/client.rb +1 -0
  27. data/lib/syntropy/http/server_connection.rb +15 -13
  28. data/lib/syntropy/json_api.rb +27 -1
  29. data/lib/syntropy/logger.rb +81 -27
  30. data/lib/syntropy/markdown.rb +61 -32
  31. data/lib/syntropy/mime_types.rb +9 -5
  32. data/lib/syntropy/module_loader.rb +25 -13
  33. data/lib/syntropy/papercraft_extensions.rb +2 -2
  34. data/lib/syntropy/request/mock_adapter.rb +10 -8
  35. data/lib/syntropy/request/request_info.rb +91 -0
  36. data/lib/syntropy/request/response.rb +3 -14
  37. data/lib/syntropy/request/validation.rb +1 -0
  38. data/lib/syntropy/request.rb +55 -14
  39. data/lib/syntropy/routing_tree.rb +27 -28
  40. data/lib/syntropy/session.rb +198 -0
  41. data/lib/syntropy/side_run.rb +25 -2
  42. data/lib/syntropy/test.rb +168 -2
  43. data/lib/syntropy/utils.rb +53 -18
  44. data/lib/syntropy/version.rb +1 -1
  45. data/lib/syntropy.rb +44 -10
  46. data/syntropy.gemspec +1 -0
  47. data/test/bm_router_proc.rb +4 -4
  48. data/test/fixtures/app/class_instance.rb +5 -0
  49. data/test/fixtures/app/http.rb +5 -0
  50. data/test/fixtures/app/post_ct.rb +5 -0
  51. data/test/fixtures/app/singleton.rb +3 -0
  52. data/test/test_app.rb +13 -52
  53. data/test/test_caching.rb +2 -2
  54. data/test/test_db_schema.rb +1 -1
  55. data/test/test_http_server_connection.rb +11 -8
  56. data/test/test_module_loader.rb +5 -2
  57. data/test/test_request_session.rb +254 -0
  58. data/test/test_response.rb +0 -19
  59. data/test/test_routing_tree.rb +69 -69
  60. data/test/test_server.rb +5 -9
  61. data/test/test_test.rb +70 -0
  62. metadata +67 -42
  63. data/examples/blog/app/_setup.rb +0 -4
  64. data/examples/mcp-oauth/test/helper.rb +0 -9
  65. /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
  66. /data/test/{app → fixtures/app}/_hook.rb +0 -0
  67. /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
  68. /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
  69. /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
  70. /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
  71. /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
  72. /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
  73. /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
  74. /data/test/{app → fixtures/app}/about/_error.rb +0 -0
  75. /data/test/{app → fixtures/app}/about/foo.md +0 -0
  76. /data/test/{app → fixtures/app}/about/index.rb +0 -0
  77. /data/test/{app → fixtures/app}/about/raise.rb +0 -0
  78. /data/test/{app → fixtures/app}/api+.rb +0 -0
  79. /data/test/{app → fixtures/app}/assets/style.css +0 -0
  80. /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
  81. /data/test/{app → fixtures/app}/bar.rb +0 -0
  82. /data/test/{app → fixtures/app}/baz.rb +0 -0
  83. /data/test/{app → fixtures/app}/by_method.rb +0 -0
  84. /data/test/{app → fixtures/app}/deps.rb +0 -0
  85. /data/test/{app → fixtures/app}/index.html +0 -0
  86. /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
  87. /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
  88. /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
  89. /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
  90. /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
  91. /data/test/{app → fixtures/app}/rss.rb +0 -0
  92. /data/test/{app → fixtures/app}/tmp.rb +0 -0
  93. /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
  94. /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
  95. /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
  96. /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
  97. /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
  98. /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
  99. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
  100. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
  101. /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
  102. /data/test/{schema → fixtures/schema}/2026-05-30-bar.rb +0 -0
@@ -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
@@ -23,12 +23,11 @@ module Syntropy
23
23
 
24
24
  @done = nil
25
25
  @response_headers = nil
26
+ @response_cookies = nil
26
27
  end
27
28
 
28
29
  def run
29
30
  loop do
30
- @done = nil
31
- @response_headers = nil
32
31
  persist = serve_request
33
32
  break if !persist
34
33
  end
@@ -47,6 +46,9 @@ module Syntropy
47
46
  # object and handing it off to the app handler. Returns true if the
48
47
  # connection should be persisted.
49
48
  def serve_request
49
+ @done = nil
50
+ @response_headers = nil
51
+ @response_cookies = nil
50
52
  @closed = nil
51
53
  headers = @io.http_read_request_headers
52
54
  return false if !headers
@@ -130,13 +132,10 @@ module Syntropy
130
132
  @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
131
133
  end
132
134
 
133
- def set_cookie(*cookies)
134
- existing_cookies = @response_headers && @response_headers['Set-Cookie']
135
- if existing_cookies
136
- @response_headers['Set-Cookie'] = existing_cookies + cookies
137
- else
138
- set_response_headers('Set-Cookie' => cookies)
139
- end
135
+ DELETE_COOKIE = "; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Max-Age=0; HttpOnly"
136
+
137
+ def set_cookie(key, value)
138
+ (@response_cookies ||= {})[key] = value || DELETE_COOKIE
140
139
  end
141
140
 
142
141
  SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
@@ -152,11 +151,11 @@ module Syntropy
152
151
  # @param body [String] response body
153
152
  # @param headers
154
153
  def respond(request, body, headers)
154
+ add_set_cookie_headers if @response_cookies
155
155
  headers = @response_headers.merge(headers) if @response_headers
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
 
@@ -315,6 +311,12 @@ module Syntropy
315
311
  lines << "#{key}: #{value}\r\n"
316
312
  end
317
313
  end
314
+
315
+ def add_set_cookie_headers
316
+ @response_headers ||= {}
317
+ sc = (@response_headers['Set-Cookie'] ||= [])
318
+ @response_cookies.each { |k, v| sc << "#{k}=#{v}" }
319
+ end
318
320
  end
319
321
  end
320
322
  end
@@ -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
@@ -28,7 +28,7 @@ module Syntropy
28
28
  # @return [void]
29
29
  def initialize(env)
30
30
  @env = env
31
- @root_dir = env[:root_dir]
31
+ @app_root = env[:app_root]
32
32
  @modules = {} # maps ref to module entry
33
33
  @fn_map = {} # maps filename to ref
34
34
  end
@@ -39,11 +39,12 @@ module Syntropy
39
39
  # @return [any] export value
40
40
  def load(ref, raise_on_missing: true)
41
41
  ref = "/#{ref}" if ref !~ /^\//
42
+ if !(entry = @modules[ref])
43
+ entry = load_module(ref, raise_on_missing:)
44
+ return if !entry
42
45
 
43
- entry = load_module(ref, raise_on_missing:)
44
- return if !entry
45
-
46
- @modules[ref] ||= entry
46
+ @modules[ref] = entry
47
+ end
47
48
  entry[:export_value]
48
49
  end
49
50
 
@@ -54,8 +55,8 @@ module Syntropy
54
55
  # @param dir [String] relative module directory
55
56
  # @return [Array<String>] list of modules
56
57
  def list(dir)
57
- fns = Dir[File.join(@root_dir, dir, '*.rb')]
58
- fns.map { it.match(/^#{@root_dir}\/(.+)\.rb$/)[1] }.sort
58
+ fns = Dir[File.join(@app_root, dir, '*.rb')]
59
+ fns.map { it.match(/^#{@app_root}\/(.+)\.rb$/)[1] }.sort
59
60
  end
60
61
 
61
62
  # Invalidates a module by its filename, normally following a change to the
@@ -88,10 +89,13 @@ module Syntropy
88
89
  entry[:reverse_deps].each { invalidate_ref(it) }
89
90
  end
90
91
 
92
+ # Invalidates a collection module.
93
+ #
94
+ # @return [void]
91
95
  def invalidate_collection_modules
92
96
  refs = []
93
97
  @modules.each do |ref, entry|
94
- refs << ref if entry[:module].is_collection_module?
98
+ refs << ref if entry[:module].collection_module?
95
99
  end
96
100
  refs.each { invalidate_ref(it) }
97
101
  end
@@ -117,7 +121,7 @@ module Syntropy
117
121
  # @return [Hash] module entry
118
122
  def load_module(ref, raise_on_missing: true)
119
123
  ref = "/#{ref}" if ref !~ /^\//
120
- fn = File.expand_path(File.join(@root_dir, "#{ref}.rb"))
124
+ fn = File.expand_path(File.join(@app_root, "#{ref}.rb"))
121
125
  if !File.file?(fn)
122
126
  raise Syntropy::Error, "File not found #{fn}" if raise_on_missing
123
127
 
@@ -141,6 +145,10 @@ module Syntropy
141
145
  }
142
146
  end
143
147
 
148
+ # Cleans up a module reference specifier, turning /index into /
149
+ #
150
+ # @param ref [String] input ref
151
+ # @return [String] clean ref
144
152
  def clean_ref(ref)
145
153
  return '/' if ref =~ /^index(\+)?$/
146
154
 
@@ -221,7 +229,7 @@ module Syntropy
221
229
  # #collection_module!
222
230
  #
223
231
  # @return [bool]
224
- def is_collection_module?
232
+ def collection_module?
225
233
  @collection_module_p
226
234
  end
227
235
 
@@ -257,6 +265,10 @@ module Syntropy
257
265
  self
258
266
  end
259
267
 
268
+ # Normalize an import reference, turning a relative path into an absolute one.
269
+ #
270
+ # @param ref [String] input ref
271
+ # @return [String] normalized ref
260
272
  def normalize_import_ref(ref)
261
273
  base = @ref == '' ? '/' : @ref
262
274
  if ref =~ /^\//
@@ -273,7 +285,7 @@ module Syntropy
273
285
  # @return [Papercraft::Template] template
274
286
  def template(proc = nil, &block)
275
287
  proc ||= block
276
- raise "No template block/proc given" if !proc
288
+ raise 'No template block/proc given' if !proc
277
289
 
278
290
  Papercraft::Template.new(proc)
279
291
  end
@@ -285,10 +297,10 @@ module Syntropy
285
297
  # @return [Papercraft::Template] template
286
298
  def template_xml(proc = nil, &block)
287
299
  proc ||= block
288
- raise "No template block/proc given" if !proc
300
+ raise 'No template block/proc given' if !proc
289
301
 
290
302
  Papercraft::Template.new(proc, mode: :xml)
291
- rescue => e
303
+ rescue StandardError => e
292
304
  p e
293
305
  p e.backtrace
294
306
  raise
@@ -4,12 +4,12 @@ require 'papercraft'
4
4
 
5
5
  Papercraft.extension(
6
6
  'auto_refresh!': ->(loc = '/.syntropy') {
7
- if $syntropy_dev_mode
7
+ if Syntropy.dev_mode
8
8
  script(src: File.join(loc, 'auto_refresh/watch.js'), type: 'module')
9
9
  end
10
10
  },
11
11
  'debug_template!': ->(loc = '/.syntropy') {
12
- if $syntropy_dev_mode
12
+ if Syntropy.dev_mode
13
13
  script(src: File.join(loc, 'debug/debug.js'), type: 'module')
14
14
  end
15
15
  }