leanweb 0.1.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,19 +2,20 @@
2
2
 
3
3
  # Copyright 2022 Felix Freeman <libsys@hacktivista.org>
4
4
  #
5
- # This file is part of "LeanWeb" and licensed under the terms of the Hacktivista
6
- # General Public License version 0.1 or (at your option) any later version. You
7
- # should have received a copy of this license along with the software. If not,
8
- # see <https://hacktivista.org/licenses/>.
5
+ # This file is part of "LeanWeb" and licensed under the terms of the GNU Affero
6
+ # General Public License version 3 with permissions for compatibility with the
7
+ # Hacktivista General Public License. You should have received a copy of this
8
+ # license along with the software. If not, see <https://gnu.org/licenses/>.
9
9
 
10
10
  require 'rack'
11
+ require 'tilt'
11
12
 
12
13
  module LeanWeb
13
14
  # Controller is a base controller with `@route`, `@request` and `@response`
14
- # private attributes that will be shared with your views when you {render}
15
- # Haml documents.
15
+ # private attributes that will be shared with your views when you
16
+ # {#render_response}.
16
17
  #
17
- # Even if you don't {render}, you can use the `.finish` method from
18
+ # Even if you don't {#render_response}, you can use the `.finish` method from
18
19
  # `Rack::Response` on `@response` to return a proper `Rack` response.
19
20
  class Controller
20
21
  # @param route [Route]
@@ -23,67 +24,68 @@ module LeanWeb
23
24
  @route = route
24
25
  @request = request
25
26
  @response = Rack::Response.new(nil, 200)
27
+ @content_for = {}
26
28
  end
27
29
 
28
- # Render magic. Supports dynamic (and static) Haml documents, other
29
- # documents don't support dynamic data.
30
- #
31
- # Depending on {Route#path} and {render} `path` you will render different
32
- # documents:
33
- #
34
- # - {Route#path} `/a/b/c` and `render('d')` will render `src/views/a/b/d`
35
- # - {Route#path} `/a/b/c/` and `render('d')` will render `src/views/a/b/c/d`
36
- # - `render('~/custom')` will render `src/views/custom`.
37
- # - `render('/custom')` will render `/custom`.
30
+ # Render magic. Supports every file extension that Tilt supports. Defaults
31
+ # to ERB when file extension is unknown.
38
32
  #
39
33
  # @param path [String] Might be an absolute path or a relative path to
40
- # `src/views/` + the parent path of the last non-capture-group on
41
- # `@route.path`. You can also make it relative to {LeanWeb::VIEW_PATH} by
42
- # prepending `~`.
34
+ # `src/views/`.
43
35
  # @param content_type [String] Defaults to the proper Content-Type for file
44
36
  # extension, `text/plain` on unknown files.
37
+ # @yield Optionally pass a block for nested rendering.
38
+ # @return [Rack::Request#finish] A valid Rack response.
39
+ def render_response(path, content_type = nil, &block)
40
+ template = create_template(path)
41
+ @response.set_header(
42
+ 'Content-Type',
43
+ content_type || template.class.metadata[:mime_type] || 'text/plain'
44
+ )
45
+ @response.write(template.render(self){ block.call if block_given? })
46
+ @response.finish
47
+ end
48
+
49
+ # Template rendering engine. Useful for partials / nested views.
45
50
  #
46
- # @return [Rack::Request#finish] A valid rack response.
47
- def render(path, content_type = nil) # rubocop:disable Metrics/MethodLength
51
+ # @param path [String] Same as on {#render_response}.
52
+ # @param options [Hash] Options for Tilt, defaults to
53
+ # template_defaults[extension].
54
+ # @return [String] Rendered `path`.
55
+ def create_template(path, options = nil)
48
56
  path = absolute_view_path(path)
49
- content = File.read(path)
50
- case File.extname(path)
51
- when '.haml'
52
- require('haml')
53
- @response.write(Haml::Engine.new(content).render(binding))
54
- @response.set_header('Content-Type', content_type || 'text/html')
55
- else
56
- require('erb')
57
- @response.write(ERB.new(content).result(binding))
58
- @response.set_header('Content-Type', content_type || 'text/plain')
59
- end
60
- @response.finish
57
+ ext = File.extname(path)[1..] || ''
58
+ ext = 'erb' unless Tilt.registered?(ext)
59
+ Tilt[ext].new(path, 1, options || template_defaults[ext] || {})
60
+ end
61
+
62
+ # Relative route to path from public directory considering current route.
63
+ #
64
+ # @param path [String] path from public directory, never begins with `/`.
65
+ def base_url(path = '.')
66
+ @base_url ||= @route.str_path[1..]
67
+ .sub(%r{[^/]*$}, '')
68
+ .gsub(%r{[^/]+}, '..')
69
+
70
+ @base_url + path
71
+ end
72
+
73
+ # Request params.
74
+ def params
75
+ @request.params
61
76
  end
62
77
 
63
78
  protected
64
79
 
65
- # @param relative_path [String]
66
- # @return [String] Full path.
67
- def absolute_view_path(relative_path)
68
- case relative_path[0]
69
- when '/'
70
- relative_path
71
- when '~'
72
- relative_path.sub('~', LeanWeb::VIEW_PATH)
73
- else
74
- "#{view_path_by_route_path}/#{relative_path}"
75
- end
80
+ # Set default options for Tilt in the format 'extension' => { options }.
81
+ def template_defaults
82
+ {}
76
83
  end
77
84
 
78
- def view_path_by_route_path
79
- LeanWeb::VIEW_PATH +
80
- if @route.path.instance_of?(Regexp)
81
- File.dirname(@route.str_path.sub(%r{/?\(.*}, ''))
82
- elsif @route.path[-1] != '/'
83
- File.dirname(@route.path)
84
- else
85
- @route.path.chomp('/')
86
- end
85
+ # @param path [String]
86
+ # @return [String] Full path.
87
+ def absolute_view_path(path)
88
+ path[0] == '/' ? path : "#{LeanWeb::VIEW_PATH}/#{path}"
87
89
  end
88
90
  end
89
91
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2022 Felix Freeman <libsys@hacktivista.org>
4
+ #
5
+ # This file is part of "LeanWeb" and licensed under the terms of the GNU Affero
6
+ # General Public License version 3 with permissions for compatibility with the
7
+ # Hacktivista General Public License. You should have received a copy of this
8
+ # license along with the software. If not, see <https://gnu.org/licenses/>.
9
+
10
+
11
+ module LeanWeb
12
+ # String refinements.
13
+ module StringRefinements
14
+ refine ::String do
15
+ # String to PascalCase.
16
+ def pascalize
17
+ camelize(pascal: true)
18
+ end
19
+
20
+ # String to camelCase.
21
+ # @param pascal [Boolean] If true first letter is uppercase.
22
+ def camelize(pascal: false)
23
+ str = gsub(/[-_\s]+(.?)/){ |match| match[1].upcase }
24
+ str[0] = pascal ? str[0].upcase : str[0].downcase
25
+ str
26
+ end
27
+
28
+ # String to snake_case
29
+ def snakeize
30
+ gsub(/[[:upper:]]/){ |match| "_#{match.downcase}" }.delete_prefix('_')
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/leanweb/route.rb CHANGED
@@ -2,47 +2,60 @@
2
2
 
3
3
  # Copyright 2022 Felix Freeman <libsys@hacktivista.org>
4
4
  #
5
- # This file is part of "LeanWeb" and licensed under the terms of the Hacktivista
6
- # General Public License version 0.1 or (at your option) any later version. You
7
- # should have received a copy of this license along with the software. If not,
8
- # see <https://hacktivista.org/licenses/>.
5
+ # This file is part of "LeanWeb" and licensed under the terms of the GNU Affero
6
+ # General Public License version 3 with permissions for compatibility with the
7
+ # Hacktivista General Public License. You should have received a copy of this
8
+ # license along with the software. If not, see <https://gnu.org/licenses/>.
9
9
 
10
+ require 'rack/mock'
10
11
 
11
12
  module LeanWeb
12
13
  # Action for {Route#action}.
13
- Action = Struct.new(:file, :controller, :action, keyword_init: true)
14
+ Action = Struct.new(:controller, :action, keyword_init: true)
14
15
 
15
- # A single route which routes with the {respond} method. It can also {build}
16
+ # A single route which routes with the {#respond} method. It can also {#build}
16
17
  # an static file.
17
18
  class Route
19
+ using StringRefinements
20
+
18
21
  attr_reader :path, :method, :action, :static
19
22
 
20
23
  # A new instance of Route.
21
24
  #
22
25
  # @param path [String, Regexp] Path matcher, can be an String or Regexp with
23
26
  # positional or named capture groups, `@action` will receive these as
24
- # positional or named arguments.
27
+ # positional or named arguments. Always begins with `/`.
25
28
  # @param method [String, nil] Must be an HTTP verb such as `GET` or `POST`.
26
29
  # @param action [Proc, Hash, String, nil] References a Method/Proc to be
27
30
  # triggered. It can be:
28
31
  #
29
- # - A full hash `{ file: 'val', controller: 'val', action: 'val' }`.
30
- # - A hash with `{ 'file' => 'action' }` only (which has a controller
31
- # class name `{File}Controller`).
32
- # - A simple string (which will consider file `main.rb` and controller
33
- # `MainController`). Defaults to "{path_basename}_{method}", (ex:
34
- # `index_get`).
35
- # - It can also be a `Proc`.
32
+ # - Nothing, defaults to :"{#path_basename}_{#method}" (such as
33
+ # `:index_get`) on `:MainController`.
34
+ # - A full hash `{ controller: 'val', action: 'val' }`.
35
+ # - A hash with `{ Controller: :action }` only.
36
+ # - A simple string for an action on `:MainController`.
37
+ # - It can also be a `Proc`. Will be executed in a `:MainController`
38
+ # instance context.
36
39
  #
37
- # @param static [Boolean|Array] Defines a route as static. Set to `false` to
38
- # say it can only work dynamically. You can also supply an array of arrays
39
- # or hashes to generate static files based on that positional or keyword
40
- # params.
41
- def initialize(path:, method: 'GET', action: nil, static: true)
40
+ # @param static [Boolean|Array] Defines a route as static. Defaults true for
41
+ # GET method, false otherwise. Set to `false` to say it can only work
42
+ # dynamically. You can also supply an array of arrays or hashes to
43
+ # generate static files based on that positional or keyword params.
44
+ def initialize(path:, method: 'GET', action: nil, static: nil)
42
45
  @path = path
43
46
  @method = method
44
47
  self.action = action
45
- @static = static
48
+ @static = static.nil? ? @method == 'GET' : static
49
+ end
50
+
51
+ # Check if route is available.
52
+ #
53
+ # If on production environment (not `development`), should serve only
54
+ # dynamic (not `static`) routes.
55
+ def available?
56
+ return @static == false if ENV['RACK_ENV'] != 'development'
57
+
58
+ true
46
59
  end
47
60
 
48
61
  # Last identifier on a path, returns `index` for `/`.
@@ -51,14 +64,14 @@ module LeanWeb
51
64
  end
52
65
 
53
66
  # @param request [Rack::Request]
54
- # @return a valid rack response.
67
+ # @return [Array] a valid rack response.
55
68
  def respond(request)
56
69
  return respond_proc(request) if @action.instance_of?(Proc)
57
70
 
58
71
  respond_method(request)
59
72
  end
60
73
 
61
- # String path, independent if {path} is Regexp or String.
74
+ # String path, independent if {#path} is Regexp or String.
62
75
  def str_path
63
76
  @path.source.gsub(/[\^$]/, '')
64
77
  rescue NoMethodError
@@ -66,26 +79,26 @@ module LeanWeb
66
79
  end
67
80
 
68
81
  # On Regexp paths, return a string valid for making a request to this route.
82
+ #
69
83
  # @param seed [Array, Hash] Seeds to use as replacement on capture groups.
70
84
  # @return [String] sown path.
71
85
  def seed_path(seed)
72
86
  sown_path = str_path
73
87
  if seed.instance_of?(Hash)
74
- seed.each { |key, val| sown_path.sub!(/\(\?<#{key}>[^)]+\)/, val) }
88
+ seed.each{ |key, val| sown_path.sub!(/\(\?<#{key}>[^)]+\)/, val) }
75
89
  else
76
- seed.each { |val| sown_path.sub!(/\([^)]+\)/, val) }
90
+ seed.each{ |val| sown_path.sub!(/\([^)]+\)/, val) }
77
91
  end
78
92
  sown_path
79
93
  end
80
94
 
81
95
  # Build this route as an static file and place it relative to
82
96
  # {LeanWeb::PUBLIC_PATH}.
97
+ #
83
98
  # @param request_path [String] Request path for dynamic (regex) routes.
84
99
  def build(request_path = @path)
85
100
  response = respond(
86
- Rack::Request.new(
87
- { 'PATH_INFO' => request_path, 'REQUEST_METHOD' => 'GET' }
88
- )
101
+ Rack::Request.new(Rack::MockRequest.env_for(request_path))
89
102
  )
90
103
  out_path = output_path(request_path, response[1]['Content-Type'] || nil)
91
104
  FileUtils.mkdir_p(File.dirname(out_path))
@@ -97,48 +110,62 @@ module LeanWeb
97
110
 
98
111
  # Assign value to `@action`.
99
112
  def action=(value)
100
- @action = if value.instance_of?(Proc)
101
- value
102
- else
103
- Action.new(**prepare_action_hash(value))
104
- end
113
+ @action =
114
+ if value.instance_of?(Proc)
115
+ value
116
+ else
117
+ Action.new(**prepare_action_hash(value))
118
+ end
105
119
  end
106
120
 
107
- # @param value [Hash, String, nil] Check {initialize} action param for
121
+ # @param src_value [Hash, String, nil] Check {#initialize} action param for
108
122
  # valid input.
109
123
  # @return [Hash] valid hash for {Action}.
110
- def prepare_action_hash(value)
111
- begin
112
- value[:file], value[:action] = value.first \
113
- unless %i[file controller action].include?(value.keys.first)
114
- rescue NoMethodError
115
- value = { action: value }
116
- end
117
- value[:file] ||= 'main'
118
- value[:controller] ||= "#{value[:file].capitalize}Controller"
119
- value[:action] ||= "#{path_basename}_#{@method.downcase}"
124
+ def prepare_action_hash(src_value)
125
+ value = prepare_prepare_action_hash(src_value)
126
+ value[:controller] = value[:controller]&.to_sym || DEFAULT_CONTROLLER
127
+ value[:action] = value[:action]&.to_sym || default_action_action
120
128
  value
121
129
  end
122
130
 
131
+ def prepare_prepare_action_hash(src_value)
132
+ if %i[controller action].include?(src_value.keys.first)
133
+ src_value
134
+ else
135
+ value = {}
136
+ value[:controller], value[:action] = src_value.first
137
+ value
138
+ end
139
+ rescue NoMethodError
140
+ { action: src_value }
141
+ end
142
+
143
+ def default_action_action
144
+ "#{path_basename.gsub('-', '_')}_#{@method.downcase}".to_sym
145
+ end
146
+
123
147
  # @param request [Rack::Request]
124
- # @return a valid rack response.
148
+ # @return [Array] a valid Rack response.
125
149
  def respond_method(request)
126
150
  params = action_params(request.path)
127
- require_relative("#{LeanWeb::CONTROLLER_PATH}/#{@action.file}")
151
+ require_relative("#{CONTROLLER_PATH}/#{@action.controller.to_s.snakeize}")
128
152
  controller = Object.const_get(@action.controller).new(self, request)
129
- return controller.public_send(@action.action, **params)\
153
+ return controller.public_send(@action.action, **params) \
130
154
  if params.instance_of?(Hash)
131
155
 
132
156
  controller.public_send(@action.action, *params)
133
157
  end
134
158
 
135
159
  # @param request [Rack::Request]
136
- # @return a valid rack response.
160
+ # @return [Array] a valid Rack response.
137
161
  def respond_proc(request)
138
162
  params = action_params(request.path)
139
- return @action.call(**params) if params.instance_of?(Hash)
163
+ require_relative("#{CONTROLLER_PATH}/#{DEFAULT_CONTROLLER.to_s.snakeize}")
164
+ controller = Object.const_get(DEFAULT_CONTROLLER).new(self, request)
165
+ return controller.instance_exec(**params, &@action) \
166
+ if params.instance_of?(Hash)
140
167
 
141
- @action.call(*params)
168
+ controller.instance_exec(*params, &@action)
142
169
  end
143
170
 
144
171
  # @param request_path [String]
@@ -147,19 +174,30 @@ module LeanWeb
147
174
  return nil unless @path.instance_of?(Regexp)
148
175
 
149
176
  matches = @path.match(request_path)
150
- return matches.named_captures.transform_keys(&:to_sym)\
177
+ return matches.named_captures.transform_keys(&:to_sym) \
151
178
  if matches.named_captures != {}
152
179
 
153
180
  matches.captures
154
181
  end
155
182
 
156
183
  # Output path for public file.
184
+ #
157
185
  # @param path [String]
158
186
  # @param content_type [String]
159
187
  # @return [String] absolute route to path + extension based on content_type.
160
188
  def output_path(path, content_type)
161
- path += 'index' if path[-1] == '/'
162
- "#{LeanWeb::PUBLIC_PATH}#{path}#{LeanWeb::MEDIA_EXTENSIONS[content_type]}"
189
+ out_path =
190
+ if path[-1] == '/'
191
+ String.new("#{PUBLIC_PATH}#{path}/index")
192
+ else
193
+ String.new("#{PUBLIC_PATH}#{path}")
194
+ end
195
+
196
+ unless path.end_with?(MEDIA_EXTENSIONS[content_type])
197
+ out_path << MEDIA_EXTENSIONS[content_type]
198
+ end
199
+
200
+ out_path
163
201
  end
164
202
  end
165
203
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2022 Felix Freeman <libsys@hacktivista.org>
4
+ #
5
+ # This file is part of "LeanWeb" and licensed under the terms of the GNU Affero
6
+ # General Public License version 3 with permissions for compatibility with the
7
+ # Hacktivista General Public License. You should have received a copy of this
8
+ # license along with the software. If not, see <https://gnu.org/licenses/>.
9
+
10
+
11
+ module LeanWeb
12
+ VERSION = '0.3.0'
13
+ end
data/lib/leanweb.rb CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  # Copyright 2022 Felix Freeman <libsys@hacktivista.org>
4
4
  #
5
- # This file is part of "LeanWeb" and licensed under the terms of the Hacktivista
6
- # General Public License version 0.1 or (at your option) any later version. You
7
- # should have received a copy of this license along with the software. If not,
8
- # see <https://hacktivista.org/licenses/>.
5
+ # This file is part of "LeanWeb" and licensed under the terms of the GNU Affero
6
+ # General Public License version 3 with permissions for compatibility with the
7
+ # Hacktivista General Public License. You should have received a copy of this
8
+ # license along with the software. If not, see <https://gnu.org/licenses/>.
9
9
 
10
10
  # LeanWeb is a minimal hybrid static / dynamic web framework.
11
11
  module LeanWeb
12
- VERSION = '0.1.3'
12
+ require_relative 'leanweb/version'
13
13
 
14
- ROOT_PATH = ENV['LEANWEB_ROOT_PATH'] || Dir.pwd
14
+ ROOT_PATH = ENV.fetch('LEANWEB_ROOT_PATH', Dir.pwd)
15
15
  CONTROLLER_PATH = "#{ROOT_PATH}/src/controllers"
16
16
  VIEW_PATH = "#{ROOT_PATH}/src/views"
17
17
  PUBLIC_PATH = "#{ROOT_PATH}/public"
@@ -21,10 +21,16 @@ module LeanWeb
21
21
  'application/javascript' => '.js',
22
22
  'application/json' => '.json',
23
23
  'text/html' => '.html',
24
- 'text/plain' => '.txt'
24
+ 'text/plain' => '.txt',
25
+ 'text/css' => '.css',
26
+ 'text/csv' => '.csv'
25
27
  }.freeze
26
28
 
29
+ DEFAULT_CONTROLLER = :MainController
30
+
27
31
  autoload :Route, 'leanweb/route.rb'
28
32
  autoload :Controller, 'leanweb/controller.rb'
29
33
  autoload :App, 'leanweb/app.rb'
30
34
  end
35
+
36
+ require_relative 'leanweb/helpers'