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,46 @@
1
+ # The result of back-end processing, which is essentially
2
+ # a container for constraint violations and arbitrary context.
3
+ class Brut::BackEnd::Result
4
+ attr_reader :constraint_violations, :context
5
+
6
+ def initialize
7
+ @constraint_violations = {}
8
+ @context = {}
9
+ end
10
+
11
+ def constraint_violation!(object:,
12
+ field:,
13
+ key:,
14
+ context: {})
15
+ @constraint_violations[object] ||= {}
16
+ @constraint_violations[object][field] ||= {}
17
+ @constraint_violations[object][field][key] = context
18
+ end
19
+
20
+ def each_violation(&block)
21
+ @constraint_violations.each do |object,fields|
22
+ fields.each do |field,keys|
23
+ keys.each do |key,context|
24
+ block.(object,field,key,context)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ def []=(key_in_context,object)
31
+ @context[key_in_context] = object
32
+ end
33
+
34
+ def [](key_in_context)
35
+ @context.fetch(key_in_context)
36
+ rescue KeyError => ex
37
+ raise KeyError.new(
38
+ "Context did not contain '#{key_in_context}' (#{key_in_context.class}). Context has these keys: #{@context.keys.join(',')}",
39
+ receiver: ex.receiver,
40
+ key: ex.key)
41
+ end
42
+
43
+
44
+ def constraint_violations? = self.constraint_violations.any?
45
+
46
+ end
@@ -0,0 +1,24 @@
1
+ require_relative "../factory_bot"
2
+ module Brut
3
+ module Backend
4
+ class SeedData
5
+ def self.inherited(seed_data_klass)
6
+ @classes ||= []
7
+ @classes << seed_data_klass
8
+ end
9
+ def self.classes = @classes || []
10
+
11
+ def setup!
12
+ Brut::FactoryBot.new.setup!
13
+ end
14
+
15
+ def load_seeds!
16
+ DB.transaction do
17
+ self.class.classes.each do |klass|
18
+ klass.new.seed!
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ class Brut::BackEnd::Validators
2
+ autoload(:FormValidator, "brut/back_end/validators/form_validator")
3
+ end
@@ -0,0 +1,37 @@
1
+ # Subclass this in your back-end to create a server-side
2
+ # validator for your form. This provides for a much
3
+ # richer set of validations than you get from the browser, but
4
+ # works basically the same way.
5
+ class Brut::BackEnd::Validators::FormValidator
6
+ def self.validate(attribute,options)
7
+ @@validations ||= {}
8
+ @@validations[attribute] = options
9
+ end
10
+
11
+ def validate(form)
12
+ @@validations.each do |attribute,options|
13
+ value = form.send(attribute)
14
+ options.each do |option, option_value|
15
+ case option
16
+ when :required
17
+ if option_value == true
18
+ if value.to_s.strip == ""
19
+ form.server_side_constraint_violation(input_name: attribute, key: :required)
20
+ end
21
+ end
22
+ when :minlength
23
+ if value.respond_to?(:length) || value.nil?
24
+ if value.nil? || value.length < option_value
25
+ form.server_side_constraint_violation(input_name: attribute, key: :too_short, context: { minlength: option_value })
26
+ end
27
+ else
28
+ raise "'#{attribute}''s value (a '#{value.class}') does not respond to 'length' - :minlength cannot be used as a validation"
29
+ end
30
+ else
31
+ raise "'#{option}' is not a recognized validation option"
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ end
@@ -0,0 +1,130 @@
1
+ require "optparse"
2
+ require_relative "../junk_drawer"
3
+ class Brut::CLI::App
4
+ include Brut::CLI::ExecutionResults
5
+ include Brut::I18n::ForCLI
6
+
7
+ def self.commands
8
+ self.constants.map { |name|
9
+ self.const_get(name)
10
+ }.select { |constant|
11
+ constant.kind_of?(Class) && constant.ancestors.include?(Brut::CLI::Command) && constant.instance_methods.include?(:execute)
12
+ }
13
+ end
14
+ def self.description(new_description=nil)
15
+ if new_description.nil?
16
+ return @description.to_s
17
+ else
18
+ @description = new_description
19
+ end
20
+ end
21
+ def self.default_command(new_command_name=nil)
22
+ if new_command_name.nil?
23
+ return @default_command || "help"
24
+ else
25
+ @default_command = new_command_name.nil? ? nil : new_command_name.to_s
26
+ end
27
+ end
28
+ def self.opts
29
+ self.option_parser
30
+ end
31
+ def self.option_parser
32
+ @option_parser ||= OptionParser.new do |opts|
33
+ opts.banner = "%{app} %{global_options} commands [command options] [args]"
34
+ end
35
+ end
36
+ def self.requires_project_env(default: "development")
37
+ default_message = if default.nil?
38
+ ""
39
+ else
40
+ " (default '#{default}')"
41
+ end
42
+ opts.on("--env=ENVIRONMENT","Project environment#{default_message}")
43
+ @default_env = ENV["RACK_ENV"] || default
44
+ @requires_project_env = true
45
+ end
46
+
47
+ def self.default_env = @default_env
48
+ def self.requires_project_env? = @requires_project_env
49
+
50
+ def self.configure_only!
51
+ @configure_only = true
52
+ end
53
+ def self.configure_only? = !!@configure_only
54
+
55
+ def initialize(global_options:,out:,err:,executor:)
56
+ @global_options = global_options
57
+ @out = out
58
+ @err = err
59
+ @executor = executor
60
+ if self.class.default_env
61
+ @global_options.set_default(:env,self.class.default_env)
62
+ end
63
+ end
64
+
65
+ def set_env_if_needed
66
+ if self.class.requires_project_env?
67
+ ENV["RACK_ENV"] = options.env
68
+ end
69
+ end
70
+
71
+ def load_env(project_root:)
72
+ if !ENV["RACK_ENV"]
73
+ ENV["RACK_ENV"] = "development"
74
+ end
75
+ env = ENV["RACK_ENV"]
76
+ if env != "production"
77
+ require "dotenv"
78
+ Dotenv.load(project_root / ".env.#{env}",
79
+ project_root / ".env.#{env}.local")
80
+
81
+ end
82
+ end
83
+
84
+ def before_execute
85
+ end
86
+
87
+ def after_bootstrap
88
+ end
89
+
90
+ def configure!
91
+ end
92
+
93
+ def execute!(command,project_root:)
94
+ before_execute
95
+ set_env_if_needed
96
+ command.set_env_if_needed
97
+ load_env(project_root:)
98
+ command.before_execute
99
+ bootstrap_result = begin
100
+ require "#{project_root}/app/bootstrap"
101
+ bootstrap = Bootstrap.new
102
+ if self.class.configure_only?
103
+ bootstrap.configure_only!
104
+ else
105
+ bootstrap.bootstrap!
106
+ end
107
+ continue_execution
108
+ rescue => ex
109
+ as_execution_result(command.handle_bootstrap_exception(ex))
110
+ end
111
+ if bootstrap_result.stop?
112
+ return bootstrap_result
113
+ end
114
+ after_bootstrap
115
+ as_execution_result(command.execute)
116
+ rescue Brut::CLI::Error => ex
117
+ abort_execution(ex.message)
118
+ end
119
+
120
+ private
121
+
122
+ def options = @global_options
123
+ def out = @out
124
+ def err = @err
125
+ def puts(...)
126
+ warn("Your CLI apps should use out.puts or err.puts or produce terminal output, not plain puts", uplevel: 1)
127
+ Kernel.puts(...)
128
+ end
129
+
130
+ end
@@ -0,0 +1,219 @@
1
+ module Brut
2
+ module CLI
3
+ class AppRunner
4
+ include Brut::CLI::ExecutionResults
5
+
6
+ def initialize(app_klass:,project_root:)
7
+ @app_klass = app_klass
8
+ @project_root = project_root
9
+ end
10
+
11
+ def run!
12
+ app_klass = @app_klass
13
+ out = Brut::CLI::Output.new(io: $stdout,prefix: "[ #{$0} ] ")
14
+ err = Brut::CLI::Output.new(io: $stderr,prefix: "[ #{$0} ] ")
15
+ executor = Brut::CLI::Executor.new(out:,err:)
16
+
17
+ result,remaining_argv,global_options,global_option_parser = parse_global(app_klass:,out:)
18
+
19
+ if result.stop?
20
+ return result.to_i
21
+ end
22
+
23
+ result,command_klass = locate_command(remaining_argv:,app_klass:,err:,out:)
24
+
25
+ if result.stop?
26
+ if !result.ok?
27
+ err.puts_no_prefix "error: #{result.message}"
28
+ err.puts_no_prefix
29
+ if result.show_usage?
30
+ show_global_help(app_klass:,out:)
31
+ err.puts_no_prefix "error: #{result.message}"
32
+ end
33
+ elsif result.show_usage?
34
+ command_klass = result.command_klass
35
+ if command_klass.nil?
36
+ show_global_help(app_klass:,out:)
37
+ else
38
+ command_option_parser = command_klass.option_parser
39
+ show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
40
+ end
41
+ end
42
+ return result.to_i
43
+ end
44
+
45
+ command_options = {}
46
+
47
+ command_option_parser = command_klass.option_parser
48
+
49
+ command_option_parser.on("-h", "--help", "Get help on this command") do
50
+ show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
51
+ return 0
52
+ end
53
+
54
+ command_argv = remaining_argv[1..-1] || []
55
+ args = command_option_parser.parse!(command_argv,into:command_options)
56
+
57
+ cli_app = app_klass.new(global_options:, out:, err:, executor:)
58
+ cmd = command_klass.new(command_options:Brut::CLI::Options.new(command_options),global_options:, args:, out:, err:, executor:)
59
+
60
+ result = cli_app.execute!(cmd, project_root:@project_root)
61
+
62
+ if result.message
63
+ if !result.ok?
64
+ err.puts "error: #{result.message}"
65
+ if result.show_usage?
66
+ err.puts
67
+ show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
68
+ end
69
+ else
70
+ out.puts result.message
71
+ end
72
+ end
73
+ result.to_i
74
+ rescue OptionParser::InvalidArgument => ex
75
+ flag = ex.args.map { |_| _.gsub(/=.*$/,"") }.join(", ")
76
+ err.puts "error: #{ex.reason} from #{flag}: value given is not one of the allowed values"
77
+ 65
78
+ rescue OptionParser::ParseError => ex
79
+ err.puts "error: #{ex.message}"
80
+ 65
81
+ rescue => ex
82
+ if ENV["BRUT_CLI_RAISE_ON_ERROR"] == "true"
83
+ raise
84
+ else
85
+ err.puts "error: #{ex.message}"
86
+ 70
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def locate_command(remaining_argv:,app_klass:,err:,out:)
93
+
94
+ command_name = remaining_argv[0] || app_klass.default_command
95
+
96
+ if !command_name
97
+ return cli_usage_error("#{$0} requires a command")
98
+ end
99
+
100
+ if command_name == "help"
101
+ command_needing_help = remaining_argv[1]
102
+ if command_needing_help
103
+ command_klass = app_klass.commands.detect { |c| c.name_matches?(remaining_argv[1]) }
104
+ if command_klass
105
+ return show_cli_usage(command_klass)
106
+ end
107
+ return cli_usage_error("No such command '#{command_needing_help}'")
108
+ end
109
+ return show_cli_usage
110
+ end
111
+
112
+ command_klass = app_klass.commands.detect { |c| c.name_matches?(command_name) }
113
+
114
+ if !command_klass
115
+ return cli_usage_error("#{command_name} is not a known command")
116
+ end
117
+ [ continue_execution, command_klass ]
118
+ end
119
+
120
+
121
+ def show_global_help(app_klass:,out:)
122
+ option_parser = app_klass.option_parser
123
+ option_parser.banner = option_parser.banner % {
124
+ app: $0,
125
+ global_options: option_parser.top.list.length == 0 ? "" : "[global options]",
126
+ }
127
+ out.puts_no_prefix option_parser.banner
128
+ out.puts_no_prefix
129
+ out.puts_no_prefix " #{app_klass.description}"
130
+ out.puts_no_prefix
131
+ out.puts_no_prefix "GLOBAL OPTIONS"
132
+ out.puts_no_prefix
133
+ option_parser.summarize do |line|
134
+ out.puts_no_prefix line
135
+ end
136
+ if app_klass.commands.any?
137
+ out.puts_no_prefix
138
+ out.puts_no_prefix "COMMANDS"
139
+ out.puts_no_prefix
140
+ max_length = [ 4, app_klass.commands.map { |_| _.command_name.to_s.length }.max ].max
141
+ printf_string = " %-#{max_length}s - %s%s\n"
142
+ printf printf_string, "help", "Get help on a command",""
143
+ app_klass.commands.sort_by(&:command_name).each do |command|
144
+ default_message = if command.name_matches?(app_klass.default_command)
145
+ " (default)"
146
+ else
147
+ ""
148
+ end
149
+
150
+ description = if command.description && command.description.kind_of?(Proc)
151
+ command.description.()
152
+ else
153
+ command.description
154
+ end
155
+ printf printf_string, command.command_name, command.description, default_message
156
+ end
157
+ end
158
+ out.puts_no_prefix
159
+ end
160
+
161
+ def show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
162
+ banner = command_option_parser.banner % {
163
+ app: $0,
164
+ global_options: global_option_parser.top.list.length == 0 ? "" : "[global options]",
165
+ command_options: command_option_parser.top.list.length == 0 ? "" : "[command options]",
166
+ args: command_klass.args,
167
+ }
168
+ command_option_parser.banner = "Usage: #{banner}"
169
+ out.puts_no_prefix command_option_parser.banner
170
+ out.puts_no_prefix
171
+ out.puts_no_prefix " " + command_klass.description
172
+ out.puts_no_prefix
173
+ if command_klass.detailed_description
174
+ out.puts_no_prefix " " + command_klass.detailed_description.strip
175
+ out.puts_no_prefix
176
+ end
177
+ out.puts_no_prefix "GLOBAL OPTIONS"
178
+ out.puts_no_prefix
179
+ global_option_parser.summarize do |line|
180
+ out.puts_no_prefix line
181
+ end
182
+ if command_option_parser.top.list.length > 0
183
+ out.puts_no_prefix
184
+ out.puts_no_prefix "COMMAND OPTIONS"
185
+ if command_option_parser.summarize.any?
186
+ out.puts_no_prefix
187
+ end
188
+ command_option_parser.summarize do |line|
189
+ out.puts_no_prefix line
190
+ end
191
+ end
192
+ end
193
+
194
+ def parse_global(app_klass:,out:)
195
+ option_parser = app_klass.option_parser
196
+
197
+ option_parser.on("-h", "--help", "Get help") do
198
+ show_global_help(app_klass:,out:)
199
+ return stop_execution
200
+ end
201
+ log_levels = [ "debug", "info", "warn", "error", "fatal" ]
202
+ if ENV["LOG_LEVEL"].to_s == ""
203
+ ENV["LOG_LEVEL"] = log_levels[-1]
204
+ end
205
+ option_parser.on("--log-level=LEVEL","Set log level. Allowed values: #{log_levels.join(', ')}. Default '#{ENV["LOG_LEVEL"]}'",log_levels) do |value|
206
+ ENV["LOG_LEVEL"] = value
207
+ end
208
+ option_parser.on("--verbose","Set log level to '#{log_levels[0]}', which will produce maximum output") do
209
+ ENV["LOG_LEVEL"] = log_levels[0]
210
+ end
211
+
212
+ hash = {}
213
+ remaining_argv = option_parser.order!(into:hash)
214
+ global_options = Brut::CLI::Options.new(hash)
215
+ [ continue_execution, remaining_argv, global_options, option_parser ]
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,123 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ require "brut/cli"
5
+
6
+ class Brut::CLI::Apps::BuildAssets < Brut::CLI::App
7
+ description "Build and manage code and assets destined for the browser, such as CSS, JS, or images"
8
+ requires_project_env
9
+ default_command :all
10
+ configure_only!
11
+
12
+ class All < Brut::CLI::Command
13
+ description "Build all assets"
14
+ opts.on("--[no-]clean","If set the metadata file used to map the files to their hashed values is deleted before assets are built")
15
+
16
+ def execute
17
+ if options.clean?(default: true)
18
+ asset_metadata_file = Brut.container.asset_metadata_file
19
+ out.puts "Removing #{asset_metadata_file}"
20
+ FileUtils.rm_f(asset_metadata_file)
21
+ end
22
+ delegate_to_commands(Images, JS, CSS)
23
+ end
24
+ end
25
+
26
+ class Images < Brut::CLI::Command
27
+ description "Copy images to the public folder"
28
+ detailed_description %{
29
+ This is to ensure that any images your code references will end up in the public directory, so they are served properly. This is not for managing images that may be referenced in CSS files. See the `css` command for information on that.
30
+ }
31
+
32
+ def execute
33
+ src_dir = Brut.container.images_src_dir
34
+ dest_dir = Brut.container.images_root_dir
35
+
36
+ command = "rsync --archive --delete --verbose #{src_dir}/ #{dest_dir}"
37
+ system! command
38
+ end
39
+ end
40
+
41
+ class CSS < Brut::CLI::Command
42
+ description "Builds a single CSS file suitable for sending to the browser"
43
+ opts.on("--clean","If set, any .css files hanging around from a prevous build are deleted. Not recommended in production environments")
44
+
45
+ detailed_description %{
46
+ This produces a hashed file in every environment, in order to keep environments consistent and reduce differences. If your CSS file references images, fonts, or other assets via url() or other CSS functions, those files will be hashed and copied into the output directory where CSS is served.
47
+
48
+ To ensure this happens correctly, your url() or other function must reference the file as a relative file from where your actual source CSS file is located. For example, a font named some-font.ttf would be in app/src/front_end/fonts and to reference this from app/src/front_end/css/index.css you'd use the url "../fonts/some-font.ttf"
49
+ }
50
+
51
+ def execute
52
+ css_bundle = Brut.container.css_bundle_output_dir / "styles.css"
53
+ css_bundle_source = Brut.container.front_end_src_dir / "css" / "index.css"
54
+ esbuild_metafile = Brut.container.tmp_dir / "build-css-meta.json"
55
+ asset_metadata_file = Brut.container.asset_metadata_file
56
+
57
+ if options.clean?
58
+ out.puts "Cleaning old CSS files from #{Brut.container.css_bundle_output_dir}"
59
+ Dir[Brut.container.css_bundle_output_dir / "*.*"].each do |file|
60
+ if File.file?(file)
61
+ out.puts "Deleting #{file}"
62
+ FileUtils.rm(file)
63
+ end
64
+ end
65
+ end
66
+
67
+ command = "npx esbuild --loader:.ttf=copy --loader:.otf=copy --metafile=#{esbuild_metafile} --entry-names=[name]-[hash] --sourcemap --bundle #{css_bundle_source} --outfile=#{css_bundle}"
68
+ out.puts "Building CSS bundle '#{css_bundle}' with '#{command}'"
69
+ system!(command)
70
+
71
+ if !File.exist?(esbuild_metafile)
72
+ err.puts "'#{esbuild_metafile}' was not generated - cannot continue"
73
+ exit 1
74
+ end
75
+
76
+ asset_metadata = Brut::FrontEnd::AssetMetadata.new(asset_metadata_file:,out:)
77
+ asset_metadata.merge!(extension: ".css", esbuild_metafile:)
78
+ asset_metadata.save!
79
+ end
80
+ end
81
+ class JS < Brut::CLI::Command
82
+ description "Builds and bundles JavaScript destined for the browser"
83
+ opts.on("--clean","If set, any .js files hanging around from a prevous build are deleted. Not recommended in production environments")
84
+ opts.on("--output-file=FILE","Bundle to create that will be sent to the browser, relative to the JS public folder. Default is app.js")
85
+ opts.on("--source-file=FILE","Entry point used to create the bundle, relative to the source JS folder. Default is index.js")
86
+
87
+ def execute
88
+ js_bundle = Brut.container.js_bundle_output_dir / options.output_file(default: "app.js")
89
+ js_bundle_source = Brut.container.front_end_src_dir / "js" / options.source_file(default: "index.js")
90
+ esbuild_metafile = Brut.container.tmp_dir / "build-js-meta.json"
91
+ asset_metadata_file = Brut.container.asset_metadata_file
92
+
93
+ name_with_hash_regexp = /app\/public\/(?<path>.+)\/(?<name>.+)\-(?<hash>.+)\.js/
94
+ if options.clean?
95
+ out.puts "Cleaning old JS files from #{Brut.container.js_bundle_output_dir}"
96
+ Dir[Brut.container.js_bundle_output_dir / "*.*"].each do |file|
97
+ if File.file?(file)
98
+ out.puts "Deleting #{file}"
99
+ FileUtils.rm(file)
100
+ end
101
+ end
102
+ end
103
+
104
+ command = "npx esbuild --metafile=#{esbuild_metafile} --entry-names=[name]-[hash] --sourcemap --bundle #{js_bundle_source} --outfile=#{js_bundle}"
105
+ env_for_command = {
106
+ "NODE_PATH" => (Brut.container.project_root / "lib").to_s, # Not needed once Brut is properly bundled
107
+ }
108
+ out.puts "Building JS bundle '#{js_bundle}' with '#{command}'"
109
+ system!(env_for_command,command)
110
+
111
+ if !File.exist?(esbuild_metafile)
112
+ err.puts "'#{esbuild_metafile}' was not generated - cannot continue"
113
+ exit 1
114
+ end
115
+
116
+ asset_metadata = Brut::FrontEnd::AssetMetadata.new(asset_metadata_file:,out:)
117
+ asset_metadata.merge!(extension: ".js", esbuild_metafile:)
118
+ asset_metadata.save!
119
+ end
120
+
121
+ end
122
+
123
+ end