syntropy 0.3 → 0.5

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +30 -11
  5. data/TODO.md +135 -0
  6. data/bin/syntropy +3 -3
  7. data/cmd/setup/template/site/.gitignore +57 -0
  8. data/cmd/setup/template/site/Dockerfile +32 -0
  9. data/cmd/setup/template/site/Gemfile +3 -0
  10. data/cmd/setup/template/site/README.md +0 -0
  11. data/cmd/setup/template/site/bin/console +0 -0
  12. data/cmd/setup/template/site/bin/restart +0 -0
  13. data/cmd/setup/template/site/bin/server +0 -0
  14. data/cmd/setup/template/site/bin/start +0 -0
  15. data/cmd/setup/template/site/bin/stop +0 -0
  16. data/cmd/setup/template/site/docker-compose.yml +51 -0
  17. data/cmd/setup/template/site/proxy/Dockerfile +5 -0
  18. data/cmd/setup/template/site/proxy/etc/Caddyfile +7 -0
  19. data/cmd/setup/template/site/proxy/etc/tls_auto +2 -0
  20. data/cmd/setup/template/site/proxy/etc/tls_cloudflare +4 -0
  21. data/cmd/setup/template/site/proxy/etc/tls_custom +1 -0
  22. data/cmd/setup/template/site/proxy/etc/tls_selfsigned +1 -0
  23. data/cmd/setup/template/site/site/_layout/default.rb +11 -0
  24. data/cmd/setup/template/site/site/about.md +6 -0
  25. data/cmd/setup/template/site/site/articles/cage.rb +29 -0
  26. data/cmd/setup/template/site/site/articles/index.rb +3 -0
  27. data/cmd/setup/template/site/site/assets/css/style.css +40 -0
  28. data/cmd/setup/template/site/site/assets/img/syntropy.png +0 -0
  29. data/cmd/setup/template/site/site/index.rb +15 -0
  30. data/docker-compose.yml +51 -0
  31. data/lib/syntropy/app.rb +112 -134
  32. data/lib/syntropy/errors.rb +16 -2
  33. data/lib/syntropy/file_watch.rb +5 -4
  34. data/lib/syntropy/module.rb +26 -5
  35. data/lib/syntropy/request_extensions.rb +96 -0
  36. data/lib/syntropy/router.rb +208 -0
  37. data/lib/syntropy/rpc_api.rb +26 -9
  38. data/lib/syntropy/side_run.rb +46 -0
  39. data/lib/syntropy/version.rb +1 -1
  40. data/lib/syntropy.rb +15 -49
  41. data/syntropy.gemspec +1 -1
  42. data/test/app/baz.rb +3 -0
  43. data/test/app_custom/_site.rb +3 -0
  44. data/test/test_app.rb +96 -51
  45. data/test/test_file_watch.rb +4 -4
  46. data/test/test_router.rb +90 -0
  47. data/test/test_side_run.rb +43 -0
  48. data/test/test_validation.rb +1 -1
  49. metadata +34 -3
data/lib/syntropy/app.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'qeweney'
4
3
  require 'json'
4
+ require 'yaml'
5
+
6
+ require 'qeweney'
5
7
  require 'papercraft'
6
8
 
7
9
  require 'syntropy/errors'
@@ -10,39 +12,50 @@ require 'syntropy/module'
10
12
 
11
13
  module Syntropy
12
14
  class App
13
- attr_reader :route_cache
15
+ class << self
16
+ def load(opts)
17
+ site_file_app(opts) || default_app(opts)
18
+ end
19
+
20
+ private
21
+
22
+ def site_file_app(opts)
23
+ site_fn = File.join(opts[:location], '_site.rb')
24
+ return nil if !File.file?(site_fn)
25
+
26
+ loader = Syntropy::ModuleLoader.new(opts[:location], opts)
27
+ loader.load('_site')
28
+ end
29
+
30
+ def default_app(opts)
31
+ new(opts[:machine], opts[:location], opts[:mount_path] || '/', opts)
32
+ end
33
+ end
14
34
 
15
35
  def initialize(machine, src_path, mount_path, opts = {})
16
36
  @machine = machine
17
37
  @src_path = File.expand_path(src_path)
18
38
  @mount_path = mount_path
19
- @route_cache = {}
20
39
  @opts = opts
21
40
 
22
- @relative_path_re = calculate_relative_path_re(mount_path)
41
+ @module_loader = Syntropy::ModuleLoader.new(@src_path, @opts)
42
+ @router = Syntropy::Router.new(@opts, @module_loader)
43
+
23
44
  @machine.spin do
24
45
  # we do startup stuff asynchronously, in order to first let TP2 do its
25
46
  # setup tasks
26
- @machine.sleep 0.25
47
+ @machine.sleep 0.15
27
48
  @opts[:logger]&.call("Serving from #{File.expand_path(@src_path)}")
28
- start_file_watcher if opts[:watch_files]
49
+ @router.start_file_watcher if opts[:watch_files]
29
50
  end
30
51
  end
31
52
 
32
- def find_route(path, cache: true)
33
- cached = @route_cache[path]
34
- return cached if cached
35
-
36
- entry = calculate_route(path)
37
- if entry[:kind] != :not_found
38
- @route_cache[path] = entry if cache
39
- end
40
- entry
41
- end
42
-
43
53
  def call(req)
44
- entry = find_route(req.path)
54
+ entry = @router[req.path]
45
55
  render_entry(req, entry)
56
+ rescue Syntropy::Error => e
57
+ msg = e.message
58
+ req.respond(msg.empty? ? nil : msg, ':status' => e.http_status)
46
59
  rescue StandardError => e
47
60
  p e
48
61
  p e.backtrace
@@ -51,132 +64,59 @@ module Syntropy
51
64
 
52
65
  private
53
66
 
54
- def start_file_watcher
55
- @opts[:logger]&.call('Watching for module file changes...', nil)
56
- wf = @opts[:watch_files]
57
- period = wf.is_a?(Numeric) ? wf : 0.1
58
- @machine.spin do
59
- Syntropy.file_watch(@machine, @src_path, period: period) do
60
- @opts[:logger]&.call("Detected changed file: #{it}")
61
- invalidate_cache(it)
62
- rescue Exception => e
63
- p e
64
- p e.backtrace
65
- exit!
66
- end
67
- end
68
- end
69
-
70
- def invalidate_cache(fn)
71
- invalidated_keys = []
72
- @route_cache.each do |k, v|
73
- @opts[:logger]&.call("Invalidate cache for #{k}", nil)
74
- invalidated_keys << k if v[:fn] == fn
75
- end
76
-
77
- invalidated_keys.each { @route_cache.delete(it) }
78
- end
79
-
80
- def calculate_relative_path_re(mount_path)
81
- mount_path = '' if mount_path == '/'
82
- /^#{mount_path}(?:\/(.*))?$/
83
- end
84
-
85
- FILE_KINDS = {
86
- '.rb' => :module,
87
- '.md' => :markdown
88
- }
89
- NOT_FOUND = { kind: :not_found }
90
-
91
- # We don't allow access to path with /.., or entries that start with _
92
- FORBIDDEN_RE = /(\/_)|((\/\.\.)\/?)/
93
-
94
- def calculate_route(path)
95
- return NOT_FOUND if path =~ FORBIDDEN_RE
96
-
97
- m = path.match(@relative_path_re)
98
- return NOT_FOUND if !m
99
-
100
- relative_path = m[1] || ''
101
- fs_path = File.join(@src_path, relative_path)
102
-
103
- return file_entry(fs_path) if File.file?(fs_path)
104
- return find_index_entry(fs_path) if File.directory?(fs_path)
105
-
106
- entry = find_file_entry_with_extension(fs_path)
107
- return entry if entry[:kind] != :not_found
108
-
109
- find_up_tree_module(path)
110
- end
111
-
112
- def file_entry(fn)
113
- { fn: File.expand_path(fn), kind: FILE_KINDS[File.extname(fn)] || :static }
114
- end
115
-
116
- def find_index_entry(dir)
117
- find_file_entry_with_extension(File.join(dir, 'index'))
118
- end
119
-
120
- def find_file_entry_with_extension(path)
121
- fn = "#{path}.html"
122
- return file_entry(fn) if File.file?(fn)
123
-
124
- fn = "#{path}.md"
125
- return file_entry(fn) if File.file?(fn)
126
-
127
- fn = "#{path}.rb"
128
- return file_entry(fn) if File.file?(fn)
129
-
130
- fn = "#{path}+.rb"
131
- return file_entry(fn) if File.file?(fn)
132
-
133
- NOT_FOUND
134
- end
135
-
136
- def find_up_tree_module(path)
137
- parent = parent_path(path)
138
- return NOT_FOUND if !parent
139
-
140
- entry = find_route("#{parent}+.rb", cache: false)
141
- entry[:kind] == :module ? entry : NOT_FOUND
142
- end
143
-
144
- UP_TREE_PATH_RE = /^(.+)?\/[^\/]+$/
145
-
146
- def parent_path(path)
147
- m = path.match(UP_TREE_PATH_RE)
148
- m && m[1]
149
- end
150
-
151
67
  def render_entry(req, entry)
152
68
  case entry[:kind]
153
69
  when :not_found
154
- req.respond('Not found', ':status' => Qeweney::Status::NOT_FOUND)
70
+ respond_not_found(req, entry)
155
71
  when :static
156
72
  respond_static(req, entry)
157
73
  when :markdown
158
- body = render_markdown(IO.read(entry[:fn]))
159
- req.respond(body, 'Content-Type' => 'text/html')
74
+ respond_markdown(req, entry)
160
75
  when :module
161
- call_module(req, entry)
76
+ respond_module(req, entry)
162
77
  else
163
78
  raise 'Invalid entry kind'
164
79
  end
165
80
  end
166
81
 
82
+ def respond_not_found(req, _entry)
83
+ headers = { ':status' => Qeweney::Status::NOT_FOUND }
84
+ case req.method
85
+ when 'head'
86
+ req.respond(nil, headers)
87
+ else
88
+ req.respond('Not found', headers)
89
+ end
90
+ end
91
+
167
92
  def respond_static(req, entry)
168
93
  entry[:mime_type] ||= Qeweney::MimeTypes[File.extname(entry[:fn])]
169
- req.respond(IO.read(entry[:fn]), 'Content-Type' => entry[:mime_type])
94
+ headers = { 'Content-Type' => entry[:mime_type] }
95
+ req.respond_by_http_method(
96
+ 'head' => [nil, headers],
97
+ 'get' => -> { [IO.read(entry[:fn]), headers] }
98
+ )
99
+ end
100
+
101
+ def respond_markdown(req, entry)
102
+ entry[:mime_type] ||= Qeweney::MimeTypes[File.extname(entry[:fn])]
103
+ headers = { 'Content-Type' => entry[:mime_type] }
104
+ req.respond_by_http_method(
105
+ 'head' => [nil, headers],
106
+ 'get' => -> { [render_markdown(entry[:fn]), headers] }
107
+ )
170
108
  end
171
109
 
172
- def call_module(req, entry)
173
- entry[:code] ||= load_module(entry)
174
- if entry[:code] == :invalid
110
+ def respond_module(req, entry)
111
+ entry[:proc] ||= load_module(entry)
112
+ if entry[:proc] == :invalid
175
113
  req.respond(nil, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
176
114
  return
177
115
  end
178
116
 
179
- entry[:code].call(req)
117
+ entry[:proc].call(req)
118
+ rescue Syntropy::Error => e
119
+ req.respond(nil, ':status' => e.http_status)
180
120
  rescue StandardError => e
181
121
  p e
182
122
  p e.backtrace
@@ -184,24 +124,62 @@ module Syntropy
184
124
  end
185
125
 
186
126
  def load_module(entry)
187
- loader = Syntropy::ModuleLoader.new(@src_path, @opts)
188
- ref = entry[:fn].gsub(%r{^#{@src_path}\/}, '').gsub(/\.rb$/, '')
189
- o = loader.load(ref)
190
- # klass = Class.new
191
- # o = klass.instance_eval(body, entry[:fn], 1)
192
-
193
- o.is_a?(Papercraft::HTML) ? wrap_template(o) : o
127
+ ref = entry[:fn].gsub(%r{^#{@src_path}/}, '').gsub(/\.rb$/, '')
128
+ o = @module_loader.load(ref)
129
+ o.is_a?(Papercraft::Template) ? wrap_template(o) : o
130
+ rescue Exception => e
131
+ @opts[:logger]&.call("Error while loading module #{ref}: #{e.message}")
132
+ :invalid
194
133
  end
195
134
 
196
135
  def wrap_template(templ)
197
- ->(req) {
136
+ lambda { |req|
198
137
  body = templ.render
199
138
  req.respond(body, 'Content-Type' => 'text/html')
200
139
  }
201
140
  end
202
141
 
203
- def render_markdown(str)
204
- Papercraft.markdown(str)
142
+ def render_markdown(fn)
143
+ atts, md = parse_markdown_file(fn)
144
+
145
+ if atts[:layout]
146
+ layout = @module_loader.load("_layout/#{atts[:layout]}")
147
+ html = layout.apply { emit_markdown(md) }.render
148
+ else
149
+ html = Papercraft.markdown(md)
150
+ end
151
+ html
152
+ end
153
+
154
+ DATE_REGEXP = /(\d{4}\-\d{2}\-\d{2})/
155
+ FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
156
+ YAML_OPTS = {
157
+ permitted_classes: [Date],
158
+ symbolize_names: true
159
+ }
160
+
161
+ # Parses the markdown file at the given path.
162
+ #
163
+ # @param path [String] file path
164
+ # @return [Array] an tuple containing properties<Hash>, contents<String>
165
+ def parse_markdown_file(path)
166
+ content = IO.read(path) || ''
167
+ atts = {}
168
+
169
+ # Parse date from file name
170
+ if (m = path.match(DATE_REGEXP))
171
+ atts[:date] ||= Date.parse(m[1])
172
+ end
173
+
174
+ if (m = content.match(FRONT_MATTER_REGEXP))
175
+ front_matter = m[1]
176
+ content = m.post_match
177
+
178
+ yaml = YAML.safe_load(front_matter, **YAML_OPTS)
179
+ atts = atts.merge(yaml)
180
+ end
181
+
182
+ [atts, content]
205
183
  end
206
184
  end
207
185
  end
@@ -4,17 +4,31 @@ require 'qeweney'
4
4
 
5
5
  module Syntropy
6
6
  class Error < StandardError
7
+ Status = Qeweney::Status
8
+
7
9
  attr_reader :http_status
8
10
 
9
11
  def initialize(status, msg = '')
10
- @http_status = status || Qeweney::Status::INTERNAL_SERVER_ERROR
11
12
  super(msg)
13
+ @http_status = status || Qeweney::Status::INTERNAL_SERVER_ERROR
14
+ end
15
+
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
+ }
12
26
  end
13
27
  end
14
28
 
15
29
  class ValidationError < Error
16
30
  def initialize(msg)
17
- @http_status = Qeweney::Status::BAD_REQUEST
31
+ super(Qeweney::Status::BAD_REQUEST, msg)
18
32
  end
19
33
  end
20
34
  end
@@ -8,15 +8,16 @@ module Syntropy
8
8
 
9
9
  queue = Thread::Queue.new
10
10
  listener = Listen.to(*roots) do |modified, added, removed|
11
- fns = (modified + added + removed).uniq
12
- fns.each { queue.push(it) }
11
+ modified.each { queue.push([:modified, it]) }
12
+ added.each { queue.push([:added, it]) }
13
+ removed.each { queue.push([:removed, it]) }
13
14
  end
14
15
  listener.start
15
16
 
16
17
  loop do
17
18
  machine.sleep(period) while queue.empty?
18
- fn = queue.shift
19
- block.call(fn)
19
+ event, fn = queue.shift
20
+ block.call(event, fn)
20
21
  end
21
22
  rescue StandardError => e
22
23
  p e
@@ -1,37 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'papercraft'
4
+
3
5
  module Syntropy
4
6
  class ModuleLoader
5
7
  def initialize(root, env)
6
8
  @root = root
7
9
  @env = env
8
- @loaded = {}
10
+ @loaded = {} # maps ref to code
11
+ @fn_map = {} # maps filename to ref
9
12
  end
10
13
 
11
14
  def load(ref)
12
15
  @loaded[ref] ||= load_module(ref)
13
16
  end
14
17
 
18
+ def invalidate(fn)
19
+ ref = @fn_map[fn]
20
+ return if !ref
21
+
22
+ @loaded.delete(ref)
23
+ @fn_map.delete(fn)
24
+ end
25
+
15
26
  private
16
27
 
17
28
  def load_module(ref)
18
- fn = File.join(@root, "#{ref}.rb")
19
- raise RuntimeError, "File not found #{fn}" if !File.file?(fn)
29
+ fn = File.expand_path(File.join(@root, "#{ref}.rb"))
30
+ @fn_map[fn] = ref
31
+ raise "File not found #{fn}" if !File.file?(fn)
20
32
 
21
33
  mod_body = IO.read(fn)
22
34
  mod_ctx = Class.new(Syntropy::Module)
23
35
  mod_ctx.loader = self
24
- # mod_ctx = .new(self, @env)
25
36
  mod_ctx.module_eval(mod_body, fn, 1)
26
37
 
27
38
  export_value = mod_ctx.__export_value__
28
39
 
40
+ wrap_module(mod_ctx, export_value)
41
+ end
42
+
43
+ def wrap_module(mod_ctx, export_value)
29
44
  case export_value
30
45
  when nil
31
- raise RuntimeError, 'No export found'
46
+ raise 'No export found'
32
47
  when Symbol
33
48
  # TODO: verify export_value denotes a valid method
34
49
  mod_ctx.new(@env)
50
+ when String
51
+ ->(req) { req.respond(export_value) }
35
52
  when Proc
36
53
  export_value
37
54
  else
@@ -57,6 +74,10 @@ module Syntropy
57
74
  @__export_value__ = ref
58
75
  end
59
76
 
77
+ def self.templ(&block)
78
+ Papercraft.html(&block)
79
+ end
80
+
60
81
  def self.__export_value__
61
82
  @__export_value__
62
83
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'qeweney'
4
+
5
+ module Syntropy
6
+ module RequestExtensions
7
+ def ctx
8
+ @ctx ||= {}
9
+ end
10
+
11
+ def validate_http_method(*accepted)
12
+ raise Syntropy::Error.method_not_allowed if !accepted.include?(method)
13
+ end
14
+
15
+ def respond_by_http_method(map)
16
+ value = map[self.method]
17
+ raise Syntropy::Error.method_not_allowed if !value
18
+
19
+ value = value.() if value.is_a?(Proc)
20
+ (body, headers) = value
21
+ respond(body, headers)
22
+ end
23
+
24
+ def respond_on_get(body, headers = {})
25
+ case self.method
26
+ when 'head'
27
+ respond(nil, headers)
28
+ when 'get'
29
+ respond(body, headers)
30
+ else
31
+ raise Syntropy::Error.method_not_allowed
32
+ end
33
+ end
34
+
35
+ def respond_on_post(body, headers = {})
36
+ case self.method
37
+ when 'head'
38
+ respond(nil, headers)
39
+ when 'post'
40
+ respond(body, headers)
41
+ else
42
+ raise Syntropy::Error.method_not_allowed
43
+ end
44
+ end
45
+
46
+ def validate_param(name, *clauses)
47
+ value = query[name]
48
+ clauses.each do |c|
49
+ valid = param_is_valid?(value, c)
50
+ raise(Syntropy::ValidationError, 'Validation error') if !valid
51
+
52
+ value = param_convert(value, c)
53
+ end
54
+ value
55
+ end
56
+
57
+ private
58
+
59
+ BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
60
+ BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
61
+ INTEGER_REGEXP = /^[+-]?[0-9]+$/
62
+ FLOAT_REGEXP = /^[+-]?[0-9]+(\.[0-9]+)?$/
63
+
64
+ def param_is_valid?(value, cond)
65
+ return cond.any? { |c| param_is_valid?(value, c) } if cond.is_a?(Array)
66
+
67
+ if value
68
+ if cond == :bool
69
+ return value =~ BOOL_REGEXP
70
+ elsif cond == Integer
71
+ return value =~ INTEGER_REGEXP
72
+ elsif cond == Float
73
+ return value =~ FLOAT_REGEXP
74
+ end
75
+ end
76
+
77
+ cond === value
78
+ end
79
+
80
+ def param_convert(value, klass)
81
+ if klass == :bool
82
+ value =~ BOOL_TRUE_REGEXP ? true : false
83
+ elsif klass == Integer
84
+ value.to_i
85
+ elsif klass == Float
86
+ value.to_f
87
+ elsif klass == Symbol
88
+ value.to_sym
89
+ else
90
+ value
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ Qeweney::Request.include(Syntropy::RequestExtensions)