syntropy 0.38.0 → 0.39.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85989e43beae58a319d108e1c8cf42f8265fdfaa0675c0fa2b0b8c8df5dbfbd1
4
- data.tar.gz: cd2dd5db7add2b3e3aa74f2a3fa02da3c1896b808a46be16b088e264875aec10
3
+ metadata.gz: c6b3b46669d534dbaf6517708d7fef38fd44bf20bd99513e065d173ca231116d
4
+ data.tar.gz: 2aaa7078c2c81adae4bae1141130bfedffd08c2239ea492b2c0076ad40ae9dcf
5
5
  SHA512:
6
- metadata.gz: 3cd0fb5d085456bc8d51db049fecaf11f000071cde6b19dd224c7ec422e1fb749be3ba92dfd5b8a43e3e95313f99b1ffed5f926ca37abd5377e2badd249576f3
7
- data.tar.gz: c3a2b789fe2c9cfbff79b2245381f3ee76bad2e905e3f8408df2666af1495b51f93988d7085c1957330b194237f366e0999b43eb25b8e0e42954f07146aaa5cb
6
+ metadata.gz: '00499df3f4e992f461a36a33efb467d46d719e5b29c6756f0bdfc6a88dbf85355619fef1a61e39a96ec36d25b492c8a862db47b551f725ec0d841fc0ec66105a'
7
+ data.tar.gz: 7d8a2f86e69873779e03a9acc65fed022c05ecd543ce48a9e0fa77d3fadc774712fc2627c8a299a1ee764e3eb3800cf4cb31ccafd5e91e035bc37bda401e8c11
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ # 0.39.0 2026-06-21
2
+
3
+ - Add support for embedded Papercraft snippets in Markdown files
4
+ - Reimplement Markdown rendering
5
+ - Increase cache-control max-age to one week
6
+ - Add support for relative redirects, add `Request#rel`
7
+ - Add support for concurrent loading, add mutex lock to module loader
8
+ - Fix `Syntropy.load_config`
9
+ - Read module source file using UringMachine
10
+ - Raise on circular module dependency
11
+ - Streamline CLI options across commands
12
+
13
+ # 0.38.1 2026-06-13
14
+
15
+ - Fix builtin app, controller extensions
16
+
1
17
  # 0.38.0 2026-06-13
2
18
 
3
19
  - Reimplement controller extensions: `dispatch_by_host`,
data/README.md CHANGED
@@ -52,6 +52,8 @@ Syntropy is based on:
52
52
  - [Extralite](https://github.com/digital-fabric/extralite) a fast and innovative
53
53
  SQLite wrapper for Ruby.
54
54
 
55
+ Syntropy is 100% brain-coded.
56
+
55
57
  ## Examples
56
58
 
57
59
  To get a taste of some of Syntropy's capabilities, you can run the included
data/TODO.md CHANGED
@@ -1,8 +1,18 @@
1
1
  ## Immediate
2
2
 
3
+ - [ ] Ability to load modules from builtin applet
4
+ - [ ] Can we mount them on the app's module loader?
5
+
3
6
  - [ ] Controllers
4
- - [ ] Streamline names of ready-made control methods:
5
- - [ ] dispatch_json_rpc
7
+ - [ ] add dispatch_json_rpc, dispatch_json_api
8
+
9
+ - [ ] Pub/sub
10
+ - [ ] Ruby side
11
+ - [ ] JS side
12
+ - [ ] Reimplement `auto_refresh` using a *default* event bus provided by
13
+ Syntropy
14
+
15
+ ## Collections
6
16
 
7
17
  - [ ] Collection - treat directories and files as collections of data.
8
18
 
@@ -34,7 +44,6 @@
34
44
 
35
45
  - [ ] Improve serving of static files:
36
46
  - [ ] support for compression
37
- - [ ] support for caching headers
38
47
  - [ ] add `Request#render_static_file(route, fn)
39
48
 
40
49
  ## Missing for a first public release
data/cmd/console.rb CHANGED
@@ -17,11 +17,11 @@ env = {
17
17
  parser = OptionParser.new do |o|
18
18
  o.banner = 'Usage: syntropy console [options]'
19
19
 
20
- o.on('-a', '--app PATH', 'Set app directory (default: ./app') do |path|
20
+ o.on('-a', '--app PATH', 'Set app directory (default: ./app)') do |path|
21
21
  env[:app_root] = path
22
22
  end
23
23
 
24
- o.on('-c', '--config PATH', 'Set config directory (default: ./config') do |path|
24
+ o.on('-c', '--config PATH', 'Set config directory (default: ./config)') do |path|
25
25
  env[:config_root] = path
26
26
  end
27
27
 
data/cmd/serve.rb CHANGED
@@ -20,17 +20,16 @@ env = {
20
20
  parser = OptionParser.new do |o|
21
21
  o.banner = 'Usage: syntropy serve [options]'
22
22
 
23
- o.on('-a', '--app PATH', 'Set app directory (default: ./app') do |path|
23
+ o.on('-a', '--app PATH', 'Set app directory (default: ./app)') do |path|
24
24
  env[:app_root] = path
25
25
  end
26
26
 
27
- o.on('-b', '--bind BIND', String,
28
- 'Bind address (default: http://0.0.0.0:1234). You can specify this flag multiple times to bind to multiple addresses.') do
27
+ o.on('-b', '--bind ADDRESS', String, 'Bind address (default: 0.0.0.0:1234)') do
29
28
  env[:bind] ||= []
30
29
  env[:bind] << it
31
30
  end
32
31
 
33
- o.on('-c', '--config PATH', 'Set config directory (default: ./config') do |path|
32
+ o.on('-c', '--config PATH', 'Set config directory (default: ./config)') do |path|
34
33
  env[:config_root] = path
35
34
  end
36
35
 
data/cmd/test.rb CHANGED
@@ -16,11 +16,11 @@ MINITEST_ARGV = []
16
16
  parser = OptionParser.new do |o|
17
17
  o.banner = 'Usage: syntropy test [options]'
18
18
 
19
- o.on('-a', '--app PATH', 'Set app directory (default: ./app') do |path|
19
+ o.on('-a', '--app PATH', 'Set app directory (default: ./app)') do |path|
20
20
  env[:app_root] = path
21
21
  end
22
22
 
23
- o.on('-c', '--config PATH', 'Set config directory (default: ./config') do |path|
23
+ o.on('-c', '--config PATH', 'Set config directory (default: ./config)') do |path|
24
24
  env[:config_root] = path
25
25
  end
26
26
 
@@ -29,6 +29,10 @@ parser = OptionParser.new do |o|
29
29
  exit
30
30
  end
31
31
 
32
+ o.on('-f', '--file PATH', 'Set test file') do |path|
33
+ env[:test_file] = path
34
+ end
35
+
32
36
  o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
33
37
  env[:mount_path] = path
34
38
  env[:builtin_applet_path] = File.join(path, '.syntropy')
@@ -82,7 +86,11 @@ Syntropy.load_config(env)
82
86
  $stdout.sync = true
83
87
  $stderr.sync = true
84
88
 
85
- Dir.glob("#{File.expand_path(env[:test_root])}/test_*.rb").each { require(it) }
89
+ if env[:test_file]
90
+ require(File.expand_path(env[:test_file]))
91
+ else
92
+ Dir.glob("#{File.expand_path(env[:test_root])}/test_*.rb").each { require(it) }
93
+ end
86
94
 
87
95
  def restart_on_file_change(machine, dir, restart_argv)
88
96
  machine.file_watch(dir, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) {
@@ -92,7 +100,7 @@ def restart_on_file_change(machine, dir, restart_argv)
92
100
  exec('ruby', __FILE__, *restart_argv)
93
101
  end
94
102
 
95
- Syntropy::Test.env = (env)
103
+ Syntropy::Test.global_env = env
96
104
  Minitest.run MINITEST_ARGV
97
105
 
98
106
  if env[:watch_mode]
@@ -9,14 +9,14 @@ def get(req)
9
9
  raise Syntropy::Error.not_found if !post
10
10
 
11
11
  req.respond_html(
12
- @template.render(post:)
12
+ @template.render(post:, req:)
13
13
  )
14
14
  end
15
15
 
16
- @template = @layout.apply { |post:, **props|
16
+ @template = @layout.apply { |post:, req:, **props|
17
17
  h1 "Edit blog post"
18
18
  div {
19
- form(action: "/posts/#{post[:id]}", method: 'post') {
19
+ form(action: req.rel(".."), method: 'post') {
20
20
  div {
21
21
  label 'Title', for: 'title'
22
22
  input name: 'title', type: 'text', value: post[:title]
@@ -8,9 +8,7 @@ def get(req)
8
8
  post = @posts.get(id)
9
9
  raise Syntropy::Error.not_found if !post
10
10
 
11
- req.respond_html(
12
- @template.render(post:, req:)
13
- )
11
+ req.respond_html(@template.render(post:, req:))
14
12
  end
15
13
 
16
14
  def post(req)
@@ -32,15 +30,15 @@ def delete(req)
32
30
  id = req.route_params['id'].to_i
33
31
 
34
32
  deleted = @posts.delete(id)
35
- raise BadRequestError, "Failed to delete post" if deleted != 1
33
+ raise BadRequestError, 'Failed to delete post' if deleted != 1
36
34
 
37
35
  req.flash[:notice] = 'Post was successfully destroyed.'
38
- req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
36
+ req.redirect '..', Syntropy::HTTP::SEE_OTHER
39
37
  end
40
38
 
41
- @template = @layout.apply { |post:, **props|
42
- h1 "My blog"
43
- p props[:req]&.flash[:notice], style: 'color: green'
39
+ @template = @layout.apply { |post:, req:, **props|
40
+ h1 'My blog'
41
+ p req.flash[:notice], style: 'color: green'
44
42
  div {
45
43
  h2 {
46
44
  a post[:title]
@@ -48,9 +46,9 @@ end
48
46
  p post[:body]
49
47
  }
50
48
  p {
51
- a "Edit", href: "/posts/#{post[:id]}/edit"
49
+ a 'Edit', href: req.rel('./edit')
52
50
  span '|'
53
- a "Back to posts", href: '/posts'
51
+ a 'Back to posts', href: req.rel('..')
54
52
  }
55
53
  div {
56
54
  form(method: 'post') {
@@ -5,9 +5,7 @@ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  posts = @posts.get_all
8
- req.respond_html(
9
- @template.render(posts:, req:)
10
- )
8
+ req.respond_html(@template.render(posts:, req:))
11
9
  end
12
10
 
13
11
  def post(req)
@@ -17,16 +15,16 @@ def post(req)
17
15
  id = @posts.create(title, body)
18
16
 
19
17
  req.flash[:notice] = 'Post was successfully created.'
20
- req.redirect("posts/#{id}")
18
+ req.redirect("./#{id}")
21
19
  end
22
20
 
23
- @template = @layout.apply { |**props|
21
+ @template = @layout.apply { |req:, **props|
24
22
  h1 "My awesome blog"
25
- p props[:req]&.flash[:notice], style: 'color: green'
23
+ p req.flash[:notice], style: 'color: green'
26
24
  props[:posts].each { |post|
27
25
  div {
28
26
  h2 {
29
- a post[:title], href: "/posts/#{post[:id]}"
27
+ a post[:title], href: req.rel("./#{post[:id]}")
30
28
  }
31
29
  p post[:body]
32
30
  }
@@ -34,7 +32,7 @@ end
34
32
 
35
33
  div {
36
34
  p {
37
- a "New post", href: '/posts/new'
35
+ a "New post", href: req.rel('./new')
38
36
  }
39
37
  }
40
38
  }
@@ -5,14 +5,14 @@ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  req.respond_html(
8
- @template.render
8
+ @template.render(req:)
9
9
  )
10
10
  end
11
11
 
12
- @template = @layout.apply { |**props|
12
+ @template = @layout.apply { |req:, **props|
13
13
  h1 "Create blog post"
14
14
  div {
15
- form(action: "/posts", method: 'post') {
15
+ form(action: req.rel(".."), method: 'post') {
16
16
  div {
17
17
  label 'Title', for: 'title'
18
18
  input name: 'title', type: 'text'
data/lib/syntropy/app.rb CHANGED
@@ -25,6 +25,24 @@ module Syntropy
25
25
  site_file_app(env) || default_app(env)
26
26
  end
27
27
 
28
+ BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
29
+
30
+ # Creates a builtin applet with the given environment hash. By default the
31
+ # builtin applet is mounted at /.syntropy.
32
+ #
33
+ # @param env [Hash] app environment
34
+ # @param mount_path [String] mount path for the builtin applet
35
+ # @return [Syntropy::App] applet
36
+ def builtin_applet(env, mount_path: '/.syntropy')
37
+ new(
38
+ machine: env[:machine],
39
+ app_root: BUILTIN_APPLET_app_root,
40
+ mount_path: mount_path,
41
+ builtin_applet_path: nil,
42
+ watch_files: nil
43
+ )
44
+ end
45
+
28
46
  private
29
47
 
30
48
  # Creates a multi-hostname app if a _site.rb file is detected.
@@ -171,7 +189,7 @@ module Syntropy
171
189
  # @return [void]
172
190
  def mount_builtin_applet
173
191
  path = @env[:builtin_applet_path]
174
- @builtin_applet ||= Syntropy.builtin_applet(@env, mount_path: path)
192
+ @builtin_applet ||= App.builtin_applet(@env, mount_path: path)
175
193
  @routing_tree.mount_applet(path, @builtin_applet)
176
194
  end
177
195
 
@@ -233,6 +251,8 @@ module Syntropy
233
251
  }
234
252
  end
235
253
 
254
+ DEFAULT_CACHE_CONTROL = 'max-age=604800' # one week
255
+
236
256
  # Serves a static file from the given target hash with cache validation.
237
257
  #
238
258
  # @param req [Syntropy::Request] request
@@ -241,7 +261,7 @@ module Syntropy
241
261
  def serve_static_file(req, target)
242
262
  validate_static_file_info(target)
243
263
  cache_opts = {
244
- cache_control: 'max-age=3600',
264
+ cache_control: DEFAULT_CACHE_CONTROL,
245
265
  last_modified: target[:last_modified_date],
246
266
  etag: target[:etag]
247
267
  }
@@ -299,63 +319,9 @@ module Syntropy
299
319
  # @param route [Hash] route entry
300
320
  # @return [Proc] route proc
301
321
  def markdown_route_proc(route)
302
- headers = { 'Content-Type' => 'text/html' }
303
-
304
- ->(req) {
305
- req.respond_by_http_method(
306
- 'head' => [nil, headers],
307
- 'get' => -> { [render_markdown(route), headers] }
308
- )
309
- }
310
- end
311
-
312
- # Renders and returns the given markdown route as HTML.
313
- #
314
- # @param route [Hash] route entry
315
- # @return [String] rendered HTML
316
- def render_markdown(route)
317
- atts, md = Syntropy::Markdown.parse(route[:target][:fn], @env)
318
-
319
- layout = compute_markdown_layout(route, atts)
320
- Papercraft.html(layout, md:, **atts)
321
- end
322
-
323
- def compute_markdown_layout(route, atts)
324
- if (layout = atts[:layout])
325
- route[:applied_layouts] ||= {}
326
- route[:applied_layouts][layout] ||= markdown_layout_template(layout)
327
- else
328
- default_markdown_layout_template
329
- end
330
- end
331
-
332
- # Returns a markdown template based on the given layout.
333
- #
334
- # @param layout [String] layout name
335
- # @return [Proc] layout template
336
- def markdown_layout_template(layout)
337
- @layouts ||= {}
338
- template = @module_loader.load("_layout/#{layout}")
339
- @layouts[layout] = Papercraft.apply(template) { |md:, **| markdown(md) }
340
- end
341
-
342
- # Returns the default markdown layout, which renders to HTML and includes a
343
- # title, the markdown content, and emits code for auto refreshing the page
344
- # on file change.
345
- #
346
- # @return [Proc] default Markdown layout template
347
- def default_markdown_layout_template
348
- @default_markdown_layout ||= ->(md:, **atts) {
349
- html5 {
350
- head {
351
- title atts[:title]
352
- }
353
- body {
354
- markdown md
355
- auto_refresh! if Syntropy.dev_mode
356
- }
357
- }
358
- }
322
+ env = @env.merge(module_loader: @module_loader)
323
+ atts, md = Syntropy::Markdown.parse_file(route[:target][:fn], env)
324
+ Syntropy::Markdown.make_controller(env, atts, md)
359
325
  end
360
326
 
361
327
  # Returns the route proc for a module route.
@@ -46,15 +46,14 @@ module Syntropy
46
46
 
47
47
  # Returns a list of parsed markdown pages at the given path.
48
48
  #
49
- # @param env [Hash] app environment hash
50
49
  # @param ref [String] directory path
51
50
  # @return [Array<Hash>] array of page entries
52
- def page_list(env, ref)
53
- full_path = File.join(env[:app_root], ref)
51
+ def page_list(ref)
52
+ full_path = File.join(@env[:app_root], ref)
54
53
  raise 'Not a directory' if !File.directory?(full_path)
55
54
 
56
55
  Dir[File.join(full_path, '*.md')].sort.map {
57
- atts, markdown = Syntropy::Markdown.parse(it, env)
56
+ atts, markdown = Syntropy::Markdown.parse_file(it, @env)
58
57
  { atts:, markdown: }
59
58
  }
60
59
  end
@@ -66,24 +65,6 @@ module Syntropy
66
65
  Syntropy::App.new(**)
67
66
  end
68
67
 
69
- BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
70
-
71
- # Creates a builtin applet with the given environment hash. By default the
72
- # builtin applet is mounted at /.syntropy.
73
- #
74
- # @param env [Hash] app environment
75
- # @param mount_path [String] mount path for the builtin applet
76
- # @return [Syntropy::App] applet
77
- def builtin_applet(env, mount_path: '/.syntropy')
78
- app(
79
- machine: env[:machine],
80
- app_root: BUILTIN_APPLET_app_root,
81
- mount_path: mount_path,
82
- builtin_applet_path: nil,
83
- watch_files: nil
84
- )
85
- end
86
-
87
68
  private
88
69
 
89
70
  # Finds sites in the root directory for the given environment hash, adds
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'securerandom'
4
5
 
5
6
  module Syntropy
6
7
  # Markdown parsing.
@@ -11,30 +12,125 @@ module Syntropy
11
12
  symbolize_names: true
12
13
  }.freeze
13
14
 
15
+ class Controller
16
+ def initialize(env, atts, md)
17
+ @env = env
18
+ @atts = atts
19
+ @md = md
20
+ @module_loader = env[:module_loader]
21
+ end
22
+
23
+ def to_proc
24
+ ->(req) {
25
+ case req.method
26
+ when 'head'
27
+ req.respond_html(nil)
28
+ when 'get'
29
+ md = process_md_embeds
30
+ html = render(md)
31
+ req.respond_html(html)
32
+ else
33
+ req.respond(nil, ':status' => HTTP::METHOD_NOT_ALLOWED)
34
+ end
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def process_md_embeds
41
+ return @md if @embedded_templates&.empty?
42
+
43
+ @embedded_templates = {}
44
+ @md.gsub(/^```ruby\n# render: true\n(.*?)\r?\n```\n/m) {
45
+ snippet = Regexp.last_match[1]
46
+ templ = @embedded_templates[snippet] ||= prepare_snippet_template(snippet)
47
+ Papercraft.html(templ)
48
+ }
49
+ end
50
+
51
+ def prepare_snippet_template(snippet, location = nil)
52
+ fn = "/tmp/snippet-#{SecureRandom.hex(8)}.rb"
53
+ src = "->() do\n#{snippet}\nend"
54
+ IO.write(fn, src)
55
+ instance_eval src, fn
56
+ end
57
+
58
+ def render(md)
59
+ @template ||= make_template
60
+ Papercraft.html(@template, md: md, **@atts)
61
+ end
62
+
63
+ def make_template
64
+ layout = make_layout
65
+ Papercraft.apply(layout) { |md:, **| markdown(md) }
66
+ end
67
+
68
+ def make_layout
69
+ return default_layout if !@atts[:layout]
70
+ raise Error, 'Missing module loader' if !@module_loader
71
+
72
+ @module_loader.load("_layout/#{@atts[:layout]}")
73
+ end
74
+
75
+ def default_layout
76
+ ->(**atts) {
77
+ html5 {
78
+ head {
79
+ title atts[:title] if atts[:title]
80
+ }
81
+ body {
82
+ render_children(**atts)
83
+ auto_refresh!
84
+ }
85
+ }
86
+ }
87
+ end
88
+ end
89
+
14
90
  class << self
15
91
  # Parses the markdown file at the given path.
16
92
  #
17
93
  # @param path [String] file path
18
94
  # @return [Array] an tuple containing properties<Hash>, contents<String>
19
- def parse(path, env)
20
- content = IO.read(path) || ''
95
+ def parse_file(path, env)
96
+ md = IO.read(path) || ''
21
97
  atts = {}
22
-
23
- parse_date(path, atts)
24
- content = parse_content(content, atts)
25
98
  atts[:url] = path_to_url(path, env[:app_root]) if env[:app_root]
99
+ parse_date(atts, path)
100
+
101
+ parse_md(atts, md)
102
+ end
103
+
104
+ def parse_md(atts, md)
105
+ html = parse_content(atts, md)
106
+ [atts, html]
107
+ end
26
108
 
27
- [atts, content]
109
+ def make_controller(env, atts, md)
110
+ Controller.new(env, atts, md).to_proc
111
+ # layout = setup_layout_template(env, atts)
112
+
113
+ # ->(req) {
114
+ # case req.method
115
+ # when 'head'
116
+ # req.respond_html(nil)
117
+ # when 'get'
118
+ # html = render_md(env, atts, md)
119
+ # req.respond_html(html)
120
+ # else
121
+ # req.respond(nil, ':status' => HTTP::METHOD_NOT_ALLOWED)
122
+ # end
123
+ # }
28
124
  end
29
125
 
30
126
  private
31
127
 
32
128
  # Parses date information from the given path.
33
129
  #
34
- # @param path [String] file path
35
130
  # @param atts [Hash] file attributes
131
+ # @param path [String] file path
36
132
  # @return [void]
37
- def parse_date(path, atts)
133
+ def parse_date(atts, path)
38
134
  # Parse date from file name
39
135
  if (m = path.match(/(\d{4}-\d{2}-\d{2})/))
40
136
  atts[:date] ||= Date.parse(m[1])
@@ -43,10 +139,10 @@ module Syntropy
43
139
 
44
140
  # Parses the markdown content and front matter attributes from the given content.
45
141
  #
46
- # @param content [String] file content
47
142
  # @param atts [Hash] file attributes
143
+ # @param content [String] file content
48
144
  # @return [String] parsed markdown content
49
- def parse_content(content, atts)
145
+ def parse_content(atts, content)
50
146
  if (m = content.match(FRONT_MATTER_REGEXP))
51
147
  front_matter = m[1]
52
148
  content = m.post_match
@@ -63,7 +159,15 @@ module Syntropy
63
159
  # @param app_root [String] app root directory
64
160
  # @return [String] url
65
161
  def path_to_url(path, app_root)
66
- path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
162
+ if app_root == '/'
163
+ path.gsub(/\.md$/, '')
164
+ else
165
+ path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
166
+ end
167
+ end
168
+
169
+ def render_md(env, atts, md)
170
+
67
171
  end
68
172
  end
69
173
  end