brut 0.0.1 → 0.0.2

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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -3,8 +3,6 @@ require "uri"
3
3
  # Holds the registered routes for this app.
4
4
  class Brut::FrontEnd::Routing
5
5
 
6
- include SemanticLogger::Loggable
7
-
8
6
  def initialize
9
7
  @routes = Set.new
10
8
  end
@@ -21,6 +19,10 @@ class Brut::FrontEnd::Routing
21
19
  new_routes = @routes.map { |route|
22
20
  if route.class == Route
23
21
  route.class.new(route.http_method,route.path_template)
22
+ elsif route.class == MissingPage || route.class == MissingHandler || route.class == MissingForm
23
+ route.class.new(route.path_template,route.exception)
24
+ elsif route.class == MissingPath
25
+ route.class.new(route.method,route.path_template,route.exception)
24
26
  else
25
27
  route.class.new(route.path_template)
26
28
  end
@@ -35,28 +37,60 @@ class Brut::FrontEnd::Routing
35
37
  end
36
38
 
37
39
  def register_page(path)
38
- route = PageRoute.new(path)
40
+ route = begin
41
+ PageRoute.new(path)
42
+ rescue Brut::Framework::Errors::NoClassForPath => ex
43
+ if Brut.container.project_env.development?
44
+ MissingPage.new(path,ex)
45
+ else
46
+ raise ex
47
+ end
48
+ end
39
49
  @routes << route
40
50
  add_routing_method(route)
41
51
  route
42
52
  end
43
53
 
44
54
  def register_form(path)
45
- route = FormRoute.new(path)
55
+ route = begin
56
+ FormRoute.new(path)
57
+ rescue Brut::Framework::Errors::NoClassForPath => ex
58
+ if Brut.container.project_env.development?
59
+ MissingForm.new(path,ex)
60
+ else
61
+ raise ex
62
+ end
63
+ end
46
64
  @routes << route
47
65
  add_routing_method(route)
48
66
  route
49
67
  end
50
68
 
51
69
  def register_handler_only(path)
52
- route = FormHandlerRoute.new(path)
70
+ route = begin
71
+ FormHandlerRoute.new(path)
72
+ rescue Brut::Framework::Errors::NoClassForPath => ex
73
+ if Brut.container.project_env.development?
74
+ MissingHandler.new(path,ex)
75
+ else
76
+ raise ex
77
+ end
78
+ end
53
79
  @routes << route
54
80
  add_routing_method(route)
55
81
  route
56
82
  end
57
83
 
58
84
  def register_path(path, method:)
59
- route = Route.new(method, path)
85
+ route = begin
86
+ Route.new(method, path)
87
+ rescue Brut::Framework::Errors::NoClassForPath => ex
88
+ if Brut.container.project_env.development?
89
+ MissingPath.new(method,path,ex)
90
+ else
91
+ raise ex
92
+ end
93
+ end
60
94
  @routes << route
61
95
  add_routing_method(route)
62
96
  route
@@ -73,7 +107,11 @@ class Brut::FrontEnd::Routing
73
107
  handler_class_match || form_class_match
74
108
  }
75
109
  if !route
76
- raise ArgumentError,"There is no configured route for #{handler_class}"
110
+ if handler_class.ancestors.include?(Brut::FrontEnd::Form)
111
+ raise ArgumentError,"There is no configured route for the form #{handler_class} and/or the handler class for this form doesn't exist"
112
+ else
113
+ raise ArgumentError,"There is no configured route for #{handler_class}"
114
+ end
77
115
  end
78
116
  route
79
117
  end
@@ -116,8 +154,6 @@ class Brut::FrontEnd::Routing
116
154
 
117
155
  class Route
118
156
 
119
- include SemanticLogger::Loggable
120
-
121
157
  attr_reader :handler_class, :path_template, :http_method
122
158
 
123
159
  def initialize(method,path_template)
@@ -133,24 +169,37 @@ class Brut::FrontEnd::Routing
133
169
  @handler_class = self.locate_handler_class(self.suffix,self.preposition)
134
170
  end
135
171
 
172
+ def path_params
173
+ @path_template.split(/\//).map { |path_part|
174
+ if path_part =~ /^:(.+)$/
175
+ $1.to_sym
176
+ else
177
+ nil
178
+ end
179
+ }.compact
180
+ end
181
+
136
182
  def path(**query_string_params)
137
183
  path = @path_template.split(/\//).map { |path_part|
138
184
  if path_part =~ /^:(.+)$/
139
185
  param_name = $1.to_sym
140
186
  if !query_string_params.key?(param_name)
141
- query_string_params_for_message = if query_string_params.keys.any?
142
- query_string_params.keys.map(&:to_s).join(", ")
143
- else
144
- "no params"
145
- end
146
- raise ArgumentError,"path for #{@handler_class} requires '#{param_name}' as a path parameter, but it was not specified to #path. Got #{query_string_params_for_message}"
187
+ raise Brut::Framework::Errors::MissingParameter.new(
188
+ param_name,
189
+ params_received: query_string_params.keys,
190
+ context: ":#{param_name} was used as a path parameter for #{@handler_class} (path '#{@path_template}')"
191
+ )
147
192
  end
148
193
  query_string_params.delete(param_name)
149
194
  else
150
195
  path_part
151
196
  end
152
197
  }
153
- uri = URI(path.join("/"))
198
+ joined_path = path.join("/")
199
+ if joined_path == ""
200
+ joined_path = "/"
201
+ end
202
+ uri = URI(joined_path)
154
203
  uri.query = URI.encode_www_form(query_string_params)
155
204
  uri
156
205
  end
@@ -162,7 +211,7 @@ class Brut::FrontEnd::Routing
162
211
  private
163
212
  def locate_handler_class(suffix,preposition, on_missing: :raise)
164
213
  if @path_template == "/"
165
- return Module.const_get("HomePage")
214
+ return Module.const_get("HomePage") # XXX Needs error handling
166
215
  end
167
216
  path_parts = @path_template.split(/\//)[1..-1]
168
217
 
@@ -184,20 +233,20 @@ class Brut::FrontEnd::Routing
184
233
  array
185
234
  }
186
235
  part_names[-1] += suffix
187
- part_names.inject(Module) { |mod,path_element|
188
- mod.const_get(path_element,mod == Module)
189
- }
190
- rescue NameError => ex
191
- if on_missing == :raise
192
- module_message = if ex.receiver == Module
193
- "Could not find"
194
- else
195
- "Module '#{ex.receiver}' did not have"
196
- end
197
- message = "Cannot find page class for route '#{@path_template}', which should be #{part_names.join("::")}. #{module_message} the class or module '#{ex.name}'"
198
- raise message
199
- else
200
- nil
236
+ begin
237
+ part_names.inject(Module) { |mod,path_element|
238
+ mod.const_get(path_element,mod == Module)
239
+ }
240
+ rescue NameError => ex
241
+ if on_missing == :raise
242
+ raise Brut::Framework::Errors::NoClassForPath.new(
243
+ class_name_path: part_names,
244
+ path_template: @path_template,
245
+ name_error: ex,
246
+ )
247
+ else
248
+ nil
249
+ end
201
250
  end
202
251
  end
203
252
 
@@ -206,6 +255,64 @@ class Brut::FrontEnd::Routing
206
255
 
207
256
  end
208
257
 
258
+ class MissingPage < Route
259
+ attr_reader :exception
260
+ def initialize(path_template,ex)
261
+ @http_method = Brut::FrontEnd::HttpMethod.new(:get)
262
+ @path_template = path_template
263
+ @handler_class = begin
264
+ page_class = Class.new(Brut::FrontEnd::Pages::MissingPage)
265
+ compressed_class_name = ex.class_name_path.join
266
+ Module.const_set(:"BrutMissingPages#{compressed_class_name}",page_class)
267
+ page_class
268
+ end
269
+ @exception = ex
270
+ end
271
+ end
272
+
273
+ class MissingHandler < Route
274
+ attr_reader :exception
275
+ def initialize(path_template,ex)
276
+ @http_method = Brut::FrontEnd::HttpMethod.new(:post)
277
+ @path_template = path_template
278
+ @handler_class = begin
279
+ handler_class = Class.new(Brut::FrontEnd::Handlers::MissingHandler)
280
+ compressed_class_name = ex.class_name_path.join
281
+ Module.const_set(:"BrutMissingHandlers#{compressed_class_name}",handler_class)
282
+ handler_class
283
+ end
284
+ @exception = ex
285
+ end
286
+ end
287
+
288
+ class MissingPath < Route
289
+ attr_reader :exception
290
+ def initialize(method,path_template,ex)
291
+ @http_method = Brut::FrontEnd::HttpMethod.new(method)
292
+ @path_template = path_template
293
+ @handler_class = begin
294
+ handler_class = Class.new(Brut::FrontEnd::Handlers::MissingHandler)
295
+ compressed_class_name = ex.class_name_path.join
296
+ Module.const_set(:"BrutMissingHandlers#{compressed_class_name}",handler_class)
297
+ handler_class
298
+ end
299
+ @exception = ex
300
+ end
301
+ end
302
+
303
+ class MissingForm < MissingHandler
304
+ attr_reader :form_class
305
+ def initialize(path_template,ex)
306
+ super
307
+ @form_class = begin
308
+ form_class = Class.new(Brut::FrontEnd::Handlers::MissingHandler::Form)
309
+ compressed_class_name = ex.class_name_path.join
310
+ Module.const_set(:"BrutMissingForms#{compressed_class_name}",form_class)
311
+ form_class
312
+ end
313
+ end
314
+ end
315
+
209
316
  class PageRoute < Route
210
317
  def initialize(path_template)
211
318
  super(Brut::FrontEnd::HttpMethod.new(:get),path_template)
@@ -2,20 +2,31 @@
2
2
  # Generally, this can act like a Hash for setting and accessing values stored in the session.
3
3
  # It provides a few useful additions:
4
4
  #
5
- # * Your app can extend this to provide an app-specific API around the session.
5
+ # * Your app can extend this to provide an app-specific API around the session, using `Brut.container.override("session_class",«your class»)`.
6
6
  # * There is direct access to commonly-used data stored in the session, such as the flash.
7
7
  class Brut::FrontEnd::Session
8
+ # Create the session based on the session provided by Rack.
9
+ #
10
+ # @param [Rack session] rack_session the session as provided by Rack. This is treated as a Hash.
8
11
  def initialize(rack_session:)
9
12
  @rack_session = rack_session
10
13
  end
11
14
 
15
+ # Return the interpretation of the `Accept-Language` header that was set by (#http_accept_language=).
16
+ #
17
+ # @return [Brut::I18n::HTTPAcceptLanguage] Never returns `nil`. If the value is corrupted or invalid, an instance that uses English
18
+ # will be returned.
12
19
  def http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_session(self[:__brut_http_accept_language])
20
+
21
+ # Set the `Accept-Language` for the session, as an {Brut::I18n::HTTPAcceptLanguage}
22
+ # @param [Brut::I18n::HTTPAcceptLanguage] http_accept_language
13
23
  def http_accept_language=(http_accept_language)
14
24
  self[:__brut_http_accept_language] = http_accept_language.for_session
15
25
  end
16
26
 
17
- # Get the timezone as reported by the browser, as a TZInfo::Timezone.
18
- # If none is available or the browser reported an invalid value, this returns nil.
27
+ # Get the timezone as reported by the browser, or nil if there isn't one or the browser sent and invalid value
28
+ #
29
+ # @return [TZInfo::Timezone|nil]
19
30
  def timezone_from_browser
20
31
  tz_name = self[:__brut_timezone_from_browser]
21
32
  if tz_name.nil?
@@ -24,13 +35,14 @@ class Brut::FrontEnd::Session
24
35
  begin
25
36
  TZInfo::Timezone.get(tz_name)
26
37
  rescue TZInfo::InvalidTimezoneIdentifier => ex
27
- SemanticLogger[self.class.name].warn(ex)
38
+ Brut.container.instrumentation.record_exception(ex, class: self.class)
28
39
  nil
29
40
  end
30
41
  end
31
42
 
32
- # Set the timezone as reported by the browser. This alleviates the need to keep
33
- # asking the browser for this information.
43
+ # Set the timezone as reported by the browser.
44
+ #
45
+ # @param timezone [TZInfo::Timezone|String] The timezone, or name of a timezone suitable for use with `TZInfo::Timezone`.
34
46
  def timezone_from_browser=(timezone)
35
47
  if timezone.kind_of?(TZInfo::Timezone)
36
48
  timezone = timezone.name
@@ -38,18 +50,85 @@ class Brut::FrontEnd::Session
38
50
  self[:__brut_timezone_from_browser] = timezone
39
51
  end
40
52
 
53
+ # Set the session timezone, regardless of what the browser reports.
54
+ #
55
+ # @param timezone [TZInfo::Timezone|String|nil] The timezone, or name of a timezone suitable for use with `TZInfo::Timezone`. Use
56
+ # `nil` to clear this value and use the browser's time zone.
57
+ def timezone=(timezone)
58
+ if timezone.kind_of?(TZInfo::Timezone)
59
+ timezone = timezone.name
60
+ end
61
+ self[:__brut_timezone_override] = timezone
62
+ end
63
+
64
+ # Get the session timezone. This is the preferred way to get a timezone for the current session. Always returns a value, based on
65
+ # the following logic:
66
+ #
67
+ # 1. If {#timezone=} has been called with a value, that time zone is returned.
68
+ # 2. If {#timezone=} has been given no value or `nil`, and {#timezone_from_browser} returns a value, that value is used.
69
+ # 3. If {#timezone_from_browser} returns `nil`, `ENV["TZ"]` is used, assuming it is a valid time zone.
70
+ # 4. If 'ENV["TZ"]` is blank or invalid, UTC is returned.
71
+ #
72
+ # It is in your best interest to ensure that each session has a valid time zone.
73
+ #
74
+ # @return [TZInfo::Timezone]
75
+ def timezone
76
+ tz_name = self[:__brut_timezone_override]
77
+ timezone = nil
78
+ if !tz_name.nil?
79
+ begin
80
+ timezone = TZInfo::Timezone.get(tz_name)
81
+ rescue TZInfo::InvalidTimezoneIdentifier => ex
82
+ Brut.container.instrumentation.record_exception(ex, class: self.class, invalid_tz_name: tz_name)
83
+ end
84
+ end
85
+ if timezone.nil?
86
+ timezone = self.timezone_from_browser
87
+ end
88
+ if timezone.nil?
89
+ begin
90
+ timezone = if ENV["TZ"]
91
+ TZInfo::Timezone.get(ENV["TZ"])
92
+ else
93
+ nil
94
+ end
95
+ rescue TZInfo::InvalidTimezoneIdentifier => ex
96
+ Brut.container.instrumentation.record_exception(ex, class: self.class, invalid_env_tz: ENV['TZ'])
97
+ nil
98
+ end
99
+ end
100
+ if timezone.nil?
101
+ timezone = TZInfo::Timezone.get("UTC")
102
+ end
103
+ timezone
104
+ end
105
+
106
+ # Access the underlying session directly
107
+ #
108
+ # @param [Symbol|String] key the key to use. Coerced into a string.
109
+ # @return [Object] whatever value, including `nil`, is in the session for this key.
41
110
  def[](key) = @rack_session[key.to_s]
42
111
 
112
+ # Set the session value for the key.
113
+ #
114
+ # @param [Symbol|String] key the key to use. Coerced into a string.
115
+ # @param [Object] value Value to use. Note that this value may be coerced into a string in a way that may not work for your use case.
116
+ # You are encouraged to send in a string. If you want to store rich data in the session, maybe don't? But if you must, add a
117
+ # method to do the marshalling in your app's subclass of this
43
118
  def[]=(key,value)
44
119
  @rack_session[key.to_s] = value
45
120
  end
46
121
 
122
+ # Delete a key from the session. This is preferred to setting the value to `nil`
47
123
  def delete(key) = @rack_session.delete(key.to_s)
48
124
 
49
- # Access the flash, as an instance of whatever class has been configured.
125
+ # Access the flash, as an instance of whatever class has been configured. Note that this returns a copy of the flash, so any changes
126
+ # will not be stored in the session unless you call (#flash=) after changing it. Generally, this isn't a big deal as Brut handles
127
+ # this for you.
50
128
  def flash
51
129
  Brut.container.flash_class.from_h(self[:__brut_flash])
52
130
  end
131
+ # Set the flash.
53
132
  def flash=(new_flash)
54
133
  self[:__brut_flash] = new_flash.to_h
55
134
  end
@@ -1,31 +1,46 @@
1
1
  require "temple"
2
2
 
3
+ # Holds code related to rendering ERB templates
3
4
  module Brut::FrontEnd::Templates
4
5
  autoload(:HTMLSafeString,"brut/front_end/templates/html_safe_string")
5
6
  autoload(:ERBParser,"brut/front_end/templates/erb_parser")
6
7
  autoload(:EscapableFilter,"brut/front_end/templates/escapable_filter")
7
8
  autoload(:BlockFilter,"brut/front_end/templates/block_filter")
8
9
  autoload(:ERBEngine,"brut/front_end/templates/erb_engine")
10
+ autoload(:Locator,"brut/front_end/templates/locator")
9
11
  end
10
12
 
11
- # Handles rendering HTML templates
13
+ # Handles rendering HTML templates written in ERB. This is a light wrapper around `Tilt`.
14
+ # This also configured a few customizations to allow a Rails-like rendering of ERB:
15
+ #
16
+ # * HTML escaping by default
17
+ # * Helpers that return {Brut::FrontEnd::Templates::HTMLSafeString}s won't be escaped
18
+ #
19
+ # @see https://github.com/rtomayko/tilt
12
20
  class Brut::FrontEnd::Template
13
21
 
22
+ # @!visibility private
23
+ # This sets up global state somewhere, even though we aren't using `TempleTemplate`
24
+ # anywhere.
14
25
  TempleTemplate = Temple::Templates::Tilt(Brut::FrontEnd::Templates::ERBEngine,
15
26
  register_as: "html.erb")
16
27
 
28
+ attr_reader :template_file_path
29
+
17
30
  # Wraps a string that is deemed safe to insert into
18
31
  # HTML without escaping it. This allows stuff like
19
32
  # <%= component(SomeComponent) %> to work without
20
33
  # having to remember to <%== all the time.
21
34
  def initialize(template_file_path)
22
- @tilt_template = Tilt.new(template_file_path)
35
+ @template_file_path = template_file_path
36
+ @tilt_template = Tilt.new(@template_file_path)
23
37
  end
24
38
 
25
39
  def render_template(...)
26
40
  @tilt_template.render(...)
27
41
  end
28
42
 
43
+ # Convienience method to escape HTML in the canonical way.
29
44
  def self.escape_html(string)
30
45
  Brut::FrontEnd::Templates::EscapableFilter.escape_html(string)
31
46
  end
@@ -1,10 +1,11 @@
1
- # This is a slightly modified copy if Hanamis' Filters::Block:
2
- #
3
- # https://github.com/hanami/view/blob/main/lib/hanami/view/erb/filters/block.rb
1
+ # Allows rendering blocks in ERB the way Rails' helpers like `form_with` do.
2
+ # This is a slightly modified copy of Hanami's `Filters::Block`.
4
3
  #
4
+ # @see https://github.com/hanami/view/blob/main/lib/hanami/view/erb/filters/block.rb
5
5
  class Brut::FrontEnd::Templates::BlockFilter < Temple::Filter
6
6
  END_LINE_RE = /\bend\b/
7
7
 
8
+ # @!visibility private
8
9
  def on_erb_block(escape, code, content)
9
10
  tmp = unique_name
10
11
 
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # https://github.com/hanami/view/blob/main/lib/hanami/view/erb/parser.rb
4
4
  #
5
- # That is licensed MIT and thus so is this.
5
+ # That is licensed MIT and thus so is this file.
6
6
  #
7
7
  # Avoid changes to this file so it can be kept updated with Hanami.
8
8
  class Brut::FrontEnd::Templates::ERBParser < Temple::Parser
@@ -3,11 +3,13 @@
3
3
  class Brut::FrontEnd::Templates::EscapableFilter < Temple::Filters::Escapable
4
4
  using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
5
5
 
6
+ # @!visibility private
6
7
  def initialize(opts = {})
7
8
  opts[:escape_code] ||= "::Brut::FrontEnd::Templates::EscapableFilter.escape_html((%s))"
8
9
  super(opts)
9
10
  end
10
11
 
12
+ # @!visibility private
11
13
  def self.escape_html(html)
12
14
  if html.kind_of?(Brut::FrontEnd::Templates::HTMLSafeString)
13
15
  html.string
@@ -1,18 +1,33 @@
1
1
  # A wrapper around a string to indicate it is HTML-safe and
2
- # can be rendered directly without escaping.
2
+ # can be rendered directly without escaping. This was done to avoid adding methods on `String` and the internal state
3
+ # required to make something like `"foo".html_safe!` work.
3
4
  class Brut::FrontEnd::Templates::HTMLSafeString
5
+ # This can be used via `using` to add `html_safe!` and `html_safe?` method to `String` when they might be more convienient
6
+ # than using {Brut::FrontEnd::Templates::HTMLSafeString} directly.
4
7
  module Refinement
5
8
  refine String do
6
9
  def html_safe! = Brut::FrontEnd::Templates::HTMLSafeString.from_string(self)
7
10
  def html_safe? = false
8
11
  end
9
12
  end
13
+ using Refinement
14
+
15
+ # @return [String] the underlying string being wrapped
10
16
  attr_reader :string
17
+
18
+ # Create an HTML safe string based on the parameter. It's recommended to use {.from_string} instead.
19
+ #
20
+ # @param [String] string A string that is considered safe to put directly into a web page without escaping.
11
21
  def initialize(string)
12
22
  @string = string
13
23
  end
14
24
 
15
- # Wrap a string in an HTMLSafeString if needed.
25
+ # Creates an HTML Safe string based on the parameter, properly handling if a HTML safe string is being passed.
26
+ #
27
+ # @param [String|Brut::FrontEnd::Templates::HTMLSafeString] string_or_html_safe_string the value to turn into an HTML safe string.
28
+ #
29
+ # @return [Brut::FrontEnd::Templates::HTMLSafeString] if `string_or_html_safe_string` is already HTML safe, returns it. Otherwise,
30
+ # wraps the string as HTML safe.
16
31
  def self.from_string(string_or_html_safe_string)
17
32
  if string_or_html_safe_string.kind_of?(self)
18
33
  string_or_html_safe_string
@@ -24,12 +39,25 @@ class Brut::FrontEnd::Templates::HTMLSafeString
24
39
  # This must be convertible to a string
25
40
  def to_s = @string
26
41
  def to_str = @string
42
+ # Matches the protocol in {Brut::FrontEnd::Templates::HTMLSafeString::Refinement}
43
+ # @return [Brut::FrontEnd::Templates::HTMLSafeString] self
27
44
  def html_safe! = self
45
+ # Matches the protocol in {Brut::FrontEnd::Templates::HTMLSafeString::Refinement}
46
+ # @return [true|false] true
28
47
  def html_safe? = true
48
+
49
+ # Return a new instance that has called `capitalize` on the underlying string
29
50
  def capitalize = self.class.new(@string.capitalize)
51
+ # Return a new instance that has called `downcase` on the underlying string
30
52
  def downcase = self.class.new(@string.downcase)
53
+ # Return a new instance that has called `upcase` on the underlying string
31
54
  def upcase = self.class.new(@string.upcase)
32
55
 
56
+ # Returns the concatenation of two strings. If the other is HTML safe, then this returns an HTML safe string.
57
+ # If the other is not, this returns a normal unsafe string.
58
+ #
59
+ # @param [String|Brut::FrontEnd::Templates::HTMLSafeString] other
60
+ # @return [String|Brut::FrontEnd::Templates::HTMLSafeString] A safe or unsafe string, depending on what was passed.
33
61
  def +(other)
34
62
  if other.html_safe?
35
63
  self.class.new(@string + other.to_s)
@@ -0,0 +1,60 @@
1
+ # Locates a template, based on a name, configured paths, and an extension. This class forms both an API
2
+ # for template location ({#locate}) as well as an implementation that is conventional with Brut apps.
3
+ class Brut::FrontEnd::Templates::Locator
4
+ # Create a locator that will search the given paths and require that template
5
+ # files have the given extension
6
+ #
7
+ # @param [Pathname|String|Array<Pathname|String>] paths one or more paths that will be searched for templates
8
+ # @param [String] extension file extension, without the dot, of the name of files that are considered templates
9
+ def initialize(paths:, extension:)
10
+ @paths = Array(paths).map { |path| Pathname(path) }
11
+ @extension = extension
12
+ end
13
+
14
+ # Given a base name, which may or may not be nested paths, returns the path to the template
15
+ # for this file. There must be exactly one template that matches.
16
+ #
17
+ # @example
18
+ #
19
+ # locator = Locator.new(
20
+ # paths: [
21
+ # Brut.container.app_src_dir / "front_end" / "components",
22
+ # Brut.container.app_src_dir / "front_end" / "other_components",
23
+ # ],
24
+ # extension: "html.erb"
25
+ # )
26
+ #
27
+ # # Suppose app/src/front_end/components/foo.html.erb exists
28
+ # path = locator.locate("foo")
29
+ # # => "app/src/front_end/components/foo.html.erb"
30
+ #
31
+ # # Suppose app/src/front_end/components/bar/blah.html.erb exists
32
+ # path = locator.locate("bar/blah")
33
+ # # => "app/src/front_end/components/bar/blah.html.erb"
34
+ #
35
+ # # Suppose both app/src/front_end/components/bar/blah.html.erb and
36
+ # # app/src/front_end/other_components/bar/blah.html.erb
37
+ # # both exist
38
+ # path = locator.locate("bar/blah")
39
+ # # => raises an error since there are two matches
40
+ #
41
+ # @param [String] base_name the base name of a file that is expected to have a template. This is searched relative to the paths
42
+ # provided to the constructor, so it may have nested paths
43
+ # @return [String] path to the template for the given `base_name`
44
+ # @raises StandardError if zero or more than one templates are found
45
+ def locate(base_name)
46
+ paths_to_try = @paths.map { |path|
47
+ path / "#{base_name}.#{@extension}"
48
+ }
49
+ paths_found = paths_to_try.select { |path|
50
+ path.exist?
51
+ }
52
+ if paths_found.empty?
53
+ raise "Could not locate template for #{base_name}. Tried: #{paths_to_try.map(&:to_s).join(', ')}"
54
+ end
55
+ if paths_found.length > 1
56
+ raise "Found more than one valid pat for #{base_name}. You must rename your files to disambiguate them. These paths were all found: #{paths_found.map(&:to_s).join(', ')}"
57
+ end
58
+ return paths_found[0]
59
+ end
60
+ end
@@ -130,6 +130,10 @@ module Brut::I18n::BaseMethods
130
130
  end
131
131
  end
132
132
 
133
+ def l(date_like, format: :default)
134
+ ::I18n::l(date_like,format: format)
135
+ end
136
+
133
137
  def this_field_value
134
138
  @__this_field_value ||= ::I18n.t("general.cv.this_field", raise: true)
135
139
  end
data/lib/brut/i18n.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # I18n holds all the code useful for translating and localizing information. It's based on Ruby's I18n.
1
2
  module Brut::I18n
2
3
  autoload(:BaseMethods, "brut/i18n/base_methods")
3
4
  autoload(:ForCLI, "brut/i18n/for_cli")