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