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