nyth 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 737a2d888ab620a466bfb28d973862252f6cec45eb558f6447ea115279b9c7d4
4
+ data.tar.gz: e57ad56ea2addba0ac45731733b979b7980635a5e9eb083de8ad5636a693aa00
5
+ SHA512:
6
+ metadata.gz: ca3ae7088f285599be22d8cf53b81671699cb4036b0998aef5f7baf1efb139fa5337641b67861c0dc03c4655d20823cc35b825897a45654433304b36f4ccc0aa
7
+ data.tar.gz: 99424811720ed203a16022f752f75b411fdbe6da6851728b5f853a47f94b8940010e2ddad54c7ebe22c10dd779d62200176bdc4e7d660526a1e724374fa6ce41
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Nyth 🪺
2
+
3
+ Pronounced **neeth**, like the Welsh word for nest.
4
+
5
+ Nyth is a tiny Rack renderer for file-backed ERB pages and partials. No structure, just build it: put files in `app`, put Ruby in `lib`, refresh the page.
6
+
7
+ ## Usage
8
+
9
+ ```ruby
10
+ run Nyth.app(root: __dir__, reload: :changed, reload_check_interval: 5.0)
11
+ ```
12
+
13
+ Requests map directly to files in `app`:
14
+
15
+ - `/` renders `app/index.erb`.
16
+ - `/about.erb` renders `app/about.erb`.
17
+ - `/assets/style.css` serves `app/assets/style.css`.
18
+
19
+ ERB files can render partials from `app`:
20
+
21
+ ```erb
22
+ <%= render "_nav.erb" %>
23
+ <%= render "_page_title.erb", heading: "About" %>
24
+ ```
25
+
26
+ Files beginning with `_` are render-only partials and return `404` when requested directly.
27
+
28
+ Ruby constants under `lib` are loaded with Zeitwerk. By default, `reload: :changed` checks Ruby file mtimes under `lib` at most once every 5 seconds and reloads constants only after a file changes. The nest stays warm without rebuilding it on every request.
29
+
30
+ Reload modes:
31
+
32
+ - `:changed` reloads `lib` only when Ruby files change.
33
+ - `:always` reloads `lib` on every request.
34
+ - `false` loads `lib` once at boot.
35
+
36
+ Set `reload_check_interval:` to control how often `:changed` scans `lib`.
37
+
38
+ ## Tests
39
+
40
+ ```sh
41
+ bundle exec rake
42
+ ```
@@ -0,0 +1,121 @@
1
+ require "erb"
2
+ require "pathname"
3
+ require "rack/mime"
4
+ require "rack/request"
5
+ require "rack/response"
6
+ require "time"
7
+
8
+ module Nyth
9
+ class Application
10
+ def initialize(root:, app_path: "app")
11
+ @root = Pathname(root).expand_path
12
+ @content_root = @root.join(app_path)
13
+ end
14
+
15
+ def call(env)
16
+ request = Rack::Request.new(env)
17
+ file = file_for(request.path_info)
18
+ return not_found unless file&.file?
19
+ return not_found if partial?(file)
20
+
21
+ file.extname == ".erb" ? html(evaluate_template(file)) : static(file, request)
22
+ rescue Errno::ENOENT
23
+ not_found
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :content_root
29
+
30
+ def file_for(path)
31
+ resolve_app_path(path == "/" ? "/index.erb" : path)
32
+ end
33
+
34
+ def resolve_app_path(path)
35
+ relative_path = path.delete_prefix("/")
36
+ return if relative_path.empty?
37
+
38
+ file = content_root.join(relative_path).expand_path
39
+ file if file.to_s.start_with?(content_root.to_s + File::SEPARATOR)
40
+ end
41
+
42
+ def partial?(file)
43
+ file.basename.to_s.start_with?("_")
44
+ end
45
+
46
+ def render(path, locals = {})
47
+ file = resolve_app_path(path)
48
+ raise Errno::ENOENT, path unless file&.file?
49
+
50
+ evaluate_template(file, locals)
51
+ end
52
+
53
+ def evaluate_template(file, locals = {})
54
+ template = ERB.new(file.read)
55
+ template.result(template_binding(locals))
56
+ end
57
+
58
+ def template_binding(locals)
59
+ template_binding = binding
60
+
61
+ locals.each do |name, value|
62
+ template_binding.local_variable_set(name, value)
63
+ end
64
+
65
+ template_binding
66
+ end
67
+
68
+ def html(body)
69
+ response(body, "text/html; charset=utf-8")
70
+ end
71
+
72
+ def static(file, request)
73
+ metadata = static_metadata(file)
74
+ return response("", nil, 304, metadata) if not_modified?(request, metadata)
75
+
76
+ response(
77
+ file.binread,
78
+ Rack::Mime.mime_type(file.extname, "application/octet-stream"),
79
+ 200,
80
+ metadata
81
+ )
82
+ end
83
+
84
+ def not_found
85
+ response("Not found\n", "text/plain; charset=utf-8", 404)
86
+ end
87
+
88
+ def response(body, content_type, status = 200, headers = {})
89
+ headers = headers.merge("content-type" => content_type) if content_type
90
+ Rack::Response.new(body, status, headers).finish
91
+ end
92
+
93
+ def static_metadata(file)
94
+ stat = file.stat
95
+ {
96
+ "cache-control" => "public, max-age=0, must-revalidate",
97
+ "etag" => %("#{stat.size}-#{stat.mtime.to_i}"),
98
+ "last-modified" => stat.mtime.httpdate
99
+ }
100
+ end
101
+
102
+ def not_modified?(request, metadata)
103
+ return etag_matches?(request, metadata) if request.get_header("HTTP_IF_NONE_MATCH")
104
+
105
+ modified_since_matches?(request, metadata)
106
+ end
107
+
108
+ def etag_matches?(request, metadata)
109
+ request.get_header("HTTP_IF_NONE_MATCH").to_s.split(/\s*,\s*/).include?(metadata["etag"])
110
+ end
111
+
112
+ def modified_since_matches?(request, metadata)
113
+ header = request.get_header("HTTP_IF_MODIFIED_SINCE")
114
+ return false unless header
115
+
116
+ Time.httpdate(header) >= Time.httpdate(metadata["last-modified"])
117
+ rescue ArgumentError
118
+ false
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,101 @@
1
+ require "zeitwerk"
2
+
3
+ module Nyth
4
+ class Reloader
5
+ RELOAD_MODES = [true, false, :always, :changed].freeze
6
+
7
+ def initialize(root:, lib_path: "lib", app_path: "app", reload: :changed, reload_check_interval: 5.0)
8
+ validate_reload!(reload)
9
+ validate_reload_check_interval!(reload_check_interval)
10
+
11
+ @root = Pathname(root).expand_path
12
+ @lib_root = @root.join(lib_path)
13
+ @app_path = app_path
14
+ @reload = reload
15
+ @reload_check_interval = reload_check_interval.to_f
16
+ @next_reload_check_at = monotonic_now
17
+ @mutex = Mutex.new
18
+ @loader = Zeitwerk::Loader.new
19
+ @loader.push_dir(@lib_root)
20
+ @loader.enable_reloading
21
+ @loader.setup
22
+ @loader.eager_load
23
+ @lib_snapshot = lib_snapshot
24
+ end
25
+
26
+ def call(env)
27
+ reload_if_needed
28
+
29
+ Application.new(root: @root, app_path: @app_path).call(env)
30
+ end
31
+
32
+ private
33
+
34
+ def validate_reload!(reload)
35
+ return if RELOAD_MODES.include?(reload)
36
+
37
+ raise ArgumentError, "reload must be one of: #{RELOAD_MODES.map(&:inspect).join(", ")}"
38
+ end
39
+
40
+ def validate_reload_check_interval!(reload_check_interval)
41
+ return unless reload_check_interval.negative?
42
+
43
+ raise ArgumentError, "reload_check_interval must be greater than or equal to 0"
44
+ end
45
+
46
+ def reload_if_needed
47
+ return unless reload_check_due?
48
+
49
+ @mutex.synchronize do
50
+ return unless reload_check_due?
51
+
52
+ next_snapshot = reload_snapshot
53
+ schedule_next_reload_check
54
+ return unless next_snapshot
55
+
56
+ @loader.reload
57
+ @loader.eager_load
58
+ @lib_snapshot = next_snapshot
59
+ end
60
+ end
61
+
62
+ def reload_check_due?
63
+ return true if @reload == true || @reload == :always
64
+ return false if @reload == false
65
+
66
+ monotonic_now >= @next_reload_check_at
67
+ end
68
+
69
+ def schedule_next_reload_check
70
+ @next_reload_check_at = monotonic_now + @reload_check_interval
71
+ end
72
+
73
+ def reload_snapshot
74
+ if @reload == true || @reload == :always
75
+ lib_snapshot
76
+ elsif @reload == :changed
77
+ changed_snapshot
78
+ end
79
+ end
80
+
81
+ def changed_snapshot
82
+ current_snapshot = lib_snapshot
83
+ return if current_snapshot == @lib_snapshot
84
+
85
+ current_snapshot
86
+ end
87
+
88
+ def lib_snapshot
89
+ Dir.glob(@lib_root.join("**/*.rb")).sort.to_h do |path|
90
+ stat = File.stat(path)
91
+ [path, [stat.mtime.to_f, stat.size]]
92
+ rescue Errno::ENOENT
93
+ [path, nil]
94
+ end
95
+ end
96
+
97
+ def monotonic_now
98
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ end
100
+ end
101
+ end
data/lib/nyth.rb ADDED
@@ -0,0 +1,14 @@
1
+ require_relative "nyth/application"
2
+ require_relative "nyth/reloader"
3
+
4
+ module Nyth
5
+ def self.app(root:, lib_path: "lib", app_path: "app", reload: :changed, reload_check_interval: 5.0)
6
+ Reloader.new(
7
+ root: root,
8
+ lib_path: lib_path,
9
+ app_path: app_path,
10
+ reload: reload,
11
+ reload_check_interval: reload_check_interval
12
+ )
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nyth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Phillip Ridlen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ - !ruby/object:Gem::Dependency
33
+ name: zeitwerk
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.7'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.7'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
52
+ - !ruby/object:Gem::Dependency
53
+ name: minitest
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '5.25'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5.25'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '6'
72
+ - !ruby/object:Gem::Dependency
73
+ name: rake
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '13'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '15'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '13'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '15'
92
+ description: Nyth maps Rack requests to files under an app directory and renders ERB
93
+ with simple partial support.
94
+ email:
95
+ - phil@ptx.sh
96
+ executables: []
97
+ extensions: []
98
+ extra_rdoc_files: []
99
+ files:
100
+ - README.md
101
+ - lib/nyth.rb
102
+ - lib/nyth/application.rb
103
+ - lib/nyth/reloader.rb
104
+ homepage: https://github.com/philtr/nyth
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ bug_tracker_uri: https://github.com/philtr/nyth/issues
109
+ homepage_uri: https://github.com/philtr/nyth
110
+ source_code_uri: https://github.com/philtr/nyth
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.2'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 4.0.6
126
+ specification_version: 4
127
+ summary: A tiny Rack renderer for file-backed ERB pages and partials.
128
+ test_files: []