brut 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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