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 +7 -0
- data/README.md +42 -0
- data/lib/nyth/application.rb +121 -0
- data/lib/nyth/reloader.rb +101 -0
- data/lib/nyth.rb +14 -0
- metadata +128 -0
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: []
|