brut 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +7 -0
- data/CODE_OF_CONDUCT.txt +99 -0
- data/Dockerfile.dx +32 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +370 -0
- data/README.md +21 -0
- data/Rakefile +1 -0
- data/bin/bin_kit.rb +39 -0
- data/bin/rake +27 -0
- data/bin/setup +145 -0
- data/brut.gemspec +60 -0
- data/docker-compose.dx.yml +16 -0
- data/dx/build +26 -0
- data/dx/docker-compose.env +22 -0
- data/dx/dx.sh.lib +24 -0
- data/dx/exec +58 -0
- data/dx/prune +19 -0
- data/dx/setupkit.sh.lib +144 -0
- data/dx/show-help-in-app-container-then-wait.sh +38 -0
- data/dx/start +30 -0
- data/dx/stop +23 -0
- data/lib/brut/back_end/action.rb +3 -0
- data/lib/brut/back_end/result.rb +46 -0
- data/lib/brut/back_end/seed_data.rb +24 -0
- data/lib/brut/back_end/validator.rb +3 -0
- data/lib/brut/back_end/validators/form_validator.rb +37 -0
- data/lib/brut/cli/app.rb +130 -0
- data/lib/brut/cli/app_runner.rb +219 -0
- data/lib/brut/cli/apps/build_assets.rb +123 -0
- data/lib/brut/cli/apps/db.rb +279 -0
- data/lib/brut/cli/apps/scaffold.rb +256 -0
- data/lib/brut/cli/apps/test.rb +200 -0
- data/lib/brut/cli/command.rb +130 -0
- data/lib/brut/cli/error.rb +12 -0
- data/lib/brut/cli/execution_results.rb +81 -0
- data/lib/brut/cli/executor.rb +37 -0
- data/lib/brut/cli/options.rb +46 -0
- data/lib/brut/cli/output.rb +30 -0
- data/lib/brut/cli.rb +24 -0
- data/lib/brut/factory_bot.rb +20 -0
- data/lib/brut/framework/app.rb +55 -0
- data/lib/brut/framework/config.rb +415 -0
- data/lib/brut/framework/container.rb +190 -0
- data/lib/brut/framework/errors/abstract_method.rb +9 -0
- data/lib/brut/framework/errors/bug.rb +14 -0
- data/lib/brut/framework/errors/not_found.rb +10 -0
- data/lib/brut/framework/errors.rb +14 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
- data/lib/brut/framework/mcp.rb +215 -0
- data/lib/brut/framework/project_environment.rb +18 -0
- data/lib/brut/framework.rb +13 -0
- data/lib/brut/front_end/asset_metadata.rb +76 -0
- data/lib/brut/front_end/component.rb +213 -0
- data/lib/brut/front_end/components/form_tag.rb +71 -0
- data/lib/brut/front_end/components/i18n_translations.rb +36 -0
- data/lib/brut/front_end/components/input.rb +13 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
- data/lib/brut/front_end/components/inputs/select.rb +100 -0
- data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
- data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
- data/lib/brut/front_end/components/locale_detection.rb +25 -0
- data/lib/brut/front_end/components/page_identifier.rb +13 -0
- data/lib/brut/front_end/components/timestamp.rb +33 -0
- data/lib/brut/front_end/download.rb +23 -0
- data/lib/brut/front_end/flash.rb +57 -0
- data/lib/brut/front_end/form.rb +171 -0
- data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
- data/lib/brut/front_end/forms/input.rb +119 -0
- data/lib/brut/front_end/forms/input_definition.rb +100 -0
- data/lib/brut/front_end/forms/validity_state.rb +36 -0
- data/lib/brut/front_end/handler.rb +48 -0
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
- data/lib/brut/front_end/handling_results.rb +14 -0
- data/lib/brut/front_end/http_method.rb +33 -0
- data/lib/brut/front_end/http_status.rb +16 -0
- data/lib/brut/front_end/middleware.rb +7 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
- data/lib/brut/front_end/page.rb +47 -0
- data/lib/brut/front_end/request_context.rb +82 -0
- data/lib/brut/front_end/route_hook.rb +15 -0
- data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
- data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
- data/lib/brut/front_end/routing.rb +236 -0
- data/lib/brut/front_end/session.rb +56 -0
- data/lib/brut/front_end/template.rb +32 -0
- data/lib/brut/front_end/templates/block_filter.rb +60 -0
- data/lib/brut/front_end/templates/erb_engine.rb +26 -0
- data/lib/brut/front_end/templates/erb_parser.rb +84 -0
- data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
- data/lib/brut/i18n/base_methods.rb +168 -0
- data/lib/brut/i18n/for_cli.rb +4 -0
- data/lib/brut/i18n/for_html.rb +4 -0
- data/lib/brut/i18n/http_accept_language.rb +68 -0
- data/lib/brut/i18n.rb +6 -0
- data/lib/brut/instrumentation/basic.rb +66 -0
- data/lib/brut/instrumentation/event.rb +19 -0
- data/lib/brut/instrumentation/http_event.rb +5 -0
- data/lib/brut/instrumentation/subscriber.rb +41 -0
- data/lib/brut/instrumentation.rb +11 -0
- data/lib/brut/junk_drawer.rb +88 -0
- data/lib/brut/sinatra_helpers.rb +183 -0
- data/lib/brut/spec_support/component_support.rb +49 -0
- data/lib/brut/spec_support/flash_support.rb +7 -0
- data/lib/brut/spec_support/general_support.rb +18 -0
- data/lib/brut/spec_support/handler_support.rb +7 -0
- data/lib/brut/spec_support/matcher.rb +9 -0
- data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
- data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
- data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
- data/lib/brut/spec_support/session_support.rb +3 -0
- data/lib/brut/spec_support.rb +12 -0
- data/lib/brut/version.rb +3 -0
- data/lib/brut.rb +38 -0
- data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
- data/lib/sequel/extensions/brut_migrations.rb +98 -0
- data/lib/sequel/plugins/created_at.rb +14 -0
- data/lib/sequel/plugins/external_id.rb +45 -0
- data/lib/sequel/plugins/find_bang.rb +13 -0
- data/lib/sequel/plugins.rb +3 -0
- 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
|