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
@@ -1,13 +1,31 @@
1
1
  module Brut
2
2
  module Framework
3
+ # Include this in your class to access some helpful methods that throw commonly-needed
4
+ # errors
3
5
  module Errors
4
6
  autoload(:Bug,"brut/framework/errors/bug")
7
+ autoload(:NotImplemented,"brut/framework/errors/not_implemented")
5
8
  autoload(:NotFound,"brut/framework/errors/not_found")
9
+ autoload(:MissingParameter,"brut/framework/errors/missing_parameter")
6
10
  autoload(:AbstractMethod,"brut/framework/errors/abstract_method")
11
+ autoload(:NoClassForPath,"brut/framework/errors/no_class_for_path")
12
+ # Raises {Brut::Framework::Errors::Bug}
13
+ #
14
+ # @param message Message to include in the error
15
+ # @raise [Brut::Framework::Errors::Bug]
7
16
  def bug!(message=nil)
8
17
  raise Brut::Framework::Errors::Bug,message
9
18
  end
19
+
20
+ # Raises {Brut::Framework::Errors::AbstractMethod}
21
+ # @raise [Brut::Framework::Errors::AbstractMethod]
22
+ def abstract_method!
23
+ raise Brut::Framework::Errors::AbstractMethod
24
+ end
10
25
  end
26
+ # Base class for errors thrown by Brut classes. Generally, Brut will not create its own version
27
+ # of an error that exists in the standard library, e.g. `ArgumentError`, so this is only a base class
28
+ # for Brut-specific error conditions.
11
29
  class Error < StandardError
12
30
  end
13
31
  end
@@ -1,17 +1,22 @@
1
- # Include this to enable methods to help with type checking. Generally, you should not use this
2
- # unless there is a real concern that someone will pass the wrong type in and it would not be obvious
3
- # that they made this mistake. Of note, this is preferred for widely used classes instead of trying
4
- # to convert arguments to whatever type the class needs.
1
+ # Include this to enable methods to help with type checking. Generally, you should not use this.
2
+ # You should only really use this if all of the following are true:
3
+ #
4
+ # * Passing in the wrong type Would Be Bad.
5
+ # * The developer passing it in would not easily be able to figure out what went wrong.
5
6
  module Brut::Framework::FussyTypeEnforcement
6
- # Perform basic type checking, ideally inside a constructor when assigning ivars
7
+ # Perform basic type checking, ideally inside a constructor when assigning ivars. This is really intended for internal
8
+ # classes that will not be exposed to user input, but rather to catch programmer bugs and programmer mis-use.
7
9
  #
8
- # value:: the value that was given
9
- # type_descriptor:: a class or an array of allowed values. If a class, value must be kind_of? that class. If an array,
10
- # value must be one of the values in the array.
11
- # variable_name_for_error_message:: the name of the variable so that error messages make sense
12
- # required:: if true, the value may not be nil. If false, nil values are allowed and no real type checking is done. Note that a
13
- # string that is blank counts as nil, so a require string must not be blank.
14
- # coerce:: if given, this is the symbol that will be used to coerce the value before type checking
10
+ # @param [Object] value the value that is to be type-checked
11
+ # @param [Class|Array<Object>] type_descriptor a class or an array of allowed values. If a class, `value` must be `kind_of?`
12
+ # that class. If an array, `value` must be one of the values in the array.
13
+ # @param [String] variable_name_for_error_message the name of the variable begin type-checked so that error messages make sense
14
+ # @param [true|false] required if true, the value may not be nil. If false, nil values are allowed. Note that in this context
15
+ # a blank string counts as `nil`, so required strings may not be blank.
16
+ # @param [Symbol|false] coerce if set, this is the symbol that will be used to coerce the value before type checking.
17
+ # For example, if you accept a string but know it should be a number, pass in `:to_i`.
18
+ # @return [Object] the value, if it matches the expectations of its type
19
+ # @raise [ArgumentError] if the value doesn't confirm to the described type
15
20
  def type!(value,type_descriptor,variable_name_for_error_message, required: false, coerce: false)
16
21
 
17
22
  value_blank = value.nil? || ( value.kind_of?(String) && value.strip == "" )
@@ -6,10 +6,17 @@ require "sequel"
6
6
  require "semantic_logger"
7
7
  require "i18n"
8
8
  require "zeitwerk"
9
+ require "opentelemetry/sdk"
10
+ require "opentelemetry/exporter/otlp"
9
11
 
10
- # Represents the Brut framework and its behavior for the app that requires it.
11
- # Essentially, this handles all default configuration and default setup behavior.
12
+
13
+ # The Master Control Program of Brut. This handles all the bootstrapping and setup of your app. You are not
14
+ # intended to use or interact with this class at all. End of line.
12
15
  class Brut::Framework::MCP
16
+
17
+ # Create the MCP.
18
+ #
19
+ # @param [Class] app_klass subclass of {Brut::Framework::App} representing the Brut app being started up and managed.
13
20
  def initialize(app_klass:)
14
21
  @config = Brut::Framework::Config.new
15
22
  @booted = false
@@ -18,6 +25,8 @@ class Brut::Framework::MCP
18
25
  self.configure!
19
26
  end
20
27
 
28
+ # Configure Brut and initialize the {Brut::Framework::App} subclass. This should, in theory, only set up values and other
29
+ # ancillary data needed to start the app. It should not connect to databases.
21
30
  def configure!
22
31
  @config.configure!
23
32
 
@@ -37,11 +46,11 @@ class Brut::Framework::MCP
37
46
  end
38
47
  SemanticLogger["Brut"].info("Logging set up")
39
48
 
40
- i18n_locales_path = Brut.container.config_dir / "i18n"
41
- locales = Dir[i18n_locales_path / "*"].map { |_|
49
+ i18n_locales_dir = Brut.container.i18n_locales_dir
50
+ locales = Dir[i18n_locales_dir / "*"].map { |_|
42
51
  Pathname(_).basename
43
52
  }
44
- ::I18n.load_path += Dir[i18n_locales_path / "**/*.rb"]
53
+ ::I18n.load_path += Dir[i18n_locales_dir / "**/*.rb"]
45
54
  ::I18n.available_locales = locales.map(&:to_s).map(&:to_sym)
46
55
 
47
56
  Brut.container.store(
@@ -71,6 +80,7 @@ class Brut::Framework::MCP
71
80
  else
72
81
  SemanticLogger["Brut"].info("Classes will not be auto-reloaded")
73
82
  end
83
+
74
84
  @loader.setup
75
85
  @app = @app_klass.new
76
86
  end
@@ -87,11 +97,30 @@ class Brut::Framework::MCP
87
97
  end
88
98
  Kernel.at_exit do
89
99
  begin
90
- Brut.container.sequel_db_handle.disconnect
100
+ Brut.container.sequel_db_handle.disconnect
91
101
  rescue Sequel::DatabaseConnectionError
92
102
  SemanticLogger["Sequel::Database"].info "Not connected to database, so not disconnecting"
93
103
  end
94
104
  end
105
+ OpenTelemetry::SDK.configure do |c|
106
+ c.service_name = @app.id
107
+ if ENV["OTEL_TRACES_EXPORTER"]
108
+ SemanticLogger[self.class].info "OTEL_TRACES_EXPORTER was set (to '#{ENV['OTEL_TRACES_EXPORTER']}'), so Brut's OTel logging is disabled"
109
+ else
110
+ c.add_span_processor(
111
+ OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
112
+ Brut::Instrumentation::LoggerSpanExporter.new
113
+ )
114
+ )
115
+ end
116
+ end
117
+
118
+ Brut.container.store(
119
+ "tracer",
120
+ OpenTelemetry::SDK::Trace::Tracer,
121
+ "Tracer for Open Telemetry",
122
+ OpenTelemetry.tracer_provider.tracer(@app.id)
123
+ )
95
124
 
96
125
  Sequel::Database.extension :pg_array
97
126
  Sequel::Database.extension :brut_instrumentation
@@ -100,9 +129,10 @@ class Brut::Framework::MCP
100
129
 
101
130
  Sequel::Model.db = sequel_db
102
131
 
103
-
104
132
  Sequel::Model.plugin :find_bang
105
133
  Sequel::Model.plugin :created_at
134
+ Sequel::Model.plugin :table_select
135
+ Sequel::Model.plugin :skip_saving_columns
106
136
 
107
137
  if !Brut.container.external_id_prefix.nil?
108
138
  Sequel::Model.plugin :external_id, global_prefix: Brut.container.external_id_prefix
@@ -113,9 +143,6 @@ class Brut::Framework::MCP
113
143
  else
114
144
  SemanticLogger["Brut"].info("Lazily loading app's classes")
115
145
  end
116
- Brut.container.instrumentation.subscribe do |event:,start:,stop:,exception:|
117
- SemanticLogger["Instrumentation"].info("#{event.category}/#{event.subcategory}/#{event.name}: #{start}/#{stop} = #{stop-start}: #{exception&.message} (#{event.details})")
118
- end
119
146
  @app.boot!
120
147
 
121
148
  require "sinatra/base"
@@ -123,14 +150,39 @@ class Brut::Framework::MCP
123
150
  @sinatra_app = Class.new(Sinatra::Base)
124
151
  @sinatra_app.include(Brut::SinatraHelpers)
125
152
 
153
+ message = if Brut.container.project_env.development?
154
+ "Form submission did not include an authenticity token. All forms must include one. To add one, use the `form_tag` helper, or include <%= component(Brut::FrontEnd::Components::Inputs::CsrfToken) %> somewhere inside your <form> tag"
155
+ else
156
+ "Forbidden"
157
+ end
126
158
  default_middlewares = [
127
- [ Rack::Protection::AuthenticityToken, [ { allow_if: ->(env) { env["PATH_INFO"] =~ /^\/__brut\// } } ] ],
159
+ [ Brut::FrontEnd::Middlewares::OpenTelemetrySpan ],
160
+ [ Brut::FrontEnd::Middlewares::AnnotateBrutOwnedPaths ],
161
+ [ Brut::FrontEnd::Middlewares::Favicon ],
162
+ [
163
+ Rack::Protection::AuthenticityToken,
164
+ [
165
+ {
166
+ allow_if: ->(env) { env["brut.owned_path"] },
167
+ message: message,
168
+ }
169
+ ]
170
+ ],
128
171
  ]
129
172
  if Brut.container.auto_reload_classes?
130
173
  default_middlewares << Brut::FrontEnd::Middlewares::ReloadApp
131
174
  end
132
175
 
133
- middlewares = default_middlewares + @app.class.middleware
176
+ middlewares = default_middlewares + @app.class.middleware.map { |(middleware,args,block)|
177
+ if !middleware.kind_of?(Class)
178
+ klass = middleware.to_s.split(/::/).reduce(Module) { |mod,part|
179
+ mod.const_get(part)
180
+ }
181
+ [ klass, args, block ]
182
+ else
183
+ [ middleware, args, block ]
184
+ end
185
+ }
134
186
 
135
187
  middlewares.each do |(middleware,args,block)|
136
188
  @sinatra_app.use(middleware,*args,&block)
@@ -158,47 +210,51 @@ class Brut::Framework::MCP
158
210
  @sinatra_app.send(method) do
159
211
  args = {}
160
212
 
161
- hook_method.parameters.each do |(type,name)|
162
- if name.to_s == "**" || name.to_s == "*"
163
- raise ArgumentError,"#{method.class}##{method.name} accepts '#{name}' and not keyword args. Define it in your class to accept the keyword arguments your method needs"
164
- end
165
- if ![ :key,:keyreq ].include?(type)
166
- raise ArgumentError,"#{name} is not a keyword arg, but is a #{type}"
213
+ Brut.container.instrumentation.span("#{klass_name}.#{method}") do |span|
214
+ hook_method.parameters.each do |(type,name)|
215
+ if name.to_s == "**" || name.to_s == "*"
216
+ raise ArgumentError,"#{method.class}##{method.name} accepts '#{name}' and not keyword args. Define it in your class to accept the keyword arguments your method needs"
217
+ end
218
+ if ![ :key,:keyreq ].include?(type)
219
+ raise ArgumentError,"#{name} is not a keyword arg, but is a #{type}"
220
+ end
221
+
222
+ if name == :request_context
223
+ args[name] = Thread.current.thread_variable_get(:request_context)
224
+ elsif name == :session
225
+ args[name] = Brut.container.session_class.new(rack_session: session)
226
+ elsif name == :request
227
+ args[name] = request
228
+ elsif name == :response
229
+ args[name] = response
230
+ elsif name == :env
231
+ args[name] = env
232
+ elsif type == :keyreq
233
+ raise ArgumentError,"#{method} argument '#{name}' is required, but it's not available in a #{method} hook"
234
+ else
235
+ # this keyword arg has a default value which will be used
236
+ end
167
237
  end
168
238
 
169
- if name == :request_context
170
- args[name] = Thread.current.thread_variable_get(:request_context)
171
- elsif name == :app_session
172
- args[name] = Brut.container.session_class.new(rack_session: session)
173
- elsif name == :request
174
- args[name] = request
175
- elsif name == :response
176
- args[name] = response
177
- elsif name == :env
178
- args[name] = env
179
- elsif type == :keyreq
180
- raise ArgumentError,"#{method} argument '#{name}' is required, but it's not available in a #{method} hook"
239
+ hook = klass.new
240
+ span.add_prefixed_attributes("#{method}.args",args.map { |k,v| [ k,v.class] }.to_h )
241
+ result = hook.send(method,**args)
242
+ span.add_attributes(result:)
243
+ case result
244
+ in URI => uri
245
+ redirect to(uri.to_s)
246
+ in Brut::FrontEnd::HttpStatus => http_status
247
+ halt http_status.to_i
248
+ in FalseClass
249
+ halt 500
250
+ in NilClass
251
+ nil
252
+ in TrueClass
253
+ nil
181
254
  else
182
- # this keyword arg has a default value which will be used
255
+ raise NoMatchingPatternError, "Result from #{method} hook #{klass}'s #{method} method was a #{result.class} (#{result.to_s} as a string), which cannot be used to understand the response to generate. Return nil or true if processing should proceed"
183
256
  end
184
257
  end
185
-
186
- hook = klass.new
187
- result = hook.send(method,**args)
188
- case result
189
- in URI => uri
190
- redirect to(uri.to_s)
191
- in Brut::FrontEnd::HttpStatus => http_status
192
- halt http_status.to_i
193
- in FalseClass
194
- halt 500
195
- in NilClass
196
- nil
197
- in TrueClass
198
- nil
199
- else
200
- raise NoMatchingPatternError, "Result from #{method} hook #{klass}'s #{method} method was a #{result.class} (#{result.to_s} as a string), which cannot be used to understand the response to generate. Return nil or true if processing should proceed"
201
- end
202
258
  end
203
259
  end
204
260
  end
@@ -208,8 +264,9 @@ class Brut::Framework::MCP
208
264
 
209
265
  @booted = true
210
266
  end
267
+ # @!visibility private
211
268
  def sinatra_app = @sinatra_app
269
+ # @!visibility private
212
270
  def app = @app
213
271
 
214
-
215
272
  end
@@ -1,4 +1,9 @@
1
+ # Manages the interpretation of dev/test/prod. The canonical instance is available via `Brut.container.project_env`. Generally, you
2
+ # should avoid basing logic on this, or at least contain the conditional behavior to the configuration values. But, you do you.
1
3
  class Brut::Framework::ProjectEnvironment
4
+ # Create the project environment based on the string
5
+ # @param [String] string_value value from e.g. `ENV["RACK_ENV"]` to use to set the environment
6
+ # @raise [ArgumentError] if the string does not map to a known environment.
2
7
  def initialize(string_value)
3
8
  @value = case string_value
4
9
  when "development" then "development"
@@ -9,10 +14,14 @@ class Brut::Framework::ProjectEnvironment
9
14
  end
10
15
  end
11
16
 
17
+ # @return [true|false] true is this is development
12
18
  def development? = @value == "development"
19
+ # @return [true|false] true is this is test
13
20
  def test? = @value == "test"
21
+ # @return [true|false] true is this is production
14
22
  def production? = @value == "production"
15
23
 
24
+ # @return [String] the string value (which should be suitable for the constructor)
16
25
  def to_s = @value
17
26
  end
18
27
 
@@ -1,4 +1,5 @@
1
1
  module Brut
2
+ # The Framework module holds a lot of Brut's internals, or classes that cut across the back end and front end.
2
3
  module Framework
3
4
  autoload(:App,"brut/framework/app")
4
5
  autoload(:Config,"brut/framework/config")
@@ -1,5 +1,10 @@
1
-
1
+ # Class to provide access to the asset metadata used to serve up hashed assets. Generally, you will not interact with this class.
2
+ #
3
+ # @!visibility private
2
4
  class Brut::FrontEnd::AssetMetadata
5
+
6
+ # @param [String] asset_metadata_file to the asset metadata file
7
+ # @param [IO] out IO on which to write messaging
3
8
  def initialize(asset_metadata_file:,out:$stdout)
4
9
  @asset_metadata_file = asset_metadata_file
5
10
  @out = out
@@ -49,6 +54,7 @@ class Brut::FrontEnd::AssetMetadata
49
54
  end
50
55
  end
51
56
 
57
+ # @!visibility private
52
58
  class ESBuildMetafile
53
59
  def initialize(metafile:)
54
60
  @metafile = metafile
@@ -2,46 +2,40 @@ require "json"
2
2
  require "rexml"
3
3
  require_relative "template"
4
4
 
5
+ # Components holds Brut-provided components that are of general use to any web app
5
6
  module Brut::FrontEnd::Components
6
7
  autoload(:FormTag,"brut/front_end/components/form_tag")
7
8
  autoload(:Input,"brut/front_end/components/input")
8
9
  autoload(:Inputs,"brut/front_end/components/input")
9
10
  autoload(:I18nTranslations,"brut/front_end/components/i18n_translations")
10
- autoload(:Timestamp,"brut/front_end/components/timestamp")
11
+ autoload(:Time,"brut/front_end/components/time")
11
12
  autoload(:PageIdentifier,"brut/front_end/components/page_identifier")
12
13
  autoload(:LocaleDetection,"brut/front_end/components/locale_detection")
14
+ autoload(:ConstraintViolations,"brut/front_end/components/constraint_violations")
15
+ autoload(:Traceparent,"brut/front_end/components/traceparent")
13
16
  end
17
+
14
18
  # A Component is the top level class for managing the rendering of
15
19
  # content. A component is essentially an ERB template and a class whose
16
- # instance servces as it's binding.
20
+ # instance servces as it's binding. It is very similar to a View Component, though
21
+ # not quite as fancy.
22
+ #
23
+ # When subclassing this to create a component, your initializer's signature will determine what data
24
+ # is required for your component to work. It can be anything, just keep in mind that any page or component
25
+ # that uses your component must be able to provide those values.
26
+ #
27
+ # If your component does not override {#render} (which, generally, it won't), an ERB file is expected to exist alongside it in the
28
+ # app. For example, if you have a component named `Auth::LoginButtonComponent`, it would expected to be in
29
+ # `app/src/front_end/components/auth/login_button_component.rb`. Thus, Brut will also expect
30
+ # `app/src/front_end/components/auth/login_button_component.html.erb` to exist as well. That ERB file is used with an instance of your
31
+ # component's class to render the component's HTML.
17
32
  #
18
- # The component has a few more smarts and helpers.
33
+ # @see Brut::FrontEnd::Component::Helpers
19
34
  class Brut::FrontEnd::Component
20
35
  using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
21
36
 
22
- class TemplateLocator
23
- def initialize(paths:, extension:)
24
- @paths = Array(paths).map { |path| Pathname(path) }
25
- @extension = extension
26
- end
27
-
28
- def locate(base_name)
29
- paths_to_try = @paths.map { |path|
30
- path / "#{base_name}.#{@extension}"
31
- }
32
- paths_found = paths_to_try.select { |path|
33
- path.exist?
34
- }
35
- if paths_found.empty?
36
- raise "Could not locate template for #{base_name}. Tried: #{paths_to_try.map(&:to_s).join(', ')}"
37
- end
38
- if paths_found.length > 1
39
- 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(', ')}"
40
- end
41
- return paths_found[0]
42
- end
43
- end
44
37
 
38
+ # @!visibility private
45
39
  class AssetPathResolver
46
40
  def initialize(metadata_file:)
47
41
  @metadata_file = metadata_file
@@ -58,8 +52,10 @@ class Brut::FrontEnd::Component
58
52
  end
59
53
  end
60
54
 
55
+ # Allows helpers that create components to pass the block they were given to the component
61
56
  attr_writer :yielded_block
62
57
 
58
+ # Intended to be called by subclasses to render the yielded block wherever it makes sense in their markup.
63
59
  def render_yielded_block
64
60
  if @yielded_block
65
61
  @yielded_block.().html_safe!
@@ -69,17 +65,25 @@ class Brut::FrontEnd::Component
69
65
  end
70
66
 
71
67
  # The core method of a component. This is expected to return
72
- # a string to be sent as a response to an HTTP request.
68
+ # a string to be sent as a response to an HTTP request. Generally, you should not call this method
69
+ # as it is intended to be called from {Brut::FrontEnd::Component::Helpers#component}.
73
70
  #
74
71
  # This implementation uses the associated template for the component
75
72
  # and sends it through ERB using this component as
76
73
  # the binding.
74
+ #
75
+ # You may override this method to provide your own HTML for the component. In doing so, you can add
76
+ # keyword args for data from the `RequestContext` you wish to receive. See {Brut::FrontEnd::RequestContext#as_method_args}.
77
+ #
78
+ # @return [Brut::FrontEnd::Templates::HTMLSafeString] string containing the component's HTML.
77
79
  def render
78
80
  Brut.container.component_locator.locate(self.template_name).
79
81
  then { |erb_file| Brut::FrontEnd::Template.new(erb_file) }.
80
82
  then { |template| template.render_template(self).html_safe! }
81
83
  end
82
84
 
85
+ # For components that are private to a page, this returns the name of the page they are a part of.
86
+ # This is used to allow a component to render a page's I18n strings.
83
87
  def page_name
84
88
  @page_name ||= begin
85
89
  page = self.class.name.split(/::/).reduce(Module) { |accumulator,class_path_part|
@@ -97,7 +101,9 @@ class Brut::FrontEnd::Component
97
101
  end
98
102
  end
99
103
 
104
+ # Used when an I18n string needs access to component-specific translations
100
105
  def self.component_name = self.name
106
+ # (see .component_name)
101
107
  def component_name = self.class.component_name
102
108
 
103
109
  # Helper methods that subclasses can use.
@@ -110,27 +116,45 @@ class Brut::FrontEnd::Component
110
116
 
111
117
  # Render a component. This is the primary way in which
112
118
  # view re-use happens. The component instance will be able to locate its
113
- # HTML template and render itself.
119
+ # HTML template and render itself. {#render} is called with variables from the `RequestContext`
120
+ # as described in {Brut::FrontEnd::RequestContext#as_method_args}
121
+ #
122
+ # @param [Brut::FrontEnd::Component|Class] component_instance instance of the component to render. If a `Class`
123
+ # is passed, it must extend {Brut::FrontEnd::Component}. It will created
124
+ # based on the logic described in {Brut::FrontEnd::RequestContext#as_constructor_args}.
125
+ # You would do this if your component needs to be injected with information
126
+ # not available to the page or component that is using it.
127
+ # @yield this block is passed to the `component_instance` via {#yielded_block=}.
128
+ #
129
+ # @return [Brut::FrontEnd::Templates::HTMLSafeString] of the rendered component.
114
130
  def component(component_instance,&block)
131
+ component_name = component_instance.kind_of?(Class) ? component_instance.name : component_instance.class.name
132
+ Brut.container.instrumentation.span(component_name) do |span|
115
133
  if component_instance.kind_of?(Class)
116
134
  if !component_instance.ancestors.include?(Brut::FrontEnd::Component)
117
135
  raise ArgumentError,"#{component_instance} is not a component and cannot be created"
118
136
  end
119
- Thread.current.thread_variable_get(:request_context).
137
+ component_instance = Thread.current.thread_variable_get(:request_context).
120
138
  then { |request_context| request_context.as_constructor_args(component_instance,request_params: nil)
121
- }.then { |constructor_args| component_instance.new(**constructor_args)
122
- } => component_instance
139
+ }.then { |constructor_args| component_instance.new(**constructor_args) }
140
+ span.add_attributes("global_component" => true, "class" => component_instance.class.name)
141
+ else
142
+ span.add_attributes("global_component" => false, "class" => component_instance.class.name)
123
143
  end
124
144
  if !block.nil?
125
145
  component_instance.yielded_block = block
126
146
  end
127
- request_context = Thread.current.thread_variable_get(:request_context).
128
- then { |request_context| request_context.as_method_args(component_instance,:render,request_params: nil, form: nil)
129
- }.then { |render_args| component_instance.render(**render_args).html_safe!
147
+ Thread.current.thread_variable_get(:request_context).then {
148
+ it.as_method_args(component_instance,:render,request_params: nil, form: nil)
149
+ }.then { |render_args|
150
+ component_instance.render(**render_args).html_safe!
130
151
  }
152
+ end
131
153
  end
132
154
 
133
155
  # Inline an SVG into the page.
156
+ #
157
+ # @param [String] svg name of an SVG file, relative to where SVGs are stored.
134
158
  def svg(svg)
135
159
  Brut.container.svg_locator.locate(svg).then { |svg_file|
136
160
  File.read(svg_file).html_safe!
@@ -141,19 +165,67 @@ class Brut::FrontEnd::Component
141
165
  # the same value, but with any content hashes that are part of the filename.
142
166
  def asset_path(path) = Brut.container.asset_path_resolver.resolve(path)
143
167
 
144
- # Render a form that should include CSRF protection.
145
- def form_tag(**attributes,&block)
146
- component(Brut::FrontEnd::Components::FormTag.new(**attributes,&block))
168
+ # Creates the form surrounding the contents of the block yielded to it. If the form's action is a POST, it will include a CSRF token.
169
+ # If the form's action is GET, it will not.
170
+ #
171
+ # @example Route without parameters
172
+ # <%= form_tag(for: NewWidgetForm, class: "new-form") do %>
173
+ # <input type="text" name="name">
174
+ # <button>Create</button>
175
+ # <% end %>
176
+ #
177
+ # @example Route with parameters
178
+ # <%= form_tag(for: SaveWidgetWithIdForm, route_params: { id: widget.external_id }, class: "new-form") do %>
179
+ # <input type="text" name="name">
180
+ # <button>Save</button>
181
+ # <% end %>
182
+ #
183
+ # @param route_params [Hash] if the form requires route parameters, their values must be passed here so that the HTML `action`
184
+ # attribute can be constructed properly.
185
+ # @param html_attributes [Hash] any additional attributes for the `<form>` tag
186
+ # @option html_attributes [Class|Brut::FrontEnd::Form] :for the form object or class representing this HTML form. If you pass this, you may not pass the HTML attributes `:action` or `:method`. Both will be derived from this object.
187
+ # @option html_attributes [String] «any-other-key» attributes to set on the `<form>` tag
188
+ # @yield No parameters given. This is expected to return additional markup to appear inside the `<form>` element.
189
+ def form_tag(route_params: {}, **html_attributes,&contents)
190
+ component(Brut::FrontEnd::Components::FormTag.new(route_params:, **html_attributes,&contents))
191
+ end
192
+
193
+ # Creates a {Brut::FrontEnd::Components::Time}.
194
+ #
195
+ # @param timestamp [Time] the timestamp to format/render. Mutually exclusive with `date`.
196
+ # @param date [Date] the date to format/render. Mutually exclusive with `timestamp`.
197
+ # @param component_options [Hash] keyword arguments to pass to {Brut::FrontEnd::Components::Time#initialize}
198
+ def time_tag(timestamp:nil,date:nil, **component_options)
199
+ args = component_options.merge(timestamp:,date:)
200
+ component(Brut::FrontEnd::Components::Time.new(**args))
201
+ end
202
+
203
+ # Render the {Brut::FrontEnd::Components::ConstraintViolations} component for the given form's input.
204
+ def constraint_violations(form:, input_name:, message_html_attributes: {}, **html_attributes)
205
+ component(
206
+ Brut::FrontEnd::Components::ConstraintViolations.new(
207
+ form:,
208
+ input_name:,
209
+ message_html_attributes:,
210
+ **html_attributes
211
+ )
212
+ )
147
213
  end
148
214
 
149
- def timestamp(timestamp, **component_options)
150
- component(Brut::FrontEnd::Components::Timestamp.new(**(component_options.merge(timestamp:))))
215
+ # Create an HTML input tag for the given input of a form. This is a convieniece method
216
+ # that calls {Brut::FrontEnd::Components::Input::TextField.for_form_input}.
217
+ def input_tag(form:, input_name:, index: nil, **html_attributes)
218
+ component(Brut::FrontEnd::Components::Inputs::TextField.for_form_input(form:,input_name:,index:,html_attributes:))
151
219
  end
152
220
 
221
+ # Indicates a given string is safe to render directly as HTML. No escaping will happen.
222
+ #
223
+ # @param [String] string a string that should be marked as HTML safe
153
224
  def html_safe!(string)
154
225
  string.html_safe!
155
226
  end
156
227
 
228
+ # @!visibility private
157
229
  VOID_ELEMENTS = [
158
230
  :area,
159
231
  :base,
@@ -170,6 +242,25 @@ class Brut::FrontEnd::Component
170
242
  :wbr,
171
243
  ]
172
244
 
245
+ # Generate an HTML element safely in code. This is useful if you don't want to create
246
+ # a separate ERB file, but still want to create a component.
247
+ #
248
+ # @param [String|Symbol] tag_name the name of the HTML tag to create.
249
+ # @param [Hash] html_attributes all the HTML attributes you wish to include in the element that is generated. Values that
250
+ # are `true` will be included without a value, and values that are `false` will be omitted.
251
+ # @yield Called to get any contents that should be put into this tag. Void elements as defined by W3C may not have a block.
252
+ #
253
+ # @example Void element
254
+ #
255
+ # html_tag(:img, src: "trellick.png") # => <img src="trellic.png">
256
+ #
257
+ # @example Nested elements
258
+ #
259
+ # html_tag(:nav, class: "flex items-center") do
260
+ # html_tag(:a, href="/") { "Home" } +
261
+ # html_tag(:a, href="/about") { "About" } +
262
+ # html_tag(:a, href="/contact") { "Contact" }
263
+ # end
173
264
  def html_tag(tag_name, **html_attributes, &block)
174
265
  tag_name = tag_name.to_s.downcase.to_sym
175
266
  attributes_string = html_attributes.map { |key,value|