ruby_wasm_ui 0.8.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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/ruby_comments.mdc +29 -0
  3. data/.github/workflows/playwright.yml +74 -0
  4. data/.github/workflows/rspec.yml +33 -0
  5. data/.node-version +1 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +218 -0
  9. data/Rakefile +4 -0
  10. data/docs/conditional-rendering.md +119 -0
  11. data/docs/lifecycle-hooks.md +75 -0
  12. data/docs/list-rendering.md +51 -0
  13. data/examples/Gemfile +5 -0
  14. data/examples/Gemfile.lock +41 -0
  15. data/examples/Makefile +15 -0
  16. data/examples/npm-packages/runtime/counter/index.html +28 -0
  17. data/examples/npm-packages/runtime/counter/index.rb +62 -0
  18. data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
  19. data/examples/npm-packages/runtime/hello/index.html +28 -0
  20. data/examples/npm-packages/runtime/hello/index.rb +29 -0
  21. data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
  22. data/examples/npm-packages/runtime/input/index.html +28 -0
  23. data/examples/npm-packages/runtime/input/index.rb +46 -0
  24. data/examples/npm-packages/runtime/input/index.spec.js +58 -0
  25. data/examples/npm-packages/runtime/list/index.html +27 -0
  26. data/examples/npm-packages/runtime/list/index.rb +33 -0
  27. data/examples/npm-packages/runtime/list/index.spec.js +46 -0
  28. data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
  29. data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
  30. data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
  31. data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
  32. data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
  33. data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
  34. data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
  35. data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
  36. data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
  37. data/examples/npm-packages/runtime/search_field/index.html +27 -0
  38. data/examples/npm-packages/runtime/search_field/index.rb +39 -0
  39. data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
  40. data/examples/npm-packages/runtime/todos/index.html +28 -0
  41. data/examples/npm-packages/runtime/todos/index.rb +239 -0
  42. data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
  43. data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
  44. data/examples/package.json +12 -0
  45. data/examples/src/counter/index.html +23 -0
  46. data/examples/src/counter/index.rb +60 -0
  47. data/lib/ruby_wasm_ui +1 -0
  48. data/lib/ruby_wasm_ui.rb +1 -0
  49. data/package-lock.json +100 -0
  50. data/package.json +32 -0
  51. data/packages/npm-packages/runtime/Gemfile +3 -0
  52. data/packages/npm-packages/runtime/Gemfile.lock +26 -0
  53. data/packages/npm-packages/runtime/README.md +5 -0
  54. data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
  55. data/packages/npm-packages/runtime/package-lock.json +6668 -0
  56. data/packages/npm-packages/runtime/package.json +38 -0
  57. data/packages/npm-packages/runtime/rollup.config.mjs +89 -0
  58. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/component_spec.rb +416 -0
  59. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/dom/scheduler_spec.rb +98 -0
  60. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/nodes_equal_spec.rb +190 -0
  61. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_conditional_group_spec.rb +505 -0
  62. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_for_group_spec.rb +377 -0
  63. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_vdom_spec.rb +573 -0
  64. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/parser_spec.rb +627 -0
  65. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/arrays_spec.rb +228 -0
  66. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/objects_spec.rb +127 -0
  67. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/props_spec.rb +205 -0
  68. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/strings_spec.rb +107 -0
  69. data/packages/npm-packages/runtime/spec/spec_helper.rb +16 -0
  70. data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
  71. data/packages/npm-packages/runtime/src/index.js +37 -0
  72. data/packages/npm-packages/runtime/src/ruby_wasm_ui/app.rb +53 -0
  73. data/packages/npm-packages/runtime/src/ruby_wasm_ui/component.rb +215 -0
  74. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dispatcher.rb +46 -0
  75. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/attributes.rb +105 -0
  76. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/destroy_dom.rb +63 -0
  77. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/events.rb +40 -0
  78. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/mount_dom.rb +108 -0
  79. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/patch_dom.rb +237 -0
  80. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/scheduler.rb +51 -0
  81. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom.rb +13 -0
  82. data/packages/npm-packages/runtime/src/ruby_wasm_ui/nodes_equal.rb +45 -0
  83. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_conditional_group.rb +150 -0
  84. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_for_group.rb +125 -0
  85. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_vdom.rb +220 -0
  86. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/parser.rb +134 -0
  87. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template.rb +11 -0
  88. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/arrays.rb +185 -0
  89. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/objects.rb +37 -0
  90. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/props.rb +25 -0
  91. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/strings.rb +19 -0
  92. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils.rb +11 -0
  93. data/packages/npm-packages/runtime/src/ruby_wasm_ui/vdom.rb +84 -0
  94. data/packages/npm-packages/runtime/src/ruby_wasm_ui/version.rb +5 -0
  95. data/packages/npm-packages/runtime/src/ruby_wasm_ui.rb +14 -0
  96. data/packages/npm-packages/runtime/vitest.config.js +8 -0
  97. data/playwright.config.js +78 -0
  98. data/sig/ruby_wasm_ui.rbs +4 -0
  99. metadata +168 -0
@@ -0,0 +1,237 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module PatchDom
4
+ # @param old_vdom [RubyWasmUi::Vdom]
5
+ # @param new_vdom [RubyWasmUi::Vdom]
6
+ # @param parent_el [JS::Object]
7
+ # @param host_component [RubyWasmUi::Component, nil]
8
+ # @return [RubyWasmUi::Vdom]
9
+ def execute(old_vdom, new_vdom, parent_el, host_component = nil)
10
+ if !NodesEqual.equal?(old_vdom, new_vdom)
11
+ index = find_index_in_parent(parent_el, old_vdom.el)
12
+ RubyWasmUi::Dom::DestroyDom.execute(old_vdom)
13
+ RubyWasmUi::Dom::MountDom.execute(new_vdom, parent_el, index, host_component)
14
+
15
+ return new_vdom
16
+ end
17
+
18
+ # when old_vdom and new_vdom is same type
19
+ new_vdom.el = old_vdom.el
20
+
21
+ case new_vdom.type
22
+ when RubyWasmUi::Vdom::DOM_TYPES[:TEXT]
23
+ patch_text(old_vdom, new_vdom)
24
+ return new_vdom # return early and skip children patch
25
+ when RubyWasmUi::Vdom::DOM_TYPES[:ELEMENT]
26
+ patch_element(old_vdom, new_vdom, host_component)
27
+ when RubyWasmUi::Vdom::DOM_TYPES[:COMPONENT]
28
+ patch_component(old_vdom, new_vdom)
29
+ else
30
+ # noop
31
+ end
32
+
33
+ patch_children(old_vdom, new_vdom, host_component)
34
+
35
+ new_vdom
36
+ end
37
+
38
+ module_function :execute
39
+
40
+ private
41
+
42
+ # @param parent_el [JS::Object]
43
+ # @param el [JS::Object]
44
+ # @return [Integer, nil]
45
+ def self.find_index_in_parent(parent_el, el)
46
+ index = parent_el[:childNodes].to_a.index(el)
47
+ return nil if index < 0
48
+
49
+ index
50
+ end
51
+
52
+ # @param old_vdom [RubyWasmUi::Vdom]
53
+ # @param new_vdom [RubyWasmUi::Vdom]
54
+ def self.patch_text(old_vdom, new_vdom)
55
+ el = old_vdom.el
56
+ old_text = old_vdom.value
57
+ new_text = new_vdom.value
58
+
59
+ if old_text != new_text
60
+ el.nodeValue = new_text
61
+ end
62
+ end
63
+
64
+ # @param old_vdom [RubyWasmUi::Vdom]
65
+ # @param new_vdom [RubyWasmUi::Vdom]
66
+ # @param host_component [RubyWasmUi::Component, nil]
67
+ def self.patch_element(old_vdom, new_vdom, host_component)
68
+ el = old_vdom.el
69
+
70
+ # Extract attributes from oldVdom.props (equivalent to JavaScript destructuring)
71
+ old_props = old_vdom.props || {}
72
+ old_class = old_props[:class]
73
+ old_style = old_props[:style]
74
+ old_events = old_props[:on]
75
+ old_attrs = old_props.reject { |key, _| [:class, :style, :on].include?(key) }
76
+
77
+ # Extract attributes from newVdom.props
78
+ new_props = new_vdom.props || {}
79
+ new_class = new_props[:class]
80
+ new_style = new_props[:style]
81
+ new_events = new_props[:on]
82
+ new_attrs = new_props.reject { |key, _| [:class, :style, :on].include?(key) }
83
+
84
+ # Get listeners from oldVdom
85
+ old_listeners = old_vdom.listeners
86
+
87
+ patch_attrs(el, old_attrs, new_attrs)
88
+ patch_classes(el, old_class, new_class)
89
+ patch_styles(el, old_style, new_style)
90
+ new_vdom.listeners = patch_events(el, old_listeners, old_events, new_events, host_component)
91
+ end
92
+
93
+ # @param el [JS::Object]
94
+ # @param old_attrs [Hash]
95
+ # @param new_attrs [Hash]
96
+ def self.patch_attrs(el, old_attrs, new_attrs)
97
+ diff = RubyWasmUi::Utils::Objects.diff(old_attrs, new_attrs)
98
+
99
+ diff[:removed].each do |key|
100
+ RubyWasmUi::Dom::Attributes.remove_attribute(el, key)
101
+ end
102
+
103
+ (diff[:added] + diff[:updated]).each do |key|
104
+ RubyWasmUi::Dom::Attributes.set_attribute(el, key, new_attrs[key])
105
+ end
106
+ end
107
+
108
+ # @param el [JS::Object]
109
+ # @param old_class [String, Array]
110
+ # @param new_class [String, Array]
111
+ def self.patch_classes(el, old_class, new_class)
112
+ old_classes = to_class_list(old_class)
113
+ new_classes = to_class_list(new_class)
114
+
115
+ diff = RubyWasmUi::Utils::Arrays.diff(old_classes, new_classes)
116
+
117
+ diff[:removed].each do |key|
118
+ el[:classList].remove(key)
119
+ end
120
+
121
+ diff[:added].each do |key|
122
+ el[:classList].add(key)
123
+ end
124
+ end
125
+
126
+ # @param el [JS::Object]
127
+ # @param old_style [Hash, String]
128
+ # @param new_style [Hash, String]
129
+ def self.patch_styles(el, old_style = {}, new_style = {})
130
+ parsed_old_style = RubyWasmUi::Dom::Attributes.parse_style(old_style)
131
+ parsed_new_style = RubyWasmUi::Dom::Attributes.parse_style(new_style)
132
+ diff = RubyWasmUi::Utils::Objects.diff(parsed_old_style || {}, parsed_new_style || {})
133
+
134
+ diff[:removed].each do |key|
135
+ RubyWasmUi::Dom::Attributes.remove_style(el, key)
136
+ end
137
+
138
+ (diff[:added] + diff[:updated]).each do |key|
139
+ RubyWasmUi::Dom::Attributes.set_style(el, key, new_style[key])
140
+ end
141
+ end
142
+
143
+ # @param el [JS::Object]
144
+ # @param old_listeners [Hash]
145
+ # @param old_events [Hash]
146
+ # @param new_events [Hash]
147
+ # @return [Hash]
148
+ def self.patch_events(el, old_listeners = {}, old_events = {}, new_events = {}, host_component = nil)
149
+ diff = RubyWasmUi::Utils::Objects.diff(old_events || {}, new_events || {})
150
+
151
+ # Remove old event listeners for removed and updated events
152
+ (diff[:removed] + diff[:updated]).each do |event_name|
153
+ if old_listeners[event_name]
154
+ el.call(:removeEventListener, event_name.to_s, old_listeners[event_name])
155
+ end
156
+ end
157
+
158
+ added_listeners = {}
159
+
160
+ # Add new event listeners for added and updated events
161
+ (diff[:added] + diff[:updated]).each do |event_name|
162
+ listener = RubyWasmUi::Dom::Events.add_event_listener(
163
+ event_name,
164
+ new_events[event_name],
165
+ el,
166
+ host_component
167
+ )
168
+ added_listeners[event_name] = listener
169
+ end
170
+
171
+ added_listeners
172
+ end
173
+
174
+ # @param old_vdom [RubyWasmUi::Vdom]
175
+ # @param new_vdom [RubyWasmUi::Vdom]
176
+ # @param host_component [RubyWasmUi::Component, nil]
177
+ # @return [void]
178
+ def self.patch_children(old_vdom, new_vdom, host_component)
179
+ old_children = RubyWasmUi::Vdom.extract_children(old_vdom)
180
+ new_children = RubyWasmUi::Vdom.extract_children(new_vdom)
181
+ parent_el = old_vdom.el
182
+
183
+ equal_proc = ->(a, b) { NodesEqual.equal?(a, b) }
184
+
185
+ diff_seq = RubyWasmUi::Utils::Arrays.diff_sequence(old_children, new_children, equal_proc)
186
+ offset = host_component&.offset || 0
187
+
188
+ diff_seq.each do |operation|
189
+ original_index = operation[:original_index]
190
+ index = operation[:index]
191
+ item = operation[:item]
192
+
193
+ case operation[:op]
194
+ when 'add'
195
+ RubyWasmUi::Dom::MountDom.execute(item, parent_el, index + offset, host_component)
196
+ when 'remove'
197
+ RubyWasmUi::Dom::DestroyDom.execute(item)
198
+ when 'move'
199
+ old_child = old_children[original_index]
200
+ new_child = new_children[index]
201
+ el = old_child.el
202
+ el_at_target_index = parent_el[:childNodes][index + offset]
203
+
204
+ parent_el.insertBefore(el, el_at_target_index)
205
+ RubyWasmUi::Dom::PatchDom.execute(old_child, new_child, parent_el, host_component)
206
+ when 'noop'
207
+ RubyWasmUi::Dom::PatchDom.execute(old_children[original_index], new_children[index], parent_el, host_component)
208
+ end
209
+ end
210
+ end
211
+
212
+ # @param classes [String, Array]
213
+ # @return [Array]
214
+ def self.to_class_list(classes = '')
215
+ if classes.is_a?(Array)
216
+ classes.select { |c| RubyWasmUi::Utils::Strings.is_not_blank_or_empty_string(c) }
217
+ else
218
+ # string case
219
+ classes.to_s.split(/\s+/).select { |c| RubyWasmUi::Utils::Strings.is_not_empty_string(c) }
220
+ end
221
+ end
222
+
223
+ # @param old_vdom [RubyWasmUi::Vdom]
224
+ # @param new_vdom [RubyWasmUi::Vdom]
225
+ # @return [void]
226
+ def self.patch_component(old_vdom, new_vdom)
227
+ component = old_vdom.component
228
+ props = RubyWasmUi::Utils::Props.extract_props_and_events(new_vdom)[:props]
229
+
230
+ component.update_props(props)
231
+
232
+ new_vdom.component = component
233
+ new_vdom.el = component.first_element
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,51 @@
1
+ module RubyWasmUi
2
+ module Dom
3
+ module Scheduler
4
+ class << self
5
+ # @return [Boolean] Flag indicating if a job processing is scheduled
6
+ attr_accessor :scheduled
7
+
8
+ # @return [Array] Array of jobs to be processed
9
+ attr_accessor :jobs
10
+
11
+ # Initialize class variables
12
+ def initialize_scheduler
13
+ @scheduled = false
14
+ @jobs = []
15
+ end
16
+
17
+ # Enqueues a job to be processed
18
+ # @param job [Proc] The job to be executed
19
+ # @return [void]
20
+ def enqueue_job(job)
21
+ initialize_scheduler if @jobs.nil?
22
+ @jobs.push(job)
23
+ schedule_update
24
+ end
25
+
26
+ private
27
+
28
+ # Schedules an update if not already scheduled
29
+ # @return [void]
30
+ def schedule_update
31
+ return if @scheduled
32
+
33
+ @scheduled = true
34
+ # Using JS.global to access queueMicrotask
35
+ JS.global.queueMicrotask(-> { process_jobs })
36
+ end
37
+
38
+ # Processes all jobs in the queue
39
+ # @return [void]
40
+ def process_jobs
41
+ while @jobs.any?
42
+ job = @jobs.shift
43
+ job.call
44
+ end
45
+
46
+ @scheduled = false
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dom/attributes"
4
+ require_relative "dom/destroy_dom"
5
+ require_relative "dom/events"
6
+ require_relative "dom/mount_dom"
7
+ require_relative "dom/patch_dom"
8
+ require_relative "dom/scheduler"
9
+
10
+ module RubyWasmUi
11
+ module Dom
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ module RubyWasmUi
2
+ module NodesEqual
3
+ module_function
4
+
5
+ # @param node_one [RubyWasmUi::Vdom]
6
+ # @param node_two [RubyWasmUi::Vdom]
7
+ # @return [Boolean]
8
+ def equal?(node_one, node_two)
9
+ if node_one.type != node_two.type
10
+ return false
11
+ end
12
+
13
+ if node_one.type == RubyWasmUi::Vdom::DOM_TYPES[:ELEMENT]
14
+ tag_one = node_one.tag
15
+ key_one = node_one.props[:key]
16
+ tag_two = node_two.tag
17
+ key_two = node_two.props[:key]
18
+
19
+ return tag_one == tag_two && key_one == key_two
20
+ end
21
+
22
+ if node_one.type == RubyWasmUi::Vdom::DOM_TYPES[:COMPONENT]
23
+ component_one = node_one.tag
24
+ key_one = node_one.props[:key]
25
+ component_two = node_two.tag
26
+ key_two = node_two.props[:key]
27
+
28
+ return component_one == component_two && key_one == key_two
29
+ end
30
+
31
+ if node_one.type == RubyWasmUi::Vdom::DOM_TYPES[:TEXT]
32
+ value_one = node_one.value
33
+ value_two = node_two.value
34
+
35
+ return value_one == value_two
36
+ end
37
+
38
+ if node_one.type == RubyWasmUi::Vdom::DOM_TYPES[:FRAGMENT]
39
+ return true
40
+ end
41
+
42
+ false
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Template
5
+ module BuildConditionalGroup
6
+ module_function
7
+
8
+ # Check if element has conditional attributes (r-if, r-elsif, r-else)
9
+ # @param element [JS.Object]
10
+ # @return [Boolean]
11
+ def has_conditional_attribute?(element)
12
+ return false unless element[:attributes]
13
+
14
+ length = element[:attributes][:length].to_i
15
+ length.times do |i|
16
+ attribute = element[:attributes][i]
17
+ key = attribute[:name].to_s
18
+ return true if %w[r-if r-elsif r-else].include?(key)
19
+ end
20
+ false
21
+ end
22
+
23
+ # Build conditional group (r-if, r-elsif, r-else)
24
+ # @param elements [JS.Array] Array of elements
25
+ # @param start_index [Integer] Starting index
26
+ # @return [Array] [conditional_code, next_index]
27
+ def build_conditional_group(elements, start_index)
28
+ conditions = []
29
+ current_index = start_index
30
+ elements_length = elements[:length].to_i
31
+
32
+ # Process all consecutive conditional elements
33
+ while current_index < elements_length
34
+ element = elements[current_index]
35
+ # Skip text nodes (whitespace between elements)
36
+ if element[:nodeType] == JS.global[:Node][:TEXT_NODE]
37
+ current_index += 1
38
+ next
39
+ end
40
+
41
+ # Break if this is not an element with conditional attributes
42
+ unless element[:nodeType] == JS.global[:Node][:ELEMENT_NODE] && has_conditional_attribute?(element)
43
+ break
44
+ end
45
+
46
+ conditional_type = get_conditional_type(element)
47
+
48
+ # If we encounter a new r-if after the first one, break the group
49
+ if conditions.any? && conditional_type == 'r-if'
50
+ break
51
+ end
52
+
53
+ condition = get_conditional_expression(element)
54
+ content = build_single_conditional_content(element)
55
+
56
+ case conditional_type
57
+ when 'r-if'
58
+ conditions << "if #{condition}"
59
+ conditions << " #{content}"
60
+ when 'r-elsif'
61
+ conditions << "elsif #{condition}"
62
+ conditions << " #{content}"
63
+ when 'r-else'
64
+ conditions << "else"
65
+ conditions << " #{content}"
66
+ current_index += 1
67
+ break
68
+ end
69
+
70
+ current_index += 1
71
+ end
72
+
73
+ # Add final else clause if not present
74
+ unless conditions.any? { |c| c == 'else' }
75
+ conditions << "else"
76
+ conditions << " RubyWasmUi::Vdom.h_fragment([])"
77
+ end
78
+
79
+ conditions << "end"
80
+
81
+ [conditions.join("\n"), current_index]
82
+ end
83
+
84
+ # Build content for a single conditional element
85
+ # @param element [JS.Object]
86
+ # @return [String]
87
+ def build_single_conditional_content(element)
88
+ tag_name = element[:tagName].to_s.downcase
89
+ filtered_attributes = filter_conditional_attributes(element[:attributes])
90
+
91
+ if tag_name == 'template' || (tag_name == 'div' && BuildVdom.has_data_template_attribute?(element))
92
+ # For template elements, render the content directly
93
+ BuildVdom.build_fragment(element, tag_name)
94
+ elsif BuildVdom.is_component?(tag_name)
95
+ # For components, render the component
96
+ BuildVdom.build_component(element, tag_name, filtered_attributes)
97
+ else
98
+ # For regular HTML elements, render the element
99
+ BuildVdom.build_element(element, tag_name, filtered_attributes)
100
+ end
101
+ end
102
+
103
+ # Get conditional expression from element attributes
104
+ # @param element [JS.Object]
105
+ # @return [String]
106
+ def get_conditional_expression(element)
107
+ length = element[:attributes][:length].to_i
108
+ length.times do |i|
109
+ attribute = element[:attributes][i]
110
+ key = attribute[:name].to_s
111
+ value = attribute[:value].to_s
112
+
113
+ if %w[r-if r-elsif].include?(key)
114
+ return BuildVdom.embed_script?(value) ? BuildVdom.get_embed_script(value) : value
115
+ end
116
+ end
117
+ 'true' # fallback for r-else
118
+ end
119
+
120
+ # Get conditional type from element attributes
121
+ # @param element [JS.Object]
122
+ # @return [String]
123
+ def get_conditional_type(element)
124
+ length = element[:attributes][:length].to_i
125
+ length.times do |i|
126
+ attribute = element[:attributes][i]
127
+ key = attribute[:name].to_s
128
+ return key if %w[r-if r-elsif r-else].include?(key)
129
+ end
130
+ 'r-if' # fallback
131
+ end
132
+
133
+ # Filter out conditional attributes from the attributes list
134
+ # @param attributes [JS.Object]
135
+ # @return [Array]
136
+ def filter_conditional_attributes(attributes)
137
+ filtered = []
138
+ length = attributes[:length].to_i
139
+ length.times do |i|
140
+ attribute = attributes[i]
141
+ key = attribute[:name].to_s
142
+ unless %w[r-if r-elsif r-else data-template].include?(key)
143
+ filtered << attribute
144
+ end
145
+ end
146
+ filtered
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmUi
4
+ module Template
5
+ module BuildForGroup
6
+ module_function
7
+
8
+ # Check if element has r-for attribute
9
+ # @param element [JS.Object]
10
+ # @return [Boolean]
11
+ def has_for_attribute?(element)
12
+ return false unless element[:attributes]
13
+
14
+ length = element[:attributes][:length].to_i
15
+ length.times do |i|
16
+ attribute = element[:attributes][i]
17
+ key = attribute[:name].to_s
18
+ return true if key == 'r-for'
19
+ end
20
+ false
21
+ end
22
+
23
+ # Build for loop code for r-for attribute
24
+ # @param element [JS.Object]
25
+ # @return [String]
26
+ def build_for_loop(element)
27
+ for_expression = get_for_expression(element)
28
+ return '' unless for_expression
29
+
30
+ # Parse r-for expression like "{todo in todos}"
31
+ # Remove curly braces and parse
32
+ clean_expression = for_expression.gsub(/^\{|\}$/, '').strip
33
+
34
+ # Match pattern: "item in collection"
35
+ if clean_expression.match(/^(\w+)\s+in\s+(.+)$/)
36
+ item_var = $1
37
+ collection_expr = $2
38
+
39
+ # Get the tag name and filtered attributes (excluding r-for)
40
+ tag_name = element[:tagName].to_s.downcase
41
+ filtered_attributes = filter_for_attributes(element[:attributes])
42
+
43
+ # Generate the map code
44
+ if RubyWasmUi::Template::BuildVdom.is_component?(tag_name)
45
+ component_code = build_component_for_item(element, tag_name, filtered_attributes, item_var)
46
+ "#{collection_expr}.map do |#{item_var}|\n #{component_code}\nend"
47
+ else
48
+ element_code = build_element_for_item(element, tag_name, filtered_attributes, item_var)
49
+ "#{collection_expr}.map do |#{item_var}|\n #{element_code}\nend"
50
+ end
51
+ else
52
+ # Fallback for invalid r-for syntax
53
+ ''
54
+ end
55
+ end
56
+
57
+ # Get r-for expression from element attributes
58
+ # @param element [JS.Object]
59
+ # @return [String, nil]
60
+ def get_for_expression(element)
61
+ length = element[:attributes][:length].to_i
62
+ length.times do |i|
63
+ attribute = element[:attributes][i]
64
+ key = attribute[:name].to_s
65
+ value = attribute[:value].to_s
66
+
67
+ if key == 'r-for'
68
+ return RubyWasmUi::Template::BuildVdom.embed_script?(value) ? RubyWasmUi::Template::BuildVdom.get_embed_script(value) : value
69
+ end
70
+ end
71
+ nil
72
+ end
73
+
74
+ # Filter out r-for attribute from the attributes list
75
+ # @param attributes [JS.Object]
76
+ # @return [Array]
77
+ def filter_for_attributes(attributes)
78
+ filtered = []
79
+ length = attributes[:length].to_i
80
+
81
+ length.times do |i|
82
+ attribute = attributes[i]
83
+ key = attribute[:name].to_s
84
+ value = attribute[:value].to_s
85
+
86
+ # Skip r-for attribute
87
+ next if key == 'r-for'
88
+
89
+ # Create attribute object in the format expected by parse_attributes
90
+ filtered << { name: key, value: value }
91
+ end
92
+
93
+ filtered
94
+ end
95
+
96
+ # Build component code for a single item in the loop
97
+ # @param element [JS.Object]
98
+ # @param tag_name [String]
99
+ # @param filtered_attributes [Array]
100
+ # @param item_var [String]
101
+ # @return [String]
102
+ def build_component_for_item(element, tag_name, filtered_attributes, item_var)
103
+ attributes_str = RubyWasmUi::Template::BuildVdom.parse_attributes(filtered_attributes)
104
+ children = RubyWasmUi::Template::BuildVdom.build(element[:childNodes])
105
+
106
+ # Convert kebab-case to PascalCase for component name
107
+ component_name = tag_name.split('-').map(&:capitalize).join
108
+ "RubyWasmUi::Vdom.h(#{component_name}, {#{attributes_str}}, [#{children}])"
109
+ end
110
+
111
+ # Build element code for a single item in the loop
112
+ # @param element [JS.Object]
113
+ # @param tag_name [String]
114
+ # @param filtered_attributes [Array]
115
+ # @param item_var [String]
116
+ # @return [String]
117
+ def build_element_for_item(element, tag_name, filtered_attributes, item_var)
118
+ attributes_str = RubyWasmUi::Template::BuildVdom.parse_attributes(filtered_attributes)
119
+ children = RubyWasmUi::Template::BuildVdom.build(element[:childNodes])
120
+
121
+ "RubyWasmUi::Vdom.h('#{tag_name}', {#{attributes_str}}, [#{children}])"
122
+ end
123
+ end
124
+ end
125
+ end