syntropy 0.38.1 → 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: 1ec61f53807ee4049d4c5e04ae42f66f3b4824733a04cad245d150a0c72f0acc
4
- data.tar.gz: ee5b563302d15d6a8d58fc7460636b5f402f726f730c42799862ceb92cafa42f
3
+ metadata.gz: c6b3b46669d534dbaf6517708d7fef38fd44bf20bd99513e065d173ca231116d
4
+ data.tar.gz: 2aaa7078c2c81adae4bae1141130bfedffd08c2239ea492b2c0076ad40ae9dcf
5
5
  SHA512:
6
- metadata.gz: 30d9fcc1b9d0db54e4faa8fa009e547d69e8b6b1beeb3684995b9c27c8f22ebffdffc5c3ec0000495aac235d21b6d61b8cb57a2e305941fc3ec00276c12d283c
7
- data.tar.gz: 8453a4dfc06cba6e3cd741fb1260618af3825366cc937256cb0d2614c6dc520d5da983309c47010e493acbbae66d8a4cc50e898d3cc6723d9189364914c6579a
6
+ metadata.gz: '00499df3f4e992f461a36a33efb467d46d719e5b29c6756f0bdfc6a88dbf85355619fef1a61e39a96ec36d25b492c8a862db47b551f725ec0d841fc0ec66105a'
7
+ data.tar.gz: 7d8a2f86e69873779e03a9acc65fed022c05ecd543ce48a9e0fa77d3fadc774712fc2627c8a299a1ee764e3eb3800cf4cb31ccafd5e91e035bc37bda401e8c11
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
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
+
1
13
  # 0.38.1 2026-06-13
2
14
 
3
15
  - Fix builtin app, controller extensions
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
@@ -251,6 +251,8 @@ module Syntropy
251
251
  }
252
252
  end
253
253
 
254
+ DEFAULT_CACHE_CONTROL = 'max-age=604800' # one week
255
+
254
256
  # Serves a static file from the given target hash with cache validation.
255
257
  #
256
258
  # @param req [Syntropy::Request] request
@@ -259,7 +261,7 @@ module Syntropy
259
261
  def serve_static_file(req, target)
260
262
  validate_static_file_info(target)
261
263
  cache_opts = {
262
- cache_control: 'max-age=3600',
264
+ cache_control: DEFAULT_CACHE_CONTROL,
263
265
  last_modified: target[:last_modified_date],
264
266
  etag: target[:etag]
265
267
  }
@@ -317,63 +319,9 @@ module Syntropy
317
319
  # @param route [Hash] route entry
318
320
  # @return [Proc] route proc
319
321
  def markdown_route_proc(route)
320
- headers = { 'Content-Type' => 'text/html' }
321
-
322
- ->(req) {
323
- req.respond_by_http_method(
324
- 'head' => [nil, headers],
325
- 'get' => -> { [render_markdown(route), headers] }
326
- )
327
- }
328
- end
329
-
330
- # Renders and returns the given markdown route as HTML.
331
- #
332
- # @param route [Hash] route entry
333
- # @return [String] rendered HTML
334
- def render_markdown(route)
335
- atts, md = Syntropy::Markdown.parse(route[:target][:fn], @env)
336
-
337
- layout = compute_markdown_layout(route, atts)
338
- Papercraft.html(layout, md:, **atts)
339
- end
340
-
341
- def compute_markdown_layout(route, atts)
342
- if (layout = atts[:layout])
343
- route[:applied_layouts] ||= {}
344
- route[:applied_layouts][layout] ||= markdown_layout_template(layout)
345
- else
346
- default_markdown_layout_template
347
- end
348
- end
349
-
350
- # Returns a markdown template based on the given layout.
351
- #
352
- # @param layout [String] layout name
353
- # @return [Proc] layout template
354
- def markdown_layout_template(layout)
355
- @layouts ||= {}
356
- template = @module_loader.load("_layout/#{layout}")
357
- @layouts[layout] = Papercraft.apply(template) { |md:, **| markdown(md) }
358
- end
359
-
360
- # Returns the default markdown layout, which renders to HTML and includes a
361
- # title, the markdown content, and emits code for auto refreshing the page
362
- # on file change.
363
- #
364
- # @return [Proc] default Markdown layout template
365
- def default_markdown_layout_template
366
- @default_markdown_layout ||= ->(md:, **atts) {
367
- html5 {
368
- head {
369
- title atts[:title]
370
- }
371
- body {
372
- markdown md
373
- auto_refresh! if Syntropy.dev_mode
374
- }
375
- }
376
- }
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)
377
325
  end
378
326
 
379
327
  # Returns the route proc for a module route.
@@ -53,7 +53,7 @@ module Syntropy
53
53
  raise 'Not a directory' if !File.directory?(full_path)
54
54
 
55
55
  Dir[File.join(full_path, '*.md')].sort.map {
56
- atts, markdown = Syntropy::Markdown.parse(it, @env)
56
+ atts, markdown = Syntropy::Markdown.parse_file(it, @env)
57
57
  { atts:, markdown: }
58
58
  }
59
59
  end
@@ -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