brut 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/CODE_OF_CONDUCT.txt +99 -0
  4. data/Dockerfile.dx +32 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +133 -0
  7. data/LICENSE.txt +370 -0
  8. data/README.md +21 -0
  9. data/Rakefile +1 -0
  10. data/bin/bin_kit.rb +39 -0
  11. data/bin/rake +27 -0
  12. data/bin/setup +145 -0
  13. data/brut.gemspec +60 -0
  14. data/docker-compose.dx.yml +16 -0
  15. data/dx/build +26 -0
  16. data/dx/docker-compose.env +22 -0
  17. data/dx/dx.sh.lib +24 -0
  18. data/dx/exec +58 -0
  19. data/dx/prune +19 -0
  20. data/dx/setupkit.sh.lib +144 -0
  21. data/dx/show-help-in-app-container-then-wait.sh +38 -0
  22. data/dx/start +30 -0
  23. data/dx/stop +23 -0
  24. data/lib/brut/back_end/action.rb +3 -0
  25. data/lib/brut/back_end/result.rb +46 -0
  26. data/lib/brut/back_end/seed_data.rb +24 -0
  27. data/lib/brut/back_end/validator.rb +3 -0
  28. data/lib/brut/back_end/validators/form_validator.rb +37 -0
  29. data/lib/brut/cli/app.rb +130 -0
  30. data/lib/brut/cli/app_runner.rb +219 -0
  31. data/lib/brut/cli/apps/build_assets.rb +123 -0
  32. data/lib/brut/cli/apps/db.rb +279 -0
  33. data/lib/brut/cli/apps/scaffold.rb +256 -0
  34. data/lib/brut/cli/apps/test.rb +200 -0
  35. data/lib/brut/cli/command.rb +130 -0
  36. data/lib/brut/cli/error.rb +12 -0
  37. data/lib/brut/cli/execution_results.rb +81 -0
  38. data/lib/brut/cli/executor.rb +37 -0
  39. data/lib/brut/cli/options.rb +46 -0
  40. data/lib/brut/cli/output.rb +30 -0
  41. data/lib/brut/cli.rb +24 -0
  42. data/lib/brut/factory_bot.rb +20 -0
  43. data/lib/brut/framework/app.rb +55 -0
  44. data/lib/brut/framework/config.rb +415 -0
  45. data/lib/brut/framework/container.rb +190 -0
  46. data/lib/brut/framework/errors/abstract_method.rb +9 -0
  47. data/lib/brut/framework/errors/bug.rb +14 -0
  48. data/lib/brut/framework/errors/not_found.rb +10 -0
  49. data/lib/brut/framework/errors.rb +14 -0
  50. data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
  51. data/lib/brut/framework/mcp.rb +215 -0
  52. data/lib/brut/framework/project_environment.rb +18 -0
  53. data/lib/brut/framework.rb +13 -0
  54. data/lib/brut/front_end/asset_metadata.rb +76 -0
  55. data/lib/brut/front_end/component.rb +213 -0
  56. data/lib/brut/front_end/components/form_tag.rb +71 -0
  57. data/lib/brut/front_end/components/i18n_translations.rb +36 -0
  58. data/lib/brut/front_end/components/input.rb +13 -0
  59. data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
  60. data/lib/brut/front_end/components/inputs/select.rb +100 -0
  61. data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
  62. data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
  63. data/lib/brut/front_end/components/locale_detection.rb +25 -0
  64. data/lib/brut/front_end/components/page_identifier.rb +13 -0
  65. data/lib/brut/front_end/components/timestamp.rb +33 -0
  66. data/lib/brut/front_end/download.rb +23 -0
  67. data/lib/brut/front_end/flash.rb +57 -0
  68. data/lib/brut/front_end/form.rb +171 -0
  69. data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
  70. data/lib/brut/front_end/forms/input.rb +119 -0
  71. data/lib/brut/front_end/forms/input_definition.rb +100 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +36 -0
  73. data/lib/brut/front_end/handler.rb +48 -0
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
  75. data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
  76. data/lib/brut/front_end/handling_results.rb +14 -0
  77. data/lib/brut/front_end/http_method.rb +33 -0
  78. data/lib/brut/front_end/http_status.rb +16 -0
  79. data/lib/brut/front_end/middleware.rb +7 -0
  80. data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
  81. data/lib/brut/front_end/page.rb +47 -0
  82. data/lib/brut/front_end/request_context.rb +82 -0
  83. data/lib/brut/front_end/route_hook.rb +15 -0
  84. data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
  85. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
  86. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
  87. data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
  88. data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
  89. data/lib/brut/front_end/routing.rb +236 -0
  90. data/lib/brut/front_end/session.rb +56 -0
  91. data/lib/brut/front_end/template.rb +32 -0
  92. data/lib/brut/front_end/templates/block_filter.rb +60 -0
  93. data/lib/brut/front_end/templates/erb_engine.rb +26 -0
  94. data/lib/brut/front_end/templates/erb_parser.rb +84 -0
  95. data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
  96. data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
  97. data/lib/brut/i18n/base_methods.rb +168 -0
  98. data/lib/brut/i18n/for_cli.rb +4 -0
  99. data/lib/brut/i18n/for_html.rb +4 -0
  100. data/lib/brut/i18n/http_accept_language.rb +68 -0
  101. data/lib/brut/i18n.rb +6 -0
  102. data/lib/brut/instrumentation/basic.rb +66 -0
  103. data/lib/brut/instrumentation/event.rb +19 -0
  104. data/lib/brut/instrumentation/http_event.rb +5 -0
  105. data/lib/brut/instrumentation/subscriber.rb +41 -0
  106. data/lib/brut/instrumentation.rb +11 -0
  107. data/lib/brut/junk_drawer.rb +88 -0
  108. data/lib/brut/sinatra_helpers.rb +183 -0
  109. data/lib/brut/spec_support/component_support.rb +49 -0
  110. data/lib/brut/spec_support/flash_support.rb +7 -0
  111. data/lib/brut/spec_support/general_support.rb +18 -0
  112. data/lib/brut/spec_support/handler_support.rb +7 -0
  113. data/lib/brut/spec_support/matcher.rb +9 -0
  114. data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
  115. data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
  116. data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
  117. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
  118. data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
  119. data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
  120. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
  121. data/lib/brut/spec_support/session_support.rb +3 -0
  122. data/lib/brut/spec_support.rb +12 -0
  123. data/lib/brut/version.rb +3 -0
  124. data/lib/brut.rb +38 -0
  125. data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
  126. data/lib/sequel/extensions/brut_migrations.rb +98 -0
  127. data/lib/sequel/plugins/created_at.rb +14 -0
  128. data/lib/sequel/plugins/external_id.rb +45 -0
  129. data/lib/sequel/plugins/find_bang.rb +13 -0
  130. data/lib/sequel/plugins.rb +3 -0
  131. metadata +484 -0
@@ -0,0 +1,215 @@
1
+ require_relative "container"
2
+ require_relative "config"
3
+ require_relative "../junk_drawer"
4
+ require_relative "app"
5
+ require "sequel"
6
+ require "semantic_logger"
7
+ require "i18n"
8
+ require "zeitwerk"
9
+
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
+ class Brut::Framework::MCP
13
+ def initialize(app_klass:)
14
+ @config = Brut::Framework::Config.new
15
+ @booted = false
16
+ @loader = Zeitwerk::Loader.new
17
+ @app_klass = app_klass
18
+ self.configure!
19
+ end
20
+
21
+ def configure!
22
+ @config.configure!
23
+
24
+ project_root = Brut.container.project_root
25
+ project_env = Brut.container.project_env
26
+
27
+ SemanticLogger.default_level = Brut.container.log_level
28
+ semantic_logger_appenders = Brut.container.semantic_logger_appenders
29
+ if semantic_logger_appenders.kind_of?(Hash)
30
+ semantic_logger_appenders = [ semantic_logger_appenders ]
31
+ end
32
+ if semantic_logger_appenders.length == 0
33
+ raise "No loggers are set up - something is wrong"
34
+ end
35
+ semantic_logger_appenders.each do |appender|
36
+ SemanticLogger.add_appender(**appender)
37
+ end
38
+ SemanticLogger["Brut"].info("Logging set up")
39
+
40
+ i18n_locales_path = Brut.container.config_dir / "i18n"
41
+ locales = Dir[i18n_locales_path / "*"].map { |_|
42
+ Pathname(_).basename
43
+ }
44
+ ::I18n.load_path += Dir[i18n_locales_path / "**/*.rb"]
45
+ ::I18n.available_locales = locales.map(&:to_s).map(&:to_sym)
46
+
47
+ Brut.container.store(
48
+ "zeitwerk_loader",
49
+ @loader.class,
50
+ "Zeitwerk Loader configured for this app",
51
+ @loader
52
+ )
53
+
54
+ Dir[Brut.container.front_end_src_dir / "*"].each do |dir|
55
+ if Pathname(dir).directory?
56
+ @loader.push_dir(dir)
57
+ end
58
+ end
59
+ Dir[Brut.container.back_end_src_dir / "*"].each do |dir|
60
+ if Pathname(dir).directory?
61
+ @loader.push_dir(dir)
62
+ end
63
+ end
64
+ @loader.ignore(Brut.container.migrations_dir)
65
+ @loader.inflector.inflect(
66
+ "db" => "DB"
67
+ )
68
+ if Brut.container.auto_reload_classes?
69
+ SemanticLogger["Brut"].info("Auto-reloaded configured")
70
+ @loader.enable_reloading
71
+ else
72
+ SemanticLogger["Brut"].info("Classes will not be auto-reloaded")
73
+ end
74
+ @loader.setup
75
+ @app = @app_klass.new
76
+ end
77
+
78
+ # Starts up the internals of Brut and that app so that it can receive requests from
79
+ # the web server. This *can* make network connections to establish connectivity
80
+ # to external resources.
81
+ def boot!
82
+ if @booted
83
+ raise "already booted!"
84
+ end
85
+ if Brut.container.debug_zeitwerk?
86
+ @loader.log!
87
+ end
88
+ Kernel.at_exit do
89
+ begin
90
+ Brut.container.sequel_db_handle.disconnect
91
+ rescue Sequel::DatabaseConnectionError
92
+ SemanticLogger["Sequel::Database"].info "Not connected to database, so not disconnecting"
93
+ end
94
+ end
95
+
96
+ Sequel::Database.extension :pg_array
97
+ Sequel::Database.extension :brut_instrumentation
98
+
99
+ sequel_db = Brut.container.sequel_db_handle
100
+
101
+ Sequel::Model.db = sequel_db
102
+
103
+
104
+ Sequel::Model.plugin :find_bang
105
+ Sequel::Model.plugin :created_at
106
+
107
+ if !Brut.container.external_id_prefix.nil?
108
+ Sequel::Model.plugin :external_id, global_prefix: Brut.container.external_id_prefix
109
+ end
110
+ if Brut.container.eager_load_classes?
111
+ SemanticLogger["Brut"].info("Eagerly loading app's classes")
112
+ @loader.eager_load
113
+ else
114
+ SemanticLogger["Brut"].info("Lazily loading app's classes")
115
+ 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
+ @app.boot!
120
+
121
+ require "sinatra/base"
122
+
123
+ @sinatra_app = Class.new(Sinatra::Base)
124
+ @sinatra_app.include(Brut::SinatraHelpers)
125
+
126
+ default_middlewares = [
127
+ [ Rack::Protection::AuthenticityToken, [ { allow_if: ->(env) { env["PATH_INFO"] =~ /^\/__brut\// } } ] ],
128
+ ]
129
+ if Brut.container.auto_reload_classes?
130
+ default_middlewares << Brut::FrontEnd::Middlewares::ReloadApp
131
+ end
132
+
133
+ middlewares = default_middlewares + @app.class.middleware
134
+
135
+ middlewares.each do |(middleware,args,block)|
136
+ @sinatra_app.use(middleware,*args,&block)
137
+ end
138
+ befores = [
139
+ Brut::FrontEnd::RouteHooks::SetupRequestContext,
140
+ Brut::FrontEnd::RouteHooks::LocaleDetection,
141
+ ] + @app.class.before
142
+
143
+ afters = [
144
+ Brut::FrontEnd::RouteHooks::AgeFlash,
145
+ Brut.container.csp_class,
146
+ Brut.container.csp_reporting_class,
147
+ ].compact + @app.class.after
148
+
149
+ [
150
+ [ befores, :before ],
151
+ [ afters, :after ],
152
+ ].each do |hooks,method|
153
+ hooks.each do |klass_name|
154
+ klass = klass_name.to_s.split(/::/).reduce(Module) { |mod,part|
155
+ mod.const_get(part)
156
+ }
157
+ hook_method = klass.instance_method(method)
158
+ @sinatra_app.send(method) do
159
+ args = {}
160
+
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}"
167
+ end
168
+
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"
181
+ else
182
+ # this keyword arg has a default value which will be used
183
+ end
184
+ 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
+ end
203
+ end
204
+ end
205
+ @app.class.routes.each do |route_block|
206
+ @sinatra_app.instance_eval(&route_block)
207
+ end
208
+
209
+ @booted = true
210
+ end
211
+ def sinatra_app = @sinatra_app
212
+ def app = @app
213
+
214
+
215
+ end
@@ -0,0 +1,18 @@
1
+ class Brut::Framework::ProjectEnvironment
2
+ def initialize(string_value)
3
+ @value = case string_value
4
+ when "development" then "development"
5
+ when "test" then "test"
6
+ when "production" then "production"
7
+ else
8
+ raise ArgumentError.new("'#{string_value}' is not a valid project environment")
9
+ end
10
+ end
11
+
12
+ def development? = @value == "development"
13
+ def test? = @value == "test"
14
+ def production? = @value == "production"
15
+
16
+ def to_s = @value
17
+ end
18
+
@@ -0,0 +1,13 @@
1
+ module Brut
2
+ module Framework
3
+ autoload(:App,"brut/framework/app")
4
+ autoload(:Config,"brut/framework/config")
5
+ autoload(:Container,"brut/framework/container")
6
+ autoload(:MCP,"brut/framework/mcp")
7
+ autoload(:ProjectEnvironment,"brut/framework/project_environment")
8
+ autoload(:Error,"brut/framework/errors")
9
+ autoload(:Errors,"brut/framework/errors")
10
+ autoload(:FussyTypeEnforcement,"brut/framework/fussy_type_enforcement")
11
+ end
12
+ end
13
+ require_relative "framework/mcp"
@@ -0,0 +1,76 @@
1
+
2
+ class Brut::FrontEnd::AssetMetadata
3
+ def initialize(asset_metadata_file:,out:$stdout)
4
+ @asset_metadata_file = asset_metadata_file
5
+ @out = out
6
+ @asset_metadata = nil
7
+ end
8
+
9
+ def merge!(extension:,esbuild_metafile:)
10
+ @out.puts "Parsing metafile '#{esbuild_metafile}'"
11
+ esbuild_metafile = ESBuildMetafile.new(metafile:esbuild_metafile)
12
+ metadata = esbuild_metafile.parse(extension:)
13
+ begin
14
+ self.load!
15
+ rescue Errno::ENOENT
16
+ @out.puts "'#{@asset_metadata_file}' does not exist - creating it"
17
+ @asset_metadata = {}
18
+ end
19
+ existing_metadata = @asset_metadata[extension] || {}
20
+ @asset_metadata[extension] = existing_metadata.merge(metadata)
21
+ end
22
+
23
+ def load!
24
+ metadata = JSON.parse(File.read(@asset_metadata_file))
25
+ if !metadata.key?("asset_metadata")
26
+ raise "Asset metadata file '#{@asset_metadata_file}' is corrupted. There is no top-level 'asset_metadata' key"
27
+ end
28
+ @asset_metadata = metadata["asset_metadata"]
29
+ end
30
+
31
+ def resolve(path)
32
+ extension = File.extname(path)
33
+ @asset_metadata ||= {}
34
+ if @asset_metadata[extension]
35
+ if @asset_metadata[extension][path]
36
+ @asset_metadata[extension][path]
37
+ else
38
+ raise "Asset metadata does not have a mapping for '#{path}'"
39
+ end
40
+ else
41
+ raise "Asset metadata has not been set up for files with extension '#{extension}'"
42
+ end
43
+ end
44
+
45
+ def save!
46
+ @out.puts "Writing updated asset metadata file '#{@asset_metadata_file}'"
47
+ File.open(@asset_metadata_file,"w") do |file|
48
+ file.puts({ "asset_metadata" => @asset_metadata }.to_json)
49
+ end
50
+ end
51
+
52
+ class ESBuildMetafile
53
+ def initialize(metafile:)
54
+ @metafile = metafile
55
+ end
56
+
57
+ def parse(extension:)
58
+ metafile_contents = JSON.parse(File.read(@metafile))
59
+
60
+ name_with_hash_regexp = /app\/public\/(?<path>.+)\/(?<name>.+)\-(?<hash>.+)#{Regexp.escape(extension)}/
61
+ metadata = metafile_contents["outputs"].keys.map { |key|
62
+ match_data = key.match(name_with_hash_regexp)
63
+ if match_data
64
+ path = match_data[:path]
65
+ name = match_data[:name]
66
+ hash = match_data[:hash]
67
+
68
+ [ "/#{path}/#{name}#{extension}", "/#{path}/#{name}-#{hash}#{extension}" ]
69
+ else
70
+ nil
71
+ end
72
+ }.compact.to_h
73
+ end
74
+ end
75
+
76
+ end
@@ -0,0 +1,213 @@
1
+ require "json"
2
+ require "rexml"
3
+ require_relative "template"
4
+
5
+ module Brut::FrontEnd::Components
6
+ autoload(:FormTag,"brut/front_end/components/form_tag")
7
+ autoload(:Input,"brut/front_end/components/input")
8
+ autoload(:Inputs,"brut/front_end/components/input")
9
+ autoload(:I18nTranslations,"brut/front_end/components/i18n_translations")
10
+ autoload(:Timestamp,"brut/front_end/components/timestamp")
11
+ autoload(:PageIdentifier,"brut/front_end/components/page_identifier")
12
+ autoload(:LocaleDetection,"brut/front_end/components/locale_detection")
13
+ end
14
+ # A Component is the top level class for managing the rendering of
15
+ # content. A component is essentially an ERB template and a class whose
16
+ # instance servces as it's binding.
17
+ #
18
+ # The component has a few more smarts and helpers.
19
+ class Brut::FrontEnd::Component
20
+ using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
21
+
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
+
45
+ class AssetPathResolver
46
+ def initialize(metadata_file:)
47
+ @metadata_file = metadata_file
48
+ reload
49
+ end
50
+
51
+ def reload
52
+ @asset_metadata = Brut::FrontEnd::AssetMetadata.new(asset_metadata_file: @metadata_file)
53
+ @asset_metadata.load!
54
+ end
55
+
56
+ def resolve(path)
57
+ @asset_metadata.resolve(path)
58
+ end
59
+ end
60
+
61
+ attr_writer :yielded_block
62
+
63
+ def render_yielded_block
64
+ if @yielded_block
65
+ @yielded_block.().html_safe!
66
+ else
67
+ raise Brut::FrontEnd::Errors::Bug, "No block was yielded to #{self.class.name}"
68
+ end
69
+ end
70
+
71
+ # The core method of a component. This is expected to return
72
+ # a string to be sent as a response to an HTTP request.
73
+ #
74
+ # This implementation uses the associated template for the component
75
+ # and sends it through ERB using this component as
76
+ # the binding.
77
+ def render
78
+ Brut.container.component_locator.locate(self.template_name).
79
+ then { |erb_file| Brut::FrontEnd::Template.new(erb_file) }.
80
+ then { |template| template.render_template(self).html_safe! }
81
+ end
82
+
83
+ def page_name
84
+ @page_name ||= begin
85
+ page = self.class.name.split(/::/).reduce(Module) { |accumulator,class_path_part|
86
+ if accumulator.ancestors.include?(Brut::FrontEnd::Page)
87
+ accumulator
88
+ else
89
+ accumulator.const_get(class_path_part)
90
+ end
91
+ }
92
+ if page.ancestors.include?(Brut::FrontEnd::Page)
93
+ page.name
94
+ else
95
+ raise "#{self.class} is not nested inside a page, so #page_name should not have been called"
96
+ end
97
+ end
98
+ end
99
+
100
+ def self.component_name = self.name
101
+ def component_name = self.class.component_name
102
+
103
+ # Helper methods that subclasses can use.
104
+ # This is a separate module to distinguish the public
105
+ # interface of this class (`render`) from these helper methods
106
+ # that are useful to subclasses and their templates.
107
+ #
108
+ # This is not intended to be extracted or used outside this class!
109
+ module Helpers
110
+
111
+ # Render a component. This is the primary way in which
112
+ # view re-use happens. The component instance will be able to locate its
113
+ # HTML template and render itself.
114
+ def component(component_instance,&block)
115
+ if component_instance.kind_of?(Class)
116
+ if !component_instance.ancestors.include?(Brut::FrontEnd::Component)
117
+ raise ArgumentError,"#{component_instance} is not a component and cannot be created"
118
+ end
119
+ Thread.current.thread_variable_get(:request_context).
120
+ 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
123
+ end
124
+ if !block.nil?
125
+ component_instance.yielded_block = block
126
+ 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!
130
+ }
131
+ end
132
+
133
+ # Inline an SVG into the page.
134
+ def svg(svg)
135
+ Brut.container.svg_locator.locate(svg).then { |svg_file|
136
+ File.read(svg_file).html_safe!
137
+ }
138
+ end
139
+
140
+ # Given a public path to an asset—the value you'd use in HTML—return
141
+ # the same value, but with any content hashes that are part of the filename.
142
+ def asset_path(path) = Brut.container.asset_path_resolver.resolve(path)
143
+
144
+ # Render a form that should include CSRF protection.
145
+ def form_tag(**attributes,&block)
146
+ component(Brut::FrontEnd::Components::FormTag.new(**attributes,&block))
147
+ end
148
+
149
+ def timestamp(timestamp, **component_options)
150
+ component(Brut::FrontEnd::Components::Timestamp.new(**(component_options.merge(timestamp:))))
151
+ end
152
+
153
+ def html_safe!(string)
154
+ string.html_safe!
155
+ end
156
+
157
+ VOID_ELEMENTS = [
158
+ :area,
159
+ :base,
160
+ :br,
161
+ :col,
162
+ :embed,
163
+ :hr,
164
+ :img,
165
+ :input,
166
+ :link,
167
+ :meta,
168
+ :source,
169
+ :track,
170
+ :wbr,
171
+ ]
172
+
173
+ def html_tag(tag_name, **html_attributes, &block)
174
+ tag_name = tag_name.to_s.downcase.to_sym
175
+ attributes_string = html_attributes.map { |key,value|
176
+ [
177
+ key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
178
+ value
179
+ ]
180
+ }.select { |key,value|
181
+ !value.nil?
182
+ }.map { |key,value|
183
+ if value == true
184
+ key
185
+ elsif value == false
186
+ ""
187
+ else
188
+ REXML::Attribute.new(key,value).to_string
189
+ end
190
+ }.join(" ")
191
+ contents = (block.nil? ? nil : block.()).to_s
192
+ if VOID_ELEMENTS.include?(tag_name)
193
+ if !contents.empty?
194
+ raise ArgumentError,"#{tag_name} may not have child nodes"
195
+ end
196
+ html_safe!(%{<#{tag_name} #{attributes_string}>})
197
+ else
198
+ html_safe!(%{<#{tag_name} #{attributes_string}>#{contents}</#{tag_name}>})
199
+ end
200
+ end
201
+ end
202
+ include Helpers
203
+ include Brut::I18n::ForHTML
204
+
205
+ private
206
+
207
+ def binding_scope = binding
208
+
209
+ # Determines the canonical name/location of the template used for this
210
+ # component. It does this base do the class name. CameCase is converted
211
+ # to snake_case.
212
+ def template_name = RichString.new(self.class.name).underscorized.to_s.gsub(/^components\//,"")
213
+ end
@@ -0,0 +1,71 @@
1
+ require "rexml"
2
+ # Represents a <form> HTML component
3
+ class Brut::FrontEnd::Components::FormTag < Brut::FrontEnd::Component
4
+ def initialize(**attributes,&contents)
5
+ form_class = attributes.delete(:for)
6
+ if !form_class.nil?
7
+ if attributes[:action]
8
+ raise ArgumentError, "You cannot specify both for: (#{form_class}) and and action: (#{attributes[:action]}) to a form_tag"
9
+ end
10
+ if attributes[:method]
11
+ raise ArgumentError, "You cannot specify both for: (#{form_class}) and and method: (#{attributes[:method]}) to a form_tag"
12
+ end
13
+ route = Brut.container.routing.route(form_class)
14
+ attributes[:method] = route.http_method
15
+ attributes[:action] = route.path
16
+ end
17
+
18
+ @include_csrf_token = true
19
+ @csrf_token_omit_reasoning = nil
20
+
21
+ http_method = Brut::FrontEnd::HttpMethod.new(attributes[:method])
22
+
23
+ if http_method.get?
24
+ if attributes.key?(:no_csrf_token)
25
+ raise ArgumentError,":no_csrf_token is not allowed for form_tag when the HTTP method is a GET"
26
+ end
27
+ force_csrf_token = attributes.delete(:force_csrf_token)
28
+ if !force_csrf_token
29
+ @include_csrf_token = false
30
+ @csrf_token_omit_reasoning = "because this form's action is GET"
31
+ end
32
+ else
33
+ if attributes.key?(:force_csrf_token)
34
+ raise ArgumentError,":force_csrf_token is not allowed for form_tag when the HTTP method is not a GET"
35
+ end
36
+ no_csrf_token = attributes.delete(:no_csrf_token)
37
+ if no_csrf_token
38
+ @include_csrf_token = false
39
+ @csrf_token_omit_reasoning = "because :no_csrf_token was passed to form_tag"
40
+ end
41
+ end
42
+ @attributes = attributes
43
+ @contents = contents
44
+ end
45
+
46
+ def render
47
+ attribute_string = @attributes.map { |key,value|
48
+ key = key.to_s
49
+ if value == true
50
+ key
51
+ elsif value == false
52
+ ""
53
+ else
54
+ REXML::Attribute.new(key,value).to_string
55
+ end
56
+ }.join(" ")
57
+ csrf_token_component = if @include_csrf_token
58
+ component(Brut::FrontEnd::Components::Inputs::CsrfToken)
59
+ elsif Brut.container.project_env.development?
60
+ html_safe!("<!-- CSRF Token omitted #{@csrf_token_omit_reasoning} (this message only appears in development) -->")
61
+ else
62
+ ""
63
+ end
64
+ %{
65
+ <form #{attribute_string}>
66
+ #{ csrf_token_component }
67
+ #{ @contents.() }
68
+ </form>
69
+ }
70
+ end
71
+ end
@@ -0,0 +1,36 @@
1
+ require "rexml"
2
+
3
+ # Produces `<brut-i18n-translation>` entries for the given values
4
+ class Brut::FrontEnd::Components::I18nTranslations < Brut::FrontEnd::Component
5
+ def initialize(i18n_key_root)
6
+ @i18n_key_root = i18n_key_root
7
+ end
8
+
9
+ def render
10
+ values = ::I18n.t(@i18n_key_root)
11
+ if values.kind_of?(String)
12
+ values = { "" => values }
13
+ end
14
+
15
+ values.map { |key,value|
16
+ if !value.kind_of?(String)
17
+ raise "Key #{key} under #{@i18n_key_root} maps to a #{value.class} instead of a String. For #{self.class} to work, the value must be a String"
18
+ end
19
+ i18n_key = if key == ""
20
+ @i18n_key_root
21
+ else
22
+ "#{@i18n_key_root}.#{key}"
23
+ end
24
+ attributes = [
25
+ REXML::Attribute.new("key",i18n_key),
26
+ REXML::Attribute.new("value",value.to_s),
27
+ ]
28
+ if !Brut.container.project_env.production?
29
+ attributes << REXML::Attribute.new("show-warnings",true)
30
+ attributes << REXML::Attribute.new("id","brut-18n-#{key}")
31
+ end
32
+ attribute_string = attributes.map(&:to_string).join(" ")
33
+ %{<brut-i18n-translation #{attribute_string}></brut-i18n-translation>}
34
+ }.join("\n")
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ require "rexml"
2
+ module Brut::FrontEnd::Components
3
+
4
+ module Inputs
5
+ autoload(:TextField,"brut/front_end/components/inputs/text_field")
6
+ autoload(:Select,"brut/front_end/components/inputs/select")
7
+ autoload(:Textarea,"brut/front_end/components/inputs/textarea")
8
+ autoload(:CsrfToken,"brut/front_end/components/inputs/csrf_token")
9
+ end
10
+
11
+ class Input < Brut::FrontEnd::Component
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ class Brut::FrontEnd::Components::Inputs::CsrfToken < Brut::FrontEnd::Components::Input
2
+ def initialize(csrf_token:)
3
+ @csrf_token = csrf_token
4
+ end
5
+ def render
6
+ html_tag(:input, type: "hidden", name: "authenticity_token", value: @csrf_token)
7
+ end
8
+ end