funicular 0.0.1 → 0.2.0

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,192 @@
1
+ # 'rng' is a PicoRuby gem available only in the wasm build. When the runtime
2
+ # is loaded under CRuby for SSR it is absent; FileUpload is client-only and
3
+ # its methods are never called on the server (see Funicular.server?).
4
+ begin
5
+ require 'rng'
6
+ rescue LoadError
7
+ # not available outside wasm environment
8
+ end
9
+
10
+ module Funicular
11
+ module FileUpload
12
+ JS_HELPER_CODE = <<~JAVASCRIPT
13
+ (function() {
14
+ 'use strict';
15
+ // Avoid duplicate mounting
16
+ if (window.funicularFormDataUpload) {
17
+ return;
18
+ }
19
+ // FormData upload helper
20
+ window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
21
+ const formData = new FormData();
22
+ for (const [key, value] of Object.entries(fieldsObj)) {
23
+ formData.append(key, String(value));
24
+ }
25
+ if (fileFieldName && fileRefId !== null && fileRefId !== undefined) {
26
+ const file = window.picorubyRefs[fileRefId];
27
+ if (file) {
28
+ formData.append(fileFieldName, file);
29
+ }
30
+ }
31
+ return fetch(url, {
32
+ method: 'PATCH',
33
+ body: formData
34
+ }).then(response => response.json());
35
+ };
36
+ console.log('Funicular helpers mounted');
37
+ })();
38
+ JAVASCRIPT
39
+
40
+ def self.mount
41
+ return if Funicular.server?
42
+ script = JS.document.createElement("script")
43
+ script[:textContent] = JS_HELPER_CODE
44
+ JS.document.body.appendChild(script)
45
+ JS.document.body.removeChild(script)
46
+ end
47
+
48
+ # Select a file from input element and generate preview
49
+ # @param input_id [String] DOM element ID of the file input
50
+ # @param block [Proc] Callback with preview data URL (or nil if no file)
51
+ def self.select_file_with_preview(input_id, &block)
52
+ input = JS.document.getElementById(input_id)
53
+ return block.call(nil, nil) unless input
54
+
55
+ # @type var files: Array[JS::Object]
56
+ files = input[:files]
57
+ if !files || files.length.to_i == 0
58
+ return block.call(nil, nil)
59
+ end
60
+
61
+ file = files[0]
62
+
63
+ # Use URL.createObjectURL instead of FileReader for preview
64
+ # This is simpler and doesn't require async callbacks
65
+ if file.nil?
66
+ raise "Selected file is nil"
67
+ else
68
+ url = JS.global[:URL]
69
+ if url.is_a?(JS::Object)
70
+ preview_url = url.createObjectURL(file)
71
+ else
72
+ preview_url = nil
73
+ end
74
+ end
75
+
76
+ # Call the block with file and preview URL
77
+ # @type var preview_url: String
78
+ block.call(file, preview_url) if block
79
+ end
80
+
81
+ # Upload data with FormData (supports file upload)
82
+ # @param url [String] Upload URL
83
+ # @param fields [Hash] Form fields to include
84
+ # @param file_field [String] Name of the file field
85
+ # @param file [JS::Object] File object from input element
86
+ # @param block [Proc] Callback with response data
87
+ def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
88
+ # Store file and callback ID
89
+ @callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
90
+ callback_id = _ = nil
91
+ while true
92
+ callback_id = RNG.random_int
93
+ break unless @callback_counters.include?(callback_id)
94
+ end
95
+ @callback_counters << callback_id
96
+
97
+ if file
98
+ JS.global[:_tempFileForUpload] = file
99
+ end
100
+
101
+ # Build fields object as JSON string
102
+ fields_json = JSON.generate(fields)
103
+
104
+ # Call JavaScript helper
105
+ # Note: The helper returns a Promise that resolves to JSON data (not Response)
106
+ script = JS.document.createElement("script")
107
+ script_code = <<~JS
108
+ (function() {
109
+ var fieldsObj = JSON.parse('#{fields_json.gsub("'", "\\\\'")}');
110
+ var fileRefId = null;
111
+ if (window._tempFileForUpload) {
112
+ fileRefId = window.picorubyRefs.indexOf(window._tempFileForUpload);
113
+ }
114
+ // Call helper and store result as JSON string when Promise resolves
115
+ window.funicularFormDataUpload(
116
+ '#{url}',
117
+ fieldsObj,
118
+ #{file_field ? "'#{file_field}'" : 'null'},
119
+ fileRefId
120
+ ).then(function(data) {
121
+ // Store as JSON string for easy Ruby parsing
122
+ window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
123
+ }).catch(function(error) {
124
+ window._funicularUploadResult_#{callback_id} = JSON.stringify({error: error.toString()});
125
+ });
126
+ })();
127
+ JS
128
+ script[:textContent] = script_code
129
+ JS.document.body.appendChild(script)
130
+ JS.document.body.removeChild(script)
131
+
132
+ # Poll for result (simple polling approach)
133
+ counter = 0
134
+ json_str = nil
135
+ while true
136
+ counter += 1
137
+ sleep 0.1
138
+ json_str = JS.global[:"_funicularUploadResult_#{callback_id}"]
139
+ break if json_str && !json_str.to_s.empty?
140
+ if 100 < counter
141
+ puts "[WARN] Timeout waiting for upload result. Return without calling block."
142
+ return
143
+ end
144
+ end
145
+
146
+ # Get JSON string from global variable (already retrieved above)
147
+ result = JSON.parse(json_str.to_s)
148
+
149
+ block.call(result) if block
150
+ puts "[DEBUG] Block called"
151
+ ensure
152
+ # Always cleanup global references, even on timeout or error
153
+ if callback_id
154
+ @callback_counters.delete(callback_id) if @callback_counters
155
+ if JS.global[:"_funicularUploadResult_#{callback_id}"]
156
+ JS.global[:"_funicularUploadResult_#{callback_id}"] = nil
157
+ end
158
+ end
159
+ if JS.global[:_tempFileForUpload]
160
+ JS.global[:_tempFileForUpload] = nil
161
+ end
162
+ end
163
+
164
+ # Helper: Store file in global variable for later use
165
+ def self.store_file(input_id, storage_key = '_selectedFile')
166
+ input = JS.document.getElementById(input_id)
167
+ return nil unless input
168
+
169
+ # @type var files: Array[JS::Object]
170
+ files = input[:files]
171
+ if !files || files.length.to_i == 0
172
+ JS.global[storage_key] = nil
173
+ return nil
174
+ end
175
+
176
+ file = files[0]
177
+ JS.global[storage_key] = file
178
+ file
179
+ end
180
+
181
+ # Helper: Retrieve stored file
182
+ def self.retrieve_file(storage_key = '_selectedFile')
183
+ file = JS.global[storage_key]
184
+ JS.global[storage_key] = nil if file
185
+ if file.is_a?(JS::Object)
186
+ file
187
+ else
188
+ nil
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,300 @@
1
+ module Funicular
2
+ class FormBuilder
3
+ attr_reader :component, :model_key, :options
4
+
5
+ def initialize(component, model_key, options = {})
6
+ @component = component
7
+ @model_key = model_key
8
+ @options = options
9
+ # Per-form options win, then the global Funicular.configure_forms config,
10
+ # then the built-in defaults. The defaults are semantic class names whose
11
+ # CSS the gem ships and injects via picoruby_include_tag, so error styling
12
+ # works without depending on the host app's CSS pipeline (e.g. Tailwind,
13
+ # which never scans the gem and so would not generate utility classes
14
+ # emitted from here).
15
+ config = Funicular.form_builder_config || {}
16
+ @error_class = options[:error_class] || config[:error_class] || "funicular-error"
17
+ @field_error_class = options[:field_error_class] || config[:field_error_class] || "funicular-field-error"
18
+ end
19
+
20
+ # Generic field builder for input elements
21
+ def build_field(field_name, field_type, field_options = {})
22
+ field_key = field_name.to_s
23
+ full_key = "#{@model_key}.#{field_key}"
24
+
25
+ # Get current value from state
26
+ value = get_nested_value(@component.state, full_key) || ""
27
+
28
+ # Build input change handler
29
+ on_input = ->(event) do
30
+ new_value = event.target[:value]
31
+ set_nested_value(@model_key, field_key, new_value)
32
+ end
33
+
34
+ # Check for errors
35
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
36
+ # errors may be a single message (legacy) or an array of messages
37
+ # (Funicular::Model::Errors#messages). Show the first.
38
+ error_message = error_message.first if error_message.is_a?(Array)
39
+ has_error = !(error_message.nil? || error_message == "")
40
+
41
+ # Merge CSS classes (add error class if error exists)
42
+ css_class = field_options[:class]
43
+ css_class = css_class.to_s if css_class
44
+ css_class ||= ""
45
+
46
+ if has_error
47
+ if css_class.empty?
48
+ css_class = @field_error_class
49
+ else
50
+ css_class = "#{css_class} #{@field_error_class}"
51
+ end
52
+ end
53
+
54
+ # Build field attributes
55
+ attrs = {
56
+ type: field_type,
57
+ value: value,
58
+ oninput: on_input
59
+ }.merge(field_options.reject { |k, _| k == :class })
60
+
61
+ # Add class attribute if not empty
62
+ attrs[:class] = css_class unless css_class.empty?
63
+
64
+ # Render field + error message
65
+ @component.div do
66
+ @component.input(attrs)
67
+ if has_error
68
+ @component.div(class: @error_class) { error_message }
69
+ end
70
+ end
71
+ end
72
+
73
+ # Public field methods
74
+ def text_field(field_name, options = {})
75
+ build_field(field_name, "text", options)
76
+ end
77
+
78
+ def password_field(field_name, options = {})
79
+ build_field(field_name, "password", options)
80
+ end
81
+
82
+ def email_field(field_name, options = {})
83
+ build_field(field_name, "email", options)
84
+ end
85
+
86
+ def number_field(field_name, options = {})
87
+ build_field(field_name, "number", options)
88
+ end
89
+
90
+ def textarea(field_name, options = {})
91
+ field_key = field_name.to_s
92
+ full_key = "#{@model_key}.#{field_key}"
93
+
94
+ value = get_nested_value(@component.state, full_key) || ""
95
+
96
+ on_input = ->(event) do
97
+ new_value = event.target[:value]
98
+ set_nested_value(@model_key, field_key, new_value)
99
+ end
100
+
101
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
102
+ # errors may be a single message (legacy) or an array of messages
103
+ # (Funicular::Model::Errors#messages). Show the first.
104
+ error_message = error_message.first if error_message.is_a?(Array)
105
+ has_error = !(error_message.nil? || error_message == "")
106
+
107
+ css_class = options[:class]
108
+ css_class = css_class.to_s if css_class
109
+ css_class ||= ""
110
+
111
+ if has_error
112
+ if css_class.empty?
113
+ css_class = @field_error_class
114
+ else
115
+ css_class = "#{css_class} #{@field_error_class}"
116
+ end
117
+ end
118
+
119
+ attrs = {
120
+ oninput: on_input
121
+ }.merge(options.reject { |k, _| k == :class })
122
+
123
+ attrs[:class] = css_class unless css_class.empty?
124
+
125
+ @component.div do
126
+ @component.textarea(attrs) { value }
127
+ if has_error
128
+ @component.div(class: @error_class) { error_message }
129
+ end
130
+ end
131
+ end
132
+
133
+ def checkbox(field_name, options = {})
134
+ field_key = field_name.to_s
135
+ full_key = "#{@model_key}.#{field_key}"
136
+
137
+ value = get_nested_value(@component.state, full_key) || false
138
+
139
+ on_change = ->(event) do
140
+ new_value = event.target[:checked]
141
+ set_nested_value(@model_key, field_key, new_value)
142
+ end
143
+
144
+ attrs = {
145
+ type: "checkbox",
146
+ checked: value,
147
+ onchange: on_change
148
+ }.merge(options)
149
+
150
+ @component.input(attrs)
151
+ end
152
+
153
+ def select(field_name, choices, options = {})
154
+ field_key = field_name.to_s
155
+ full_key = "#{@model_key}.#{field_key}"
156
+
157
+ value = get_nested_value(@component.state, full_key) || ""
158
+
159
+ on_change = ->(event) do
160
+ new_value = event.target[:value]
161
+ set_nested_value(@model_key, field_key, new_value)
162
+ end
163
+
164
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
165
+ # errors may be a single message (legacy) or an array of messages
166
+ # (Funicular::Model::Errors#messages). Show the first.
167
+ error_message = error_message.first if error_message.is_a?(Array)
168
+ has_error = !(error_message.nil? || error_message == "")
169
+
170
+ css_class = options[:class]
171
+ css_class = css_class.to_s if css_class
172
+ css_class ||= ""
173
+
174
+ if has_error
175
+ if css_class.empty?
176
+ css_class = @field_error_class
177
+ else
178
+ css_class = "#{css_class} #{@field_error_class}"
179
+ end
180
+ end
181
+
182
+ attrs = {
183
+ onchange: on_change
184
+ }.merge(options.reject { |k, _| k == :class })
185
+
186
+ attrs[:class] = css_class unless css_class.empty?
187
+
188
+ @component.div do
189
+ @component.select(attrs) do
190
+ choices.each do |choice|
191
+ option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
192
+ selected = value.to_s == option_value.to_s
193
+ @component.option(value: option_value, selected: selected) do
194
+ option_text
195
+ end
196
+ end
197
+ end
198
+ if has_error
199
+ @component.div(class: @error_class) { error_message }
200
+ end
201
+ end
202
+ end
203
+
204
+ def file_field(field_name, options = {})
205
+ field_key = field_name.to_s
206
+
207
+ # Check if custom onchange handler is provided
208
+ custom_handler = options.delete(:onchange)
209
+
210
+ # Default handler: store file name in state
211
+ default_handler = ->(event) do
212
+ file = event.target[:files] ? event.target[:files][0] : nil
213
+ file_name = if file.nil?
214
+ nil
215
+ else
216
+ # @type var file: Hash[Symbol, String]
217
+ file[:name]
218
+ end
219
+ set_nested_value(@model_key, field_key, file_name)
220
+ end
221
+
222
+ on_change = custom_handler || default_handler
223
+
224
+ attrs = {
225
+ type: "file",
226
+ onchange: on_change
227
+ }.merge(options)
228
+
229
+ @component.input(attrs)
230
+ end
231
+
232
+ def submit(label = "Submit", options = {})
233
+ attrs = { type: "submit" }.merge(options)
234
+ @component.button(attrs) { label }
235
+ end
236
+
237
+ def label(field_name, text = nil, options = {})
238
+ text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
239
+ @component.label(options) { text }
240
+ end
241
+
242
+ private
243
+
244
+ # Get nested value from state (e.g., "user.username")
245
+ def get_nested_value(state, key_path)
246
+ keys = key_path.split('.')
247
+ value = state
248
+ keys.each do |key|
249
+ if value.respond_to?(key.to_sym)
250
+ value = value.send(key.to_sym)
251
+ elsif value.is_a?(Hash)
252
+ value = value[key.to_sym] || value[key]
253
+ else
254
+ value = nil
255
+ end
256
+ break if value.nil?
257
+ end
258
+ value
259
+ end
260
+
261
+ # Set nested value in state via patch
262
+ def set_nested_value(model_key, field_key, new_value)
263
+ # Handle nested keys like "address.city"
264
+ if field_key.include?('.')
265
+ # Complex nested update
266
+ keys = field_key.split('.')
267
+ current_model = @component.state.send(model_key.to_sym)
268
+ updated_model = deep_merge_value(current_model, keys, new_value)
269
+ @component.patch(model_key.to_sym => updated_model)
270
+ else
271
+ # Simple update
272
+ current_model = @component.state.send(model_key.to_sym)
273
+ if current_model.nil?
274
+ @component.patch(model_key.to_sym => { field_key.to_sym => new_value })
275
+ elsif current_model.is_a?(Hash)
276
+ updated_model = current_model.merge(field_key.to_sym => new_value)
277
+ @component.patch(model_key.to_sym => updated_model)
278
+ else
279
+ # Assume it's an object with instance variables
280
+ current_model.instance_variable_set("@#{field_key}", new_value)
281
+ @component.patch(model_key.to_sym => current_model)
282
+ end
283
+ end
284
+ end
285
+
286
+ # Deep merge helper for nested keys
287
+ def deep_merge_value(hash, keys, value)
288
+ return hash unless hash.is_a?(Hash)
289
+
290
+ hash = hash.dup
291
+ if keys.length == 1
292
+ hash[keys[0].to_sym] = value
293
+ else
294
+ key = keys[0].to_sym
295
+ hash[key] = deep_merge_value(hash[key] || {}, (keys[1..-1] || []), value)
296
+ end
297
+ hash
298
+ end
299
+ end
300
+ end