syntropy 0.6 → 0.8

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: 553553edc3b0c81902bc3a77470bc6e9789fcd757066dcd5b4e790d38f7a28bf
4
- data.tar.gz: fdb2cec5f581ba49d5156cce188f31a4811d29767c71833bfcfa37899ee0efc5
3
+ metadata.gz: df2a40954f2c157f5820138528b8c4a5589670b77b5d661a3c55a8f882231f64
4
+ data.tar.gz: e74d8ab77bf364d4ae4629057fcb0a3027e5901e258b22504f473d99327b9f8a
5
5
  SHA512:
6
- metadata.gz: 7340ecb01725dcc15a78a66fef5f87a113c2e9759a2305753241b0c1cf738f45b3fd6fea2dcf7bca55196cc3fc309db2ade3e7bb12292fc5aa3abe63362403f9
7
- data.tar.gz: 48427e653e2c99022177e5475ef53b66c57143288f43e4997c40f1ba464728b310c5e0df343e7f03e3ed79f9fa8637e305b6a22296b19fb15068b7228cf0531f
6
+ metadata.gz: 1221b890c2bf0d28cef98e6cca53918e45101c1505d1b745022ec93545a6edbe9e67b281dcadf0f037bcced05a116106c1f27e95eedebdba8f587e03dcc13cd3
7
+ data.tar.gz: 5fa7d7da0207068c6130bc70bb4582c74bde3f9cecbe164fad2c8f93b56f2f8f30bd445bf0d015aa834c3e0e04ead49ca5bd13c3ba2ce1b872006d3fa6afe31a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.8 2025-07-05
2
+
3
+ - Add `MODULE` constant for referencing the module
4
+ - Implement `Module.page_list`
5
+
6
+ ## 0.7 2025-07-05
7
+
8
+ - Implement `Module.route_by_host`
9
+ - Add snoozing on DB progress
10
+
1
11
  ## 0.6 2025-07-05
2
12
 
3
13
  - Add support for middleware and error handlers
data/README.md CHANGED
@@ -59,6 +59,7 @@ site/
59
59
  ├ _articles/
60
60
  | └ 2025-01-01-hello_world.md
61
61
  ├ api/
62
+ | ├ _hook.rb
62
63
  | └ v1.rb
63
64
  ├ assets/
64
65
  | ├ css/
@@ -73,10 +74,30 @@ site/
73
74
  Syntropy knows how to serve static asset files (CSS, JS, images...) as well as
74
75
  render markdown files and run modules written in Ruby.
75
76
 
77
+ Some conventions employed in Syntropy-based web apps:
78
+
79
+ - Files and directories starting with an underscore, e.g. `/_layout` are
80
+ considered private, and are not exposed to HTTP clients.
81
+ - Normally, a module route only responds to its exact path. To respond to any
82
+ subtree path, add a plus sign to the end of the module name, e.g. `/api+.rb`.
83
+ - A `_hook.rb` module is invoked on each request routed to anywhere in the
84
+ corresponding subtree. For example, a hook defined in `/api/_hook.rb` will be
85
+ used on requests to `/api`, `/api/foo`, `/api/bar` etc.
86
+ - As a corollary, each route "inherits" all hooks defined up the tree. For
87
+ example, a request to `/api/foo` will invoke hooks defined in `/api/_hook.rb`
88
+ and `/_hook.rb`.
89
+ - In a similar fashion to hooks, error handlers can be defined for different
90
+ subtrees in a `_error.rb` module. For each route, in case of an exception,
91
+ Syntropy will invoke the closest-found error handler module up the tree. For
92
+ example, an error raised while responding to a request to `/api/foo` will
93
+ prefer the error handler in `/api/_error.rb`, rather than `/_error.rb`.
94
+ - The Syntrpy router accepts clean URLs for Ruby modules and Markdown files. It
95
+ also accepts clean URLs for `index.html` files.
96
+
76
97
  ## What does a Syntropic Ruby module look like?
77
98
 
78
- Consider `archive.rb` in the example above. We want to get a list of articles
79
- and render it with the given layout:
99
+ Consider `site/archive.rb` in the file tree above. We want to get a list of
100
+ articles and render it using the given layout:
80
101
 
81
102
  ```ruby
82
103
  # archive.rb
@@ -97,7 +118,7 @@ export @@layout.apply(title: 'archive') {
97
118
  }
98
119
  ```
99
120
 
100
- But a module can be something completely different:
121
+ But a module can also be something completely different:
101
122
 
102
123
  ```ruby
103
124
  # api/v1.rb
@@ -121,4 +142,114 @@ export APIV1
121
142
  ```
122
143
 
123
144
  Basically, the exported value can be a template, a callable or a class that
124
- responds to the request.
145
+ responds to the request. Here's a minimal module that responds with a hello
146
+ world:
147
+
148
+ ```ruby
149
+ export ->(req) { req.respond('Hello, world!') }
150
+ ```
151
+
152
+ ## Module Export / Import
153
+
154
+ Modules communicate with the Syntropy framework and with other modules using
155
+ `export` and `import`. Each module must export a single object, which can be a
156
+ controller class, a callable (a proc/closure) or a template. The exported object
157
+ is used by Syntropy as the entrypoint for the route.
158
+
159
+ But modules can also import other modules. This permits the use of layouts:
160
+
161
+ ```ruby
162
+ # site/_layout/default.rb
163
+ export template { |**props|
164
+ header {
165
+ h1 'Foo'
166
+ }
167
+ content {
168
+ emit_yield(**props)
169
+ }
170
+ }
171
+
172
+ # site/index.rb
173
+ layout = import '_layout/default'
174
+
175
+ export layout.apply { |**props|
176
+ p 'o hi!'
177
+ }
178
+ ```
179
+
180
+ A module can also be written as a set of methods without any explicit class
181
+ definition. This allows writing modules in a more functional style:
182
+
183
+ ```ruby
184
+ # site/_lib/utils.rb
185
+
186
+ def foo
187
+ 42
188
+ end
189
+
190
+ export self
191
+
192
+ # site/index.rb
193
+ Utils = import '_lib/utils'
194
+
195
+ export template {
196
+ h1 "foo = #{Utils.foo}"
197
+ }
198
+ ```
199
+
200
+ ## Hooks (a.k.a. Middleware)
201
+
202
+ A hook is a piece of code that can intercept HTTP requests before they are
203
+ passed off to the correspending route. Hooks are applied to the subtree of the
204
+ directory in which they reside.
205
+
206
+ Hooks can be used for a variety of purposes:
207
+
208
+ - Parameter validation
209
+ - Authentication, authorization & session management
210
+ - Logging
211
+ - Request rewriting / redirecting
212
+
213
+ When multiple hooks are defined up the tree for a particular route, they are
214
+ chained together such that each hook is invoked starting from the file tree root
215
+ and down to the route path.
216
+
217
+ Hooks are implemented as modules named `_hook.rb`, that export procs (or
218
+ callables) with the following signature:
219
+
220
+ ```ruby
221
+ # **/_hook.rb
222
+ export ->(req, app) { ... }
223
+ ```
224
+
225
+ ... where req is the request object, and app is the callable that code. Here's
226
+ an example of an authorization hook:
227
+
228
+ ```ruby
229
+ export ->(req, app) {
230
+ if (!req.cookies[:session_id])
231
+ req.redirect('/signin')
232
+ else
233
+ app.(req)
234
+ end
235
+ }
236
+ ```
237
+
238
+ ## Error handlers
239
+
240
+ An error handler can be defined separately for each subtree. When an exception
241
+ is raised that is not rescued by the application code, Syntropy will look for an
242
+ error handler up the file tree, and will invoke the first error handler found.
243
+
244
+ Error handlers are implemented as modules named `_error.rb`, that export procs (or
245
+ callables) with the following signature:
246
+
247
+ ```ruby
248
+ # **/_error.rb
249
+ ->(req, err) { ... }
250
+ ```
251
+
252
+ Using different error handlers for parts of the route tree allows different
253
+ error responses for each route. For example, the error response for an API route
254
+ can be a JSON object, while the error response for a browser page route can be a
255
+ custom HTML page.
data/lib/syntropy/app.rb CHANGED
@@ -32,20 +32,20 @@ module Syntropy
32
32
  end
33
33
  end
34
34
 
35
- def initialize(machine, src_path, mount_path, opts = {})
35
+ def initialize(machine, location, mount_path, opts = {})
36
36
  @machine = machine
37
- @src_path = File.expand_path(src_path)
37
+ @location = File.expand_path(location)
38
38
  @mount_path = mount_path
39
39
  @opts = opts
40
40
 
41
- @module_loader = Syntropy::ModuleLoader.new(@src_path, @opts)
41
+ @module_loader = Syntropy::ModuleLoader.new(@location, @opts)
42
42
  @router = Syntropy::Router.new(@opts, @module_loader)
43
43
 
44
44
  @machine.spin do
45
45
  # we do startup stuff asynchronously, in order to first let TP2 do its
46
46
  # setup tasks
47
47
  @machine.sleep 0.15
48
- @opts[:logger]&.call("Serving from #{File.expand_path(@src_path)}")
48
+ @opts[:logger]&.call("Serving from #{File.expand_path(@location)}")
49
49
  @router.start_file_watcher if opts[:watch_files]
50
50
  end
51
51
  end
@@ -135,7 +135,7 @@ module Syntropy
135
135
  end
136
136
 
137
137
  def load_module(entry)
138
- ref = entry[:fn].gsub(%r{^#{@src_path}/}, '').gsub(/\.rb$/, '')
138
+ ref = entry[:fn].gsub(%r{^#{@location}/}, '').gsub(/\.rb$/, '')
139
139
  o = @module_loader.load(ref)
140
140
  o.is_a?(Papercraft::Template) ? wrap_template(o) : o
141
141
  rescue Exception => e
@@ -151,46 +151,15 @@ module Syntropy
151
151
  end
152
152
 
153
153
  def render_markdown(fn)
154
- atts, md = parse_markdown_file(fn)
154
+ atts, md = Syntropy.parse_markdown_file(fn, @opts)
155
155
 
156
156
  if atts[:layout]
157
157
  layout = @module_loader.load("_layout/#{atts[:layout]}")
158
- html = layout.apply { emit_markdown(md) }.render
158
+ html = layout.apply(**atts) { emit_markdown(md) }.render
159
159
  else
160
160
  html = Papercraft.markdown(md)
161
161
  end
162
162
  html
163
163
  end
164
-
165
- DATE_REGEXP = /(\d{4}\-\d{2}\-\d{2})/
166
- FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
167
- YAML_OPTS = {
168
- permitted_classes: [Date],
169
- symbolize_names: true
170
- }
171
-
172
- # Parses the markdown file at the given path.
173
- #
174
- # @param path [String] file path
175
- # @return [Array] an tuple containing properties<Hash>, contents<String>
176
- def parse_markdown_file(path)
177
- content = IO.read(path) || ''
178
- atts = {}
179
-
180
- # Parse date from file name
181
- if (m = path.match(DATE_REGEXP))
182
- atts[:date] ||= Date.parse(m[1])
183
- end
184
-
185
- if (m = content.match(FRONT_MATTER_REGEXP))
186
- front_matter = m[1]
187
- content = m.post_match
188
-
189
- yaml = YAML.safe_load(front_matter, **YAML_OPTS)
190
- atts = atts.merge(yaml)
191
- end
192
-
193
- [atts, content]
194
- end
195
164
  end
196
165
  end
@@ -34,9 +34,7 @@ module Syntropy
34
34
  private
35
35
 
36
36
  def checkout
37
- if @queue.count == 0 && @count < @max_conn
38
- return create_db
39
- end
37
+ return make_db_instance if @queue.count == 0 && @count < @max_conn
40
38
 
41
39
  @machine.shift(@queue)
42
40
  end
@@ -45,16 +43,11 @@ module Syntropy
45
43
  @machine.push(@queue, db)
46
44
  end
47
45
 
48
- def create_db
49
- db = Extralite::Database.new(@fn, wal: true)
50
- setup_db(db)
51
- @count += 1
52
- db
53
- end
54
-
55
- def setup_db(db)
56
- # setup WAL, sync
57
- # setup concurrency stuff
46
+ def make_db_instance
47
+ Extralite::Database.new(@fn, wal: true).tap do
48
+ @count += 1
49
+ it.on_progress(mode: :at_least_once, period: 320, tick: 10) { @machine.snooze }
50
+ end
58
51
  end
59
52
  end
60
53
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ DATE_REGEXP = /(\d{4}-\d{2}-\d{2})/
5
+ FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
6
+ YAML_OPTS = {
7
+ permitted_classes: [Date],
8
+ symbolize_names: true
9
+ }
10
+
11
+ # Parses the markdown file at the given path.
12
+ #
13
+ # @param path [String] file path
14
+ # @return [Array] an tuple containing properties<Hash>, contents<String>
15
+ def self.parse_markdown_file(path, opts)
16
+ content = IO.read(path) || ''
17
+ atts = {}
18
+
19
+ # Parse date from file name
20
+ m = path.match(DATE_REGEXP)
21
+ atts[:date] ||= Date.parse(m[1]) if m
22
+
23
+ if (m = content.match(FRONT_MATTER_REGEXP))
24
+ front_matter = m[1]
25
+ content = m.post_match
26
+
27
+ yaml = YAML.safe_load(front_matter, **YAML_OPTS)
28
+ atts = atts.merge(yaml)
29
+ end
30
+
31
+ if opts[:location]
32
+ atts[:url] = path
33
+ .gsub(/#{opts[:location]}/, '')
34
+ .gsub(/\.md$/, '')
35
+ end
36
+
37
+ [atts, content]
38
+ end
39
+ end
@@ -32,7 +32,7 @@ module Syntropy
32
32
 
33
33
  mod_body = IO.read(fn)
34
34
  mod_ctx = Class.new(Syntropy::Module)
35
- mod_ctx.loader = self
35
+ mod_ctx.prepare(loader: self, env: @env)
36
36
  mod_ctx.module_eval(mod_body, fn, 1)
37
37
 
38
38
  export_value = mod_ctx.__export_value__
@@ -62,24 +62,56 @@ module Syntropy
62
62
  @env = env
63
63
  end
64
64
 
65
- def self.loader=(loader)
66
- @loader = loader
67
- end
65
+ class << self
66
+ def prepare(loader:, env:)
67
+ @loader = loader
68
+ @env = env
69
+ const_set(:MODULE, self)
70
+ end
68
71
 
69
- def self.import(ref)
70
- @loader.load(ref)
71
- end
72
+ attr_reader :__export_value__
72
73
 
73
- def self.export(ref)
74
- @__export_value__ = ref
75
- end
74
+ def import(ref)
75
+ @loader.load(ref)
76
+ end
76
77
 
77
- def self.templ(&block)
78
- Papercraft.html(&block)
79
- end
78
+ def export(ref)
79
+ @__export_value__ = ref
80
+ end
80
81
 
81
- def self.__export_value__
82
- @__export_value__
82
+ def template(&block)
83
+ Papercraft.html(&block)
84
+ end
85
+
86
+ def route_by_host(map = nil)
87
+ root = @env[:location]
88
+ sites = Dir[File.join(root, '*')]
89
+ .select { File.directory?(it) }
90
+ .each_with_object({}) { |fn, h|
91
+ name = File.basename(fn)
92
+ opts = @env.merge(location: fn)
93
+ h[name] = Syntropy::App.new(opts[:machine], opts[:location], opts[:mount_path], opts)
94
+ }
95
+
96
+ map&.each do |k, v|
97
+ sites[k] = sites[v]
98
+ end
99
+
100
+ lambda { |req|
101
+ site = sites[req.host]
102
+ site ? site.call(req) : req.respond(nil, ':status' => Status::BAD_REQUEST)
103
+ }
104
+ end
105
+
106
+ def page_list(ref)
107
+ full_path = File.join(@env[:location], ref)
108
+ raise 'Not a directory' if !File.directory?(full_path)
109
+
110
+ Dir[File.join(full_path, '*.md')].sort.map {
111
+ atts, markdown = Syntropy.parse_markdown_file(it, @env)
112
+ { atts:, markdown: }
113
+ }
114
+ end
83
115
  end
84
116
  end
85
117
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.6'
4
+ VERSION = '0.8'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -4,14 +4,15 @@ require 'qeweney'
4
4
  require 'uringmachine'
5
5
  require 'tp2'
6
6
 
7
- require 'syntropy/errors'
7
+ require 'syntropy/app'
8
8
  require 'syntropy/connection_pool'
9
+ require 'syntropy/errors'
10
+ require 'syntropy/markdown'
9
11
  require 'syntropy/module'
12
+ require 'syntropy/request_extensions'
13
+ require 'syntropy/router'
10
14
  require 'syntropy/rpc_api'
11
15
  require 'syntropy/side_run'
12
- require 'syntropy/router'
13
- require 'syntropy/app'
14
- require 'syntropy/request_extensions'
15
16
 
16
17
  module Syntropy
17
18
  Status = Qeweney::Status
@@ -34,7 +35,7 @@ module Syntropy
34
35
  CLEAR = "\e[0m"
35
36
  YELLOW = "\e[33m"
36
37
 
37
- BANNER = (
38
+ BANNER =
38
39
  "\n"\
39
40
  " #{GREEN}\n"\
40
41
  " #{GREEN} ooo\n"\
@@ -44,5 +45,4 @@ module Syntropy
44
45
  " #{GREEN} #{YELLOW}|#{GREEN} vvv o #{CLEAR}https://github.com/noteflakes/syntropy\n"\
45
46
  " #{GREEN} :#{YELLOW}|#{GREEN}:::#{YELLOW}|#{GREEN}::#{YELLOW}|#{GREEN}:\n"\
46
47
  "#{YELLOW}+++++++++++++++++++++++++++++++++++++++++++++++++++++++++\e[0m\n\n"
47
- )
48
48
  end
data/test/app/_hook.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  export ->(req, proc) {
2
2
  req.ctx[:foo] = req.query[:foo]
3
- # p proc: proc
4
3
  proc.(req)
5
4
  }
@@ -1,4 +1,4 @@
1
- ->(**props) {
1
+ export template { |**props|
2
2
  header {
3
3
  h1 'Foo'
4
4
  }
@@ -0,0 +1,5 @@
1
+ def foo
2
+ :bar
3
+ end
4
+
5
+ export self
data/test/app/api+.rb CHANGED
@@ -15,6 +15,10 @@ class API < Syntropy::RPCAPI
15
15
 
16
16
  @count += 1
17
17
  end
18
+
19
+ def req(req)
20
+ { query: req.query, headers: req.headers }
21
+ end
18
22
  end
19
23
 
20
24
  export API
@@ -0,0 +1 @@
1
+ export route_by_host
@@ -0,0 +1 @@
1
+ <h1>bar.baz</h1>
@@ -0,0 +1 @@
1
+ <h1>foo.bar</h1>
data/test/test_app.rb CHANGED
@@ -180,3 +180,36 @@ class CustomAppTest < Minitest::Test
180
180
  assert_equal Status::TEAPOT, req.response_status
181
181
  end
182
182
  end
183
+
184
+ class MultiSiteAppTest < Minitest::Test
185
+ Status = Qeweney::Status
186
+
187
+ APP_ROOT = File.join(__dir__, 'app_multi_site')
188
+
189
+ def setup
190
+ @machine = UM.new
191
+ @app = Syntropy::App.load(
192
+ machine: @machine,
193
+ location: APP_ROOT,
194
+ mount_path: '/'
195
+ )
196
+ end
197
+
198
+ def make_request(*, **)
199
+ req = mock_req(*, **)
200
+ @app.call(req)
201
+ req
202
+ end
203
+
204
+ def test_route_by_host
205
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'blah')
206
+ assert_nil req.response_body
207
+ assert_equal Status::BAD_REQUEST, req.response_status
208
+
209
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'foo.bar')
210
+ assert_equal '<h1>foo.bar</h1>', req.response_body.chomp
211
+
212
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'bar.baz')
213
+ assert_equal '<h1>bar.baz</h1>', req.response_body.chomp
214
+ end
215
+ end
data/test/test_module.rb CHANGED
@@ -29,4 +29,10 @@ class ModuleTest < Minitest::Test
29
29
  @env[:baz] += 1
30
30
  assert_equal 43, mod.bar
31
31
  end
32
+
33
+ def test_export_self
34
+ mod = @loader.load('_lib/self')
35
+ assert_kind_of Syntropy::Module, mod
36
+ assert_equal :bar, mod.foo
37
+ end
32
38
  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.6'
4
+ version: '0.8'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -195,6 +195,7 @@ files:
195
195
  - lib/syntropy/connection_pool.rb
196
196
  - lib/syntropy/errors.rb
197
197
  - lib/syntropy/file_watch.rb
198
+ - lib/syntropy/markdown.rb
198
199
  - lib/syntropy/module.rb
199
200
  - lib/syntropy/request_extensions.rb
200
201
  - lib/syntropy/router.rb
@@ -207,6 +208,7 @@ files:
207
208
  - test/app/_lib/callable.rb
208
209
  - test/app/_lib/klass.rb
209
210
  - test/app/_lib/missing-export.rb
211
+ - test/app/_lib/self.rb
210
212
  - test/app/about/_error.rb
211
213
  - test/app/about/foo.md
212
214
  - test/app/about/index.rb
@@ -218,6 +220,9 @@ files:
218
220
  - test/app/index.html
219
221
  - test/app/tmp.rb
220
222
  - test/app_custom/_site.rb
223
+ - test/app_multi_site/_site.rb
224
+ - test/app_multi_site/bar.baz/index.html
225
+ - test/app_multi_site/foo.bar/index.html
221
226
  - test/helper.rb
222
227
  - test/run.rb
223
228
  - test/test_app.rb