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,236 @@
|
|
1
|
+
require "uri"
|
2
|
+
|
3
|
+
# Holds the registered routes for this app.
|
4
|
+
class Brut::FrontEnd::Routing
|
5
|
+
|
6
|
+
include SemanticLogger::Loggable
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@routes = Set.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def for(path:,method:)
|
13
|
+
http_method = Brut::FrontEnd::HttpMethod.new(method)
|
14
|
+
@routes.detect { |route|
|
15
|
+
route.path_template == path &&
|
16
|
+
route.http_method == http_method
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
def reload
|
21
|
+
new_routes = @routes.map { |route|
|
22
|
+
if route.class == Route
|
23
|
+
route.class.new(route.http_method,route.path_template)
|
24
|
+
else
|
25
|
+
route.class.new(route.path_template)
|
26
|
+
end
|
27
|
+
}
|
28
|
+
@routes = Set.new(new_routes)
|
29
|
+
@routes.each do |route|
|
30
|
+
handler_class = route.handler_class
|
31
|
+
if handler_class.name !~ /^Brut::[A-Z]/
|
32
|
+
add_routing_method(route)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def register_page(path)
|
38
|
+
route = PageRoute.new(path)
|
39
|
+
@routes << route
|
40
|
+
add_routing_method(route)
|
41
|
+
route
|
42
|
+
end
|
43
|
+
|
44
|
+
def register_form(path)
|
45
|
+
route = FormRoute.new(path)
|
46
|
+
@routes << route
|
47
|
+
add_routing_method(route)
|
48
|
+
route
|
49
|
+
end
|
50
|
+
|
51
|
+
def register_handler_only(path)
|
52
|
+
route = FormHandlerRoute.new(path)
|
53
|
+
@routes << route
|
54
|
+
add_routing_method(route)
|
55
|
+
route
|
56
|
+
end
|
57
|
+
|
58
|
+
def register_path(path, method:)
|
59
|
+
route = Route.new(method, path)
|
60
|
+
@routes << route
|
61
|
+
add_routing_method(route)
|
62
|
+
route
|
63
|
+
end
|
64
|
+
|
65
|
+
def route(handler_class)
|
66
|
+
route = @routes.detect { |route|
|
67
|
+
handler_class_match = route.handler_class.name == handler_class.name
|
68
|
+
form_class_match = if route.respond_to?(:form_class)
|
69
|
+
route.form_class.name == handler_class.name
|
70
|
+
else
|
71
|
+
false
|
72
|
+
end
|
73
|
+
handler_class_match || form_class_match
|
74
|
+
}
|
75
|
+
if !route
|
76
|
+
raise ArgumentError,"There is no configured route for #{handler_class}"
|
77
|
+
end
|
78
|
+
route
|
79
|
+
end
|
80
|
+
|
81
|
+
def uri(handler_class, with_method: :any, **rest)
|
82
|
+
route = self.route(handler_class)
|
83
|
+
route_allowed_for_method = if with_method == :any
|
84
|
+
true
|
85
|
+
elsif Brut::FrontEnd::HttpMethod.new(with_method) == route.http_method
|
86
|
+
true
|
87
|
+
else
|
88
|
+
false
|
89
|
+
end
|
90
|
+
if !route_allowed_for_method
|
91
|
+
raise ArgumentError,"The route for '#{handler_class}' (#{route.path}) is not supported by HTTP method '#{with_method}'"
|
92
|
+
end
|
93
|
+
route.path(**rest)
|
94
|
+
end
|
95
|
+
|
96
|
+
def inspect
|
97
|
+
@routes.map { |route|
|
98
|
+
"#{route.http_method}:#{route.path_template} - #{route.handler_class.name}"
|
99
|
+
}.join("\n")
|
100
|
+
end
|
101
|
+
|
102
|
+
def add_routing_method(route)
|
103
|
+
handler_class = route.handler_class
|
104
|
+
if handler_class.respond_to?(:routing) && handler_class.method(:routing).owner != Brut::FrontEnd::Form
|
105
|
+
raise ArgumentError,"#{handler_class} (that handles path #{route.path_template}) got it's ::routing method from #{handler_class.method(:routing).owner}, meaning it has overridden the value fro Brut::FrontEnd::Form"
|
106
|
+
end
|
107
|
+
form_class = route.respond_to?(:form_class) ? route.form_class : nil
|
108
|
+
[ handler_class, form_class ].compact.each do |klass|
|
109
|
+
klass.class_eval do
|
110
|
+
def self.routing(**args)
|
111
|
+
Brut.container.routing.uri(self,**args)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
class Route
|
118
|
+
|
119
|
+
include SemanticLogger::Loggable
|
120
|
+
|
121
|
+
attr_reader :handler_class, :path_template, :http_method
|
122
|
+
|
123
|
+
def initialize(method,path_template)
|
124
|
+
http_method = Brut::FrontEnd::HttpMethod.new(method)
|
125
|
+
if ![:get, :post].include?(http_method.to_sym)
|
126
|
+
raise ArgumentError,"Only GET and POST are supported. '#{method}' is not"
|
127
|
+
end
|
128
|
+
if path_template !~ /^\//
|
129
|
+
raise ArgumentError,"Routes must start with a slash: '#{path_template}'"
|
130
|
+
end
|
131
|
+
@http_method = http_method
|
132
|
+
@path_template = path_template
|
133
|
+
@handler_class = self.locate_handler_class(self.suffix,self.preposition)
|
134
|
+
end
|
135
|
+
|
136
|
+
def path(**query_string_params)
|
137
|
+
path = @path_template.split(/\//).map { |path_part|
|
138
|
+
if path_part =~ /^:(.+)$/
|
139
|
+
param_name = $1.to_sym
|
140
|
+
if !query_string_params.key?(param_name)
|
141
|
+
query_string_params_for_message = if query_string_params.keys.any?
|
142
|
+
query_string_params.keys.map(&:to_s).join(", ")
|
143
|
+
else
|
144
|
+
"no params"
|
145
|
+
end
|
146
|
+
raise ArgumentError,"path for #{@handler_class} requires '#{param_name}' as a path parameter, but it was not specified to #path. Got #{query_string_params_for_message}"
|
147
|
+
end
|
148
|
+
query_string_params.delete(param_name)
|
149
|
+
else
|
150
|
+
path_part
|
151
|
+
end
|
152
|
+
}
|
153
|
+
uri = URI(path.join("/"))
|
154
|
+
uri.query = URI.encode_www_form(query_string_params)
|
155
|
+
uri
|
156
|
+
end
|
157
|
+
|
158
|
+
def ==(other)
|
159
|
+
self.method == other.method && self.path == other.path
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
def locate_handler_class(suffix,preposition, on_missing: :raise)
|
164
|
+
if @path_template == "/"
|
165
|
+
return Module.const_get("HomePage")
|
166
|
+
end
|
167
|
+
path_parts = @path_template.split(/\//)[1..-1]
|
168
|
+
|
169
|
+
part_names = path_parts.reduce([]) { |array,path_part|
|
170
|
+
if path_part =~ /^:(.+)$/
|
171
|
+
if array.empty?
|
172
|
+
raise ArgumentError,"Your path may not start with a placeholder: '#{@path_template}'"
|
173
|
+
end
|
174
|
+
placeholder_camelized = RichString.new($1).camelize
|
175
|
+
array[-1] << preposition
|
176
|
+
array[-1] << placeholder_camelized.to_s
|
177
|
+
elsif array.empty? && path_part == "__brut"
|
178
|
+
array << "Brut"
|
179
|
+
array << "FrontEnd"
|
180
|
+
array << "Handlers"
|
181
|
+
else
|
182
|
+
array << RichString.new(path_part).camelize.to_s
|
183
|
+
end
|
184
|
+
array
|
185
|
+
}
|
186
|
+
part_names[-1] += suffix
|
187
|
+
part_names.inject(Module) { |mod,path_element|
|
188
|
+
mod.const_get(path_element,mod == Module)
|
189
|
+
}
|
190
|
+
rescue NameError => ex
|
191
|
+
if on_missing == :raise
|
192
|
+
module_message = if ex.receiver == Module
|
193
|
+
"Could not find"
|
194
|
+
else
|
195
|
+
"Module '#{ex.receiver}' did not have"
|
196
|
+
end
|
197
|
+
message = "Cannot find page class for route '#{@path_template}', which should be #{part_names.join("::")}. #{module_message} the class or module '#{ex.name}'"
|
198
|
+
raise message
|
199
|
+
else
|
200
|
+
nil
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def suffix = "Handler"
|
205
|
+
def preposition = "With"
|
206
|
+
|
207
|
+
end
|
208
|
+
|
209
|
+
class PageRoute < Route
|
210
|
+
def initialize(path_template)
|
211
|
+
super(Brut::FrontEnd::HttpMethod.new(:get),path_template)
|
212
|
+
end
|
213
|
+
def suffix = "Page"
|
214
|
+
def preposition = "By"
|
215
|
+
end
|
216
|
+
|
217
|
+
class FormRoute < Route
|
218
|
+
attr_reader :form_class
|
219
|
+
def initialize(path_template)
|
220
|
+
super(Brut::FrontEnd::HttpMethod.new(:post),path_template)
|
221
|
+
@form_class = self.locate_handler_class("Form","With")
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
class FormHandlerRoute < Route
|
226
|
+
def initialize(path_template)
|
227
|
+
super(Brut::FrontEnd::HttpMethod.new(:post),path_template)
|
228
|
+
unnecessary_class = self.locate_handler_class("Form","With", on_missing: nil)
|
229
|
+
if !unnecessary_class.nil?
|
230
|
+
raise ArgumentError,"#{path_template} should only have #{handler_class} defined, however #{unnecessary_class} was found. If #{path_template} should be a form submission, use `form \"#{path_template}\"` instead of `action \"#{path_template}\"`. Otherwise, delete #{unnecessary_class}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
end
|
236
|
+
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# A class that represents the current session, as opposed to just a Hash.
|
2
|
+
# Generally, this can act like a Hash for setting and accessing values stored in the session.
|
3
|
+
# It provides a few useful additions:
|
4
|
+
#
|
5
|
+
# * Your app can extend this to provide an app-specific API around the session.
|
6
|
+
# * There is direct access to commonly-used data stored in the session, such as the flash.
|
7
|
+
class Brut::FrontEnd::Session
|
8
|
+
def initialize(rack_session:)
|
9
|
+
@rack_session = rack_session
|
10
|
+
end
|
11
|
+
|
12
|
+
def http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_session(self[:__brut_http_accept_language])
|
13
|
+
def http_accept_language=(http_accept_language)
|
14
|
+
self[:__brut_http_accept_language] = http_accept_language.for_session
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get the timezone as reported by the browser, as a TZInfo::Timezone.
|
18
|
+
# If none is available or the browser reported an invalid value, this returns nil.
|
19
|
+
def timezone_from_browser
|
20
|
+
tz_name = self[:__brut_timezone_from_browser]
|
21
|
+
if tz_name.nil?
|
22
|
+
return nil
|
23
|
+
end
|
24
|
+
begin
|
25
|
+
TZInfo::Timezone.get(tz_name)
|
26
|
+
rescue TZInfo::InvalidTimezoneIdentifier => ex
|
27
|
+
SemanticLogger[self.class.name].warn(ex)
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Set the timezone as reported by the browser. This alleviates the need to keep
|
33
|
+
# asking the browser for this information.
|
34
|
+
def timezone_from_browser=(timezone)
|
35
|
+
if timezone.kind_of?(TZInfo::Timezone)
|
36
|
+
timezone = timezone.name
|
37
|
+
end
|
38
|
+
self[:__brut_timezone_from_browser] = timezone
|
39
|
+
end
|
40
|
+
|
41
|
+
def[](key) = @rack_session[key.to_s]
|
42
|
+
|
43
|
+
def[]=(key,value)
|
44
|
+
@rack_session[key.to_s] = value
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(key) = @rack_session.delete(key.to_s)
|
48
|
+
|
49
|
+
# Access the flash, as an instance of whatever class has been configured.
|
50
|
+
def flash
|
51
|
+
Brut.container.flash_class.from_h(self[:__brut_flash])
|
52
|
+
end
|
53
|
+
def flash=(new_flash)
|
54
|
+
self[:__brut_flash] = new_flash.to_h
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require "temple"
|
2
|
+
|
3
|
+
module Brut::FrontEnd::Templates
|
4
|
+
autoload(:HTMLSafeString,"brut/front_end/templates/html_safe_string")
|
5
|
+
autoload(:ERBParser,"brut/front_end/templates/erb_parser")
|
6
|
+
autoload(:EscapableFilter,"brut/front_end/templates/escapable_filter")
|
7
|
+
autoload(:BlockFilter,"brut/front_end/templates/block_filter")
|
8
|
+
autoload(:ERBEngine,"brut/front_end/templates/erb_engine")
|
9
|
+
end
|
10
|
+
|
11
|
+
# Handles rendering HTML templates
|
12
|
+
class Brut::FrontEnd::Template
|
13
|
+
|
14
|
+
TempleTemplate = Temple::Templates::Tilt(Brut::FrontEnd::Templates::ERBEngine,
|
15
|
+
register_as: "html.erb")
|
16
|
+
|
17
|
+
# Wraps a string that is deemed safe to insert into
|
18
|
+
# HTML without escaping it. This allows stuff like
|
19
|
+
# <%= component(SomeComponent) %> to work without
|
20
|
+
# having to remember to <%== all the time.
|
21
|
+
def initialize(template_file_path)
|
22
|
+
@tilt_template = Tilt.new(template_file_path)
|
23
|
+
end
|
24
|
+
|
25
|
+
def render_template(...)
|
26
|
+
@tilt_template.render(...)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.escape_html(string)
|
30
|
+
Brut::FrontEnd::Templates::EscapableFilter.escape_html(string)
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# This is a slightly modified copy if Hanamis' Filters::Block:
|
2
|
+
#
|
3
|
+
# https://github.com/hanami/view/blob/main/lib/hanami/view/erb/filters/block.rb
|
4
|
+
#
|
5
|
+
class Brut::FrontEnd::Templates::BlockFilter < Temple::Filter
|
6
|
+
END_LINE_RE = /\bend\b/
|
7
|
+
|
8
|
+
def on_erb_block(escape, code, content)
|
9
|
+
tmp = unique_name
|
10
|
+
|
11
|
+
# Remove the last `end` :code sexp, since this is technically "outside" the block
|
12
|
+
# contents, which we want to capture separately below. This `end` is added back after
|
13
|
+
# capturing the content below.
|
14
|
+
case content.last
|
15
|
+
in [:code, c] if c =~ END_LINE_RE
|
16
|
+
content.pop
|
17
|
+
end
|
18
|
+
|
19
|
+
[:multi,
|
20
|
+
# Capture the result of the code in a variable. We can't do `[:dynamic, code]` because
|
21
|
+
# it's probably not a complete expression (which is a requirement for Temple).
|
22
|
+
# DBC: an example is that 'code' might be "form_for do" which is not an expression.
|
23
|
+
# Because we later put an "end" in, the result will be
|
24
|
+
#
|
25
|
+
# some_var = helper do
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# Which IS valid Ruby.
|
29
|
+
[:code, "#{tmp} = #{code}"],
|
30
|
+
# Capture the content of a block in a separate buffer. This means that `yield` will
|
31
|
+
# not output the content to the current buffer, but rather return the output.
|
32
|
+
[:capture, unique_name, compile(content)],
|
33
|
+
[:code, "end"],
|
34
|
+
# Output the content, without escaping it.
|
35
|
+
# Hanami has this ↴
|
36
|
+
# [:escape, escape, [:dynamic, tmp]]
|
37
|
+
[:escape, escape, [:dynamic, Brut::FrontEnd::Templates.name + "::HTMLSafeString.new(#{tmp})"]]
|
38
|
+
]
|
39
|
+
|
40
|
+
# Details explaining the change:
|
41
|
+
#
|
42
|
+
# The sexps for template are quite convoluted and highly dynamic, so it is hard
|
43
|
+
# to understand exactly what effect they will have. Basically, what this [:multi thing is
|
44
|
+
# doing is to capture the result of the block in a variable:
|
45
|
+
#
|
46
|
+
# some_var = form_for(args) do
|
47
|
+
#
|
48
|
+
# It then captures the inside of the block in a new variable:
|
49
|
+
#
|
50
|
+
# some_other_var = «whatever was inside that `do`»
|
51
|
+
#
|
52
|
+
# And follows it with an end.
|
53
|
+
#
|
54
|
+
# The first variable—some_var—now holds the return value of the helper, form_for in this case. To
|
55
|
+
# output this content to the actual view, it must be dereferenced, thus [ :dynamic, "some_var" ].
|
56
|
+
#
|
57
|
+
# We are going to treat the return value of the block helper as HTML safe. Thus, we'll wrap it
|
58
|
+
# with HTMLSafeString.new(…).
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# A temple "engine" that can be used to parse ERB and generate HTML
|
2
|
+
# in just the way we need.
|
3
|
+
class Brut::FrontEnd::Templates::ERBEngine < Temple::Engine
|
4
|
+
# Parse the ERB into sexps
|
5
|
+
use Brut::FrontEnd::Templates::ERBParser
|
6
|
+
|
7
|
+
# Handle block syntax used in a <%=
|
8
|
+
use Brut::FrontEnd::Templates::BlockFilter
|
9
|
+
|
10
|
+
# Trim whitespace like ERB does
|
11
|
+
use Temple::ERB::Trimming
|
12
|
+
|
13
|
+
# Escape strings only if they are not HTMLSafeString
|
14
|
+
use Brut::FrontEnd::Templates::EscapableFilter
|
15
|
+
# This filter actually runs the Ruby code
|
16
|
+
use Temple::Filters::StaticAnalyzer
|
17
|
+
# Flattens nested :multi expressions which I'm not sure is needed, but
|
18
|
+
# have cargo-culted from hanami
|
19
|
+
use Temple::Filters::MultiFlattener
|
20
|
+
# merges sequential :static, which again, not sure is needed, but
|
21
|
+
# have cargo-culted from hanami
|
22
|
+
use Temple::Filters::StaticMerger
|
23
|
+
|
24
|
+
# This generates everything into a string
|
25
|
+
use Temple::Generators::ArrayBuffer
|
26
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# Almost verbatim copy of Hanami's parser:
|
2
|
+
#
|
3
|
+
# https://github.com/hanami/view/blob/main/lib/hanami/view/erb/parser.rb
|
4
|
+
#
|
5
|
+
# That is licensed MIT and thus so is this.
|
6
|
+
#
|
7
|
+
# Avoid changes to this file so it can be kept updated with Hanami.
|
8
|
+
class Brut::FrontEnd::Templates::ERBParser < Temple::Parser
|
9
|
+
ERB_PATTERN = /(\n|<%%|%%>)|<%(==?|\#)?(.*?)?-?%>/m
|
10
|
+
|
11
|
+
IF_UNLESS_CASE_LINE_RE = /\A\s*(if|unless|case)\b/
|
12
|
+
BLOCK_LINE_RE = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
|
13
|
+
END_LINE_RE = /\bend\b/
|
14
|
+
|
15
|
+
def call(input)
|
16
|
+
results = [[:multi]]
|
17
|
+
pos = 0
|
18
|
+
|
19
|
+
input.scan(ERB_PATTERN) do |token, indicator, code|
|
20
|
+
# Capture any text between the last ERB tag and the current one, and update the position
|
21
|
+
# to match the end of the current tag for the next iteration of text collection.
|
22
|
+
text = input[pos...$~.begin(0)]
|
23
|
+
pos = $~.end(0)
|
24
|
+
|
25
|
+
if token
|
26
|
+
# First, handle certain static tokens picked up by our ERB_PATTERN regexp. These are
|
27
|
+
# newlines as well as the special codes for literal `<%` and `%>` values.
|
28
|
+
case token
|
29
|
+
when "\n"
|
30
|
+
results.last << [:static, "#{text}\n"] << [:newline]
|
31
|
+
when "<%%", "%%>"
|
32
|
+
results.last << [:static, text] unless text.empty?
|
33
|
+
token.slice!(1)
|
34
|
+
results.last << [:static, token]
|
35
|
+
end
|
36
|
+
else
|
37
|
+
# Next, handle actual ERB tags. Start by adding any static text between this match and
|
38
|
+
# the last.
|
39
|
+
results.last << [:static, text] unless text.empty?
|
40
|
+
|
41
|
+
case indicator
|
42
|
+
when "#"
|
43
|
+
# Comment tags: <%# this is a comment %>
|
44
|
+
results.last << [:code, "\n" * code.count("\n")]
|
45
|
+
when %r{=}
|
46
|
+
# Expression tags: <%= "hello (auto-escaped)" %> or <%== "hello (not escaped)" %>
|
47
|
+
if code =~ BLOCK_LINE_RE
|
48
|
+
# See Hanami::View::Erb::Filters::Block for the processing of `:erb, :block` sexps
|
49
|
+
block_node = [:erb, :block, indicator.size == 1, code, (block_content = [:multi])]
|
50
|
+
results.last << block_node
|
51
|
+
|
52
|
+
# For blocks opened in ERB expression tags, push this `[:multi]` sexp
|
53
|
+
# (representing the content of the block) onto the stack of resuts. This allows
|
54
|
+
# subsequent results to be appropriately added inside the block, until its closing
|
55
|
+
# tag is encountered, and this `block_content` multi is subsequently popped off
|
56
|
+
# the results stack.
|
57
|
+
results << block_content
|
58
|
+
else
|
59
|
+
results.last << [:escape, indicator.size == 1, [:dynamic, code]]
|
60
|
+
end
|
61
|
+
else
|
62
|
+
# Code tags: <% if some_cond %>
|
63
|
+
if code =~ BLOCK_LINE_RE || code =~ IF_UNLESS_CASE_LINE_RE
|
64
|
+
results.last << [:code, code]
|
65
|
+
|
66
|
+
# For ERB code tags that will result in a matching `end`, push the last result
|
67
|
+
# back onto the stack of results. This might seem redundant, but it allows
|
68
|
+
# subsequent sexps to continue to be pushed onto the same result while also
|
69
|
+
# allowing it to be safely popped again when the matching `end` is encountered.
|
70
|
+
results << results.last
|
71
|
+
elsif code =~ END_LINE_RE
|
72
|
+
results.last << [:code, code]
|
73
|
+
results.pop
|
74
|
+
else
|
75
|
+
results.last << [:code, code]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Add any text after the final ERB tag
|
82
|
+
results.last << [:static, input[pos..-1]]
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# A temple filter that handles escaping HTML unless it's been wrapped in
|
2
|
+
# an HTMLSafeString.
|
3
|
+
class Brut::FrontEnd::Templates::EscapableFilter < Temple::Filters::Escapable
|
4
|
+
using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
|
5
|
+
|
6
|
+
def initialize(opts = {})
|
7
|
+
opts[:escape_code] ||= "::Brut::FrontEnd::Templates::EscapableFilter.escape_html((%s))"
|
8
|
+
super(opts)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.escape_html(html)
|
12
|
+
if html.kind_of?(Brut::FrontEnd::Templates::HTMLSafeString)
|
13
|
+
html.string
|
14
|
+
else
|
15
|
+
Temple::Utils.escape_html(html)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# A wrapper around a string to indicate it is HTML-safe and
|
2
|
+
# can be rendered directly without escaping.
|
3
|
+
class Brut::FrontEnd::Templates::HTMLSafeString
|
4
|
+
module Refinement
|
5
|
+
refine String do
|
6
|
+
def html_safe! = Brut::FrontEnd::Templates::HTMLSafeString.from_string(self)
|
7
|
+
def html_safe? = false
|
8
|
+
end
|
9
|
+
end
|
10
|
+
attr_reader :string
|
11
|
+
def initialize(string)
|
12
|
+
@string = string
|
13
|
+
end
|
14
|
+
|
15
|
+
# Wrap a string in an HTMLSafeString if needed.
|
16
|
+
def self.from_string(string_or_html_safe_string)
|
17
|
+
if string_or_html_safe_string.kind_of?(self)
|
18
|
+
string_or_html_safe_string
|
19
|
+
else
|
20
|
+
self.new(string_or_html_safe_string)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# This must be convertible to a string
|
25
|
+
def to_s = @string
|
26
|
+
def to_str = @string
|
27
|
+
def html_safe! = self
|
28
|
+
def html_safe? = true
|
29
|
+
def capitalize = self.class.new(@string.capitalize)
|
30
|
+
def downcase = self.class.new(@string.downcase)
|
31
|
+
def upcase = self.class.new(@string.upcase)
|
32
|
+
|
33
|
+
def +(other)
|
34
|
+
if other.html_safe?
|
35
|
+
self.class.new(@string + other.to_s)
|
36
|
+
else
|
37
|
+
@string + other.to_s
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|