brut 0.0.1

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 (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