brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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")