@sue445/rb-wasm-vdom 0.1.0-beta.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Go Sueyoshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # rb-wasm-vdom
2
+ A reactive Virtual DOM library for [ruby.wasm](https://github.com/ruby/ruby.wasm) and [Picoruby.wasm](https://www.npmjs.com/package/@picoruby/wasm-wasi)
3
+
4
+ [![build](https://github.com/sue445/rb-wasm-vdom/actions/workflows/build.yml/badge.svg)](https://github.com/sue445/rb-wasm-vdom/actions/workflows/build.yml)
5
+
6
+ ## Development
7
+ ### Run unit test
8
+ At first, install [wasmtime](https://docs.wasmtime.dev/cli-install.html)
9
+
10
+ * Mac: `brew install wasmtime`
11
+
12
+ ```bash
13
+ npm run test:unit
14
+ ```
15
+
16
+ ### Run integration test
17
+ ```bash
18
+ npx playwright install
19
+ npm run test:integration
20
+ ```
@@ -0,0 +1,22 @@
1
+ //#endregion
2
+ //#region src/index.js
3
+ var e = [
4
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n # Virtual DOM Node Definition\n class VNode\n attr_reader :tag #: String\n attr_reader :props #: Hash[String, String]\n attr_reader :children #: Array[VNode | String]\n\n # @rbs tag: String\n # @rbs props: Hash[String, String]\n # @rbs children: Array[VNode | String]\n # @rbs return: void\n def initialize(tag, props = {}, children = [])\n @tag = tag\n @props = props\n @children = children\n end\n end\nend\n",
5
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n # Reactive State Management\n class ReactiveState\n # @rbs initial_data: Hash[Symbol, untyped]\n # @rbs &on_change: () -> void\n # @rbs return: void\n def initialize(initial_data, &on_change)\n @data = initial_data\n @on_change = on_change\n end\n\n # @rbs key: Symbol\n # @rbs return: untyped\n def [](key)\n @data[key]\n end\n\n # @rbs key: Symbol\n # @rbs value: untyped\n # @rbs return: void\n def []=(key, value)\n return if @data[key] == value\n\n @data[key] = value\n @on_change.call\n end\n end\nend\n",
6
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n # HTML Template Parser\n class TemplateParser\n # @rbs return: void\n def self.setup_js_parser # rubocop:disable Metrics/MethodLength\n # Add a prefix to the function name to prevent global namespace pollution in JS\n JS.eval(<<~JS)\n window.__RbWasmVdom_parseHTMLToJSON = function(html) {\n const doc = new DOMParser().parseFromString(html, \"text/html\");\n function walk(node) {\n if (node.nodeType === 3) {\n const text = node.textContent.trim();\n return text ? text : null;\n }\n if (node.nodeType === 1) {\n const obj = { tag: node.tagName.toLowerCase(), props: {}, children: [] };\n for (let i = 0; i < node.attributes.length; i++) {\n obj.props[node.attributes[i].name] = node.attributes[i].value;\n }\n for (let i = 0; i < node.childNodes.length; i++) {\n const childRes = walk(node.childNodes[i]);\n if (childRes !== null) obj.children.push(childRes);\n }\n return obj;\n }\n return null;\n }\n const root = doc.body.firstElementChild;\n return root ? JSON.stringify(walk(root)) : \"null\";\n }\n JS\n @setup_done = true\n end\n\n # @rbs html_string: String\n # @rbs return: VNode | String | nil\n def self.parse(html_string)\n setup_js_parser unless @setup_done\n json_str = JS.global.__RbWasmVdom_parseHTMLToJSON(html_string).to_s\n return nil if json_str == \"null\" || json_str.empty?\n\n build_ast(JSON.parse(json_str))\n end\n\n # @rbs data: Hash[String, untyped] | String\n # @rbs return: VNode | String\n def self.build_ast(data)\n return data if data.is_a?(String)\n\n children = data[\"children\"].map { |child| build_ast(child) }\n VNode.new(data[\"tag\"], data[\"props\"], children)\n end\n end\nend\n",
7
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n module DomRenderer\n private\n\n # @rbs vnode: VNode | String\n # @rbs return: untyped\n def create_element(vnode)\n document = JS.global[:document]\n return document.createTextNode(vnode) if vnode.is_a?(String)\n\n el = document.createElement(vnode.tag)\n update_props(el, {}, vnode.props)\n\n vnode.children.each do |child|\n el.appendChild(create_element(child))\n end\n el\n end\n\n # @rbs el: untyped\n # @rbs old_props: Hash[String, String]\n # @rbs new_props: Hash[String, String]\n # @rbs return: void\n def update_props(el, old_props, new_props)\n remove_old_props(el, old_props, new_props)\n apply_new_props(el, old_props, new_props)\n end\n\n # @rbs el: untyped\n # @rbs old_props: Hash[String, String]\n # @rbs new_props: Hash[String, String]\n # @rbs return: void\n def remove_old_props(el, old_props, new_props)\n old_props.each_key do |key|\n el.removeAttribute(key) unless new_props.key?(key) || key.start_with?(\"@\")\n end\n end\n\n # @rbs el: untyped\n # @rbs old_props: Hash[String, String]\n # @rbs new_props: Hash[String, String]\n # @rbs return: void\n def apply_new_props(el, old_props, new_props)\n new_props.each do |key, value|\n next if old_props[key] == value\n\n apply_prop(el, old_props, key, value)\n end\n end\n\n # @rbs el: untyped\n # @rbs old_props: Hash[String, String]\n # @rbs key: String\n # @rbs value: String\n # @rbs return: void\n def apply_prop(el, old_props, key, value)\n if key.start_with?(\"@\")\n add_event_listener(el, old_props, key, value)\n elsif key == \"value\"\n update_value_prop(el, key, value)\n else\n el.setAttribute(key, value)\n end\n end\n\n # @rbs el: untyped\n # @rbs old_props: Hash[String, String]\n # @rbs key: String\n # @rbs value: String\n # @rbs return: void\n def add_event_listener(el, old_props, key, value)\n return if old_props.key?(key)\n\n event_name = key.sub(/^@/, \"\")\n el.addEventListener(event_name) do |e|\n @methods[value.to_sym].call(e, @state)\n end\n end\n\n # @rbs el: untyped\n # @rbs key: String\n # @rbs value: String\n # @rbs return: void\n def update_value_prop(el, key, value)\n el[:value] = value\n el.setAttribute(key, value)\n end\n end\nend\n",
8
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n module Patcher\n include DomRenderer\n\n # @rbs parent_el: untyped\n # @rbs old_vnode: VNode | String | nil\n # @rbs new_vnode: VNode | String | nil\n # @rbs index: Integer\n # @rbs return: void\n def patch(parent_el, old_vnode, new_vnode, index)\n current_el = child_at(parent_el, index)\n\n return append_node(parent_el, new_vnode) if old_vnode.nil?\n return remove_node(parent_el, current_el) if new_vnode.nil?\n return replace_node(parent_el, current_el, new_vnode) if changed?(old_vnode, new_vnode)\n\n patch_element(current_el, old_vnode, new_vnode) if old_vnode.is_a?(VNode) && new_vnode.is_a?(VNode)\n end\n\n private\n\n # @rbs node1: VNode | String\n # @rbs node2: VNode | String\n # @rbs return: bool\n def changed?(node1, node2)\n return true if node1.class != node2.class\n return node1 != node2 if node1.is_a?(String)\n\n # @type var node1: VNode\n # @type var node2: VNode\n\n node1.tag != node2.tag\n end\n\n # @rbs parent_el: untyped\n # @rbs index: Integer\n # @rbs return: untyped\n def child_at(parent_el, index)\n parent_el[:childNodes].item(index)\n end\n\n # @rbs parent_el: untyped\n # @rbs new_vnode: VNode | String | nil\n # @rbs return: void\n def append_node(parent_el, new_vnode)\n parent_el.appendChild(create_element(new_vnode)) if new_vnode\n end\n\n # @rbs parent_el: untyped\n # @rbs current_el: untyped\n # @rbs return: void\n def remove_node(parent_el, current_el)\n parent_el.removeChild(current_el) unless current_el == JS::Null\n end\n\n # @rbs parent_el: untyped\n # @rbs current_el: untyped\n # @rbs new_vnode: VNode | String\n # @rbs return: void\n def replace_node(parent_el, current_el, new_vnode)\n parent_el.replaceChild(create_element(new_vnode), current_el)\n end\n\n # @rbs current_el: untyped\n # @rbs old_vnode: VNode\n # @rbs new_vnode: VNode\n # @rbs return: void\n def patch_element(current_el, old_vnode, new_vnode)\n update_props(current_el, old_vnode.props, new_vnode.props)\n\n old_children = old_vnode.children\n new_children = new_vnode.children\n\n patch_children(current_el, old_children, new_children)\n end\n\n # @rbs current_el: untyped\n # @rbs old_children: Array[VNode | String]\n # @rbs new_children: Array[VNode | String]\n # @rbs return: void\n def patch_children(current_el, old_children, new_children)\n remove_extra_children(current_el, old_children, new_children)\n patch_common_children(current_el, old_children, new_children)\n\n return unless new_children.length > old_children.length\n\n append_missing_children(current_el, old_children, new_children)\n end\n\n # @rbs current_el: untyped\n # @rbs old_children: Array[VNode | String]\n # @rbs new_children: Array[VNode | String]\n # @rbs return: void\n def remove_extra_children(current_el, old_children, new_children)\n return unless old_children.length > new_children.length\n\n (old_children.length - 1).downto(new_children.length) do |i|\n child_to_remove = current_el[:childNodes].item(i)\n current_el.removeChild(child_to_remove) unless child_to_remove == JS::Null\n end\n end\n\n # @rbs current_el: untyped\n # @rbs old_children: Array[VNode | String]\n # @rbs new_children: Array[VNode | String]\n # @rbs return: void\n def patch_common_children(current_el, old_children, new_children)\n [old_children.length, new_children.length].min.times do |i|\n patch(current_el, old_children[i], new_children[i], i)\n end\n end\n\n # @rbs current_el: untyped\n # @rbs old_children: Array[VNode | String]\n # @rbs new_children: Array[VNode | String]\n # @rbs return: void\n def append_missing_children(current_el, old_children, new_children)\n (old_children.length...new_children.length).each do |i|\n current_el.appendChild(create_element(new_children[i]))\n end\n end\n end\nend\n",
9
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n class Interpolator\n PLACEHOLDER_PATTERN = /\\{\\{\\s*\\w+\\s*\\}\\}/\n KEY_PATTERN = /\\w+/\n\n # @rbs state: ReactiveState\n def initialize(state)\n @state = state\n end\n\n # @rbs text: String\n def call(text)\n text.to_s.gsub(PLACEHOLDER_PATTERN) do |placeholder|\n interpolate_placeholder(placeholder)\n end\n end\n\n private\n\n # @rbs placeholder: String\n # @rbs return: String\n def interpolate_placeholder(placeholder)\n key = placeholder.match(KEY_PATTERN)&.[](0)\n return placeholder unless key\n\n @state[key.to_sym].to_s\n end\n end\nend\n",
10
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nmodule RbWasmVdom\n # Framework Core\n class App\n include Patcher\n\n # @rbs selector: String\n # @rbs template: String\n # @rbs state: Hash[Symbol, untyped]\n # @rbs methods: Hash[Symbol, Proc]\n # @rbs return: void\n def initialize(selector, template:, state:, methods:)\n @el = JS.global[:document].querySelector(selector)\n @template_ast = TemplateParser.parse(template)\n @methods = methods\n @current_vnode = nil\n\n @state = ReactiveState.new(state) do\n render_cycle\n end\n\n @interpolator = Interpolator.new(@state)\n\n render_cycle\n end\n\n private\n\n # @rbs return: void\n def render_cycle\n new_vnode = build_vdom(@template_ast)\n\n if @current_vnode.nil?\n @el[:innerHTML] = \"\"\n @el.appendChild(create_element(new_vnode))\n else\n patch(@el, @current_vnode, new_vnode, 0)\n end\n\n @current_vnode = new_vnode\n end\n\n # @rbs ast_node: VNode | String\n # @rbs return: VNode | String\n def build_vdom(ast_node)\n return @interpolator.call(ast_node) if ast_node.is_a?(String)\n\n new_props = {} #: Hash[String, String]\n ast_node.props.each do |k, v|\n new_props[k] = @interpolator.call(v)\n end\n\n new_children = ast_node.children.map { |child| build_vdom(child) }\n VNode.new(ast_node.tag, new_props, new_children)\n end\n end\nend\n",
11
+ "# frozen_string_literal: true\n# rbs_inline: enabled\n\nrequire \"js\"\nrequire \"json\"\n\nmodule RbWasmVdom\n # @rbs selector: String\n # @rbs template: String\n # @rbs state: Hash[Symbol, untyped]\n # @rbs methods: Hash[Symbol, Proc]\n # @rbs return: App\n def self.create_app(selector, template:, state:, methods:)\n App.new(selector, template: template, state: state, methods: methods)\n end\nend\n"
12
+ ].join("\n\n"), t = !1;
13
+ function n(n, { el: r, template: i, state: a, methods: o }) {
14
+ t ||= (n.eval(e), !0), window.__rb_vdom_state = a, window.__rb_vdom_methods = o, n.eval(`
15
+ state_hash = JS.global[:__rb_vdom_state]
16
+ methods_hash = JS.global[:__rb_vdom_methods]
17
+
18
+ RbWasmVdom.create_app("${r}", template: """${i}""", state: state_hash, methods: methods_hash)
19
+ `);
20
+ }
21
+ //#endregion
22
+ export { n as createApp };
@@ -0,0 +1,442 @@
1
+ var RbWasmVdom=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=[`# frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module RbWasmVdom
5
+ # Virtual DOM Node Definition
6
+ class VNode
7
+ attr_reader :tag #: String
8
+ attr_reader :props #: Hash[String, String]
9
+ attr_reader :children #: Array[VNode | String]
10
+
11
+ # @rbs tag: String
12
+ # @rbs props: Hash[String, String]
13
+ # @rbs children: Array[VNode | String]
14
+ # @rbs return: void
15
+ def initialize(tag, props = {}, children = [])
16
+ @tag = tag
17
+ @props = props
18
+ @children = children
19
+ end
20
+ end
21
+ end
22
+ `,`# frozen_string_literal: true
23
+ # rbs_inline: enabled
24
+
25
+ module RbWasmVdom
26
+ # Reactive State Management
27
+ class ReactiveState
28
+ # @rbs initial_data: Hash[Symbol, untyped]
29
+ # @rbs &on_change: () -> void
30
+ # @rbs return: void
31
+ def initialize(initial_data, &on_change)
32
+ @data = initial_data
33
+ @on_change = on_change
34
+ end
35
+
36
+ # @rbs key: Symbol
37
+ # @rbs return: untyped
38
+ def [](key)
39
+ @data[key]
40
+ end
41
+
42
+ # @rbs key: Symbol
43
+ # @rbs value: untyped
44
+ # @rbs return: void
45
+ def []=(key, value)
46
+ return if @data[key] == value
47
+
48
+ @data[key] = value
49
+ @on_change.call
50
+ end
51
+ end
52
+ end
53
+ `,`# frozen_string_literal: true
54
+ # rbs_inline: enabled
55
+
56
+ module RbWasmVdom
57
+ # HTML Template Parser
58
+ class TemplateParser
59
+ # @rbs return: void
60
+ def self.setup_js_parser # rubocop:disable Metrics/MethodLength
61
+ # Add a prefix to the function name to prevent global namespace pollution in JS
62
+ JS.eval(<<~JS)
63
+ window.__RbWasmVdom_parseHTMLToJSON = function(html) {
64
+ const doc = new DOMParser().parseFromString(html, "text/html");
65
+ function walk(node) {
66
+ if (node.nodeType === 3) {
67
+ const text = node.textContent.trim();
68
+ return text ? text : null;
69
+ }
70
+ if (node.nodeType === 1) {
71
+ const obj = { tag: node.tagName.toLowerCase(), props: {}, children: [] };
72
+ for (let i = 0; i < node.attributes.length; i++) {
73
+ obj.props[node.attributes[i].name] = node.attributes[i].value;
74
+ }
75
+ for (let i = 0; i < node.childNodes.length; i++) {
76
+ const childRes = walk(node.childNodes[i]);
77
+ if (childRes !== null) obj.children.push(childRes);
78
+ }
79
+ return obj;
80
+ }
81
+ return null;
82
+ }
83
+ const root = doc.body.firstElementChild;
84
+ return root ? JSON.stringify(walk(root)) : "null";
85
+ }
86
+ JS
87
+ @setup_done = true
88
+ end
89
+
90
+ # @rbs html_string: String
91
+ # @rbs return: VNode | String | nil
92
+ def self.parse(html_string)
93
+ setup_js_parser unless @setup_done
94
+ json_str = JS.global.__RbWasmVdom_parseHTMLToJSON(html_string).to_s
95
+ return nil if json_str == "null" || json_str.empty?
96
+
97
+ build_ast(JSON.parse(json_str))
98
+ end
99
+
100
+ # @rbs data: Hash[String, untyped] | String
101
+ # @rbs return: VNode | String
102
+ def self.build_ast(data)
103
+ return data if data.is_a?(String)
104
+
105
+ children = data["children"].map { |child| build_ast(child) }
106
+ VNode.new(data["tag"], data["props"], children)
107
+ end
108
+ end
109
+ end
110
+ `,`# frozen_string_literal: true
111
+ # rbs_inline: enabled
112
+
113
+ module RbWasmVdom
114
+ module DomRenderer
115
+ private
116
+
117
+ # @rbs vnode: VNode | String
118
+ # @rbs return: untyped
119
+ def create_element(vnode)
120
+ document = JS.global[:document]
121
+ return document.createTextNode(vnode) if vnode.is_a?(String)
122
+
123
+ el = document.createElement(vnode.tag)
124
+ update_props(el, {}, vnode.props)
125
+
126
+ vnode.children.each do |child|
127
+ el.appendChild(create_element(child))
128
+ end
129
+ el
130
+ end
131
+
132
+ # @rbs el: untyped
133
+ # @rbs old_props: Hash[String, String]
134
+ # @rbs new_props: Hash[String, String]
135
+ # @rbs return: void
136
+ def update_props(el, old_props, new_props)
137
+ remove_old_props(el, old_props, new_props)
138
+ apply_new_props(el, old_props, new_props)
139
+ end
140
+
141
+ # @rbs el: untyped
142
+ # @rbs old_props: Hash[String, String]
143
+ # @rbs new_props: Hash[String, String]
144
+ # @rbs return: void
145
+ def remove_old_props(el, old_props, new_props)
146
+ old_props.each_key do |key|
147
+ el.removeAttribute(key) unless new_props.key?(key) || key.start_with?("@")
148
+ end
149
+ end
150
+
151
+ # @rbs el: untyped
152
+ # @rbs old_props: Hash[String, String]
153
+ # @rbs new_props: Hash[String, String]
154
+ # @rbs return: void
155
+ def apply_new_props(el, old_props, new_props)
156
+ new_props.each do |key, value|
157
+ next if old_props[key] == value
158
+
159
+ apply_prop(el, old_props, key, value)
160
+ end
161
+ end
162
+
163
+ # @rbs el: untyped
164
+ # @rbs old_props: Hash[String, String]
165
+ # @rbs key: String
166
+ # @rbs value: String
167
+ # @rbs return: void
168
+ def apply_prop(el, old_props, key, value)
169
+ if key.start_with?("@")
170
+ add_event_listener(el, old_props, key, value)
171
+ elsif key == "value"
172
+ update_value_prop(el, key, value)
173
+ else
174
+ el.setAttribute(key, value)
175
+ end
176
+ end
177
+
178
+ # @rbs el: untyped
179
+ # @rbs old_props: Hash[String, String]
180
+ # @rbs key: String
181
+ # @rbs value: String
182
+ # @rbs return: void
183
+ def add_event_listener(el, old_props, key, value)
184
+ return if old_props.key?(key)
185
+
186
+ event_name = key.sub(/^@/, "")
187
+ el.addEventListener(event_name) do |e|
188
+ @methods[value.to_sym].call(e, @state)
189
+ end
190
+ end
191
+
192
+ # @rbs el: untyped
193
+ # @rbs key: String
194
+ # @rbs value: String
195
+ # @rbs return: void
196
+ def update_value_prop(el, key, value)
197
+ el[:value] = value
198
+ el.setAttribute(key, value)
199
+ end
200
+ end
201
+ end
202
+ `,`# frozen_string_literal: true
203
+ # rbs_inline: enabled
204
+
205
+ module RbWasmVdom
206
+ module Patcher
207
+ include DomRenderer
208
+
209
+ # @rbs parent_el: untyped
210
+ # @rbs old_vnode: VNode | String | nil
211
+ # @rbs new_vnode: VNode | String | nil
212
+ # @rbs index: Integer
213
+ # @rbs return: void
214
+ def patch(parent_el, old_vnode, new_vnode, index)
215
+ current_el = child_at(parent_el, index)
216
+
217
+ return append_node(parent_el, new_vnode) if old_vnode.nil?
218
+ return remove_node(parent_el, current_el) if new_vnode.nil?
219
+ return replace_node(parent_el, current_el, new_vnode) if changed?(old_vnode, new_vnode)
220
+
221
+ patch_element(current_el, old_vnode, new_vnode) if old_vnode.is_a?(VNode) && new_vnode.is_a?(VNode)
222
+ end
223
+
224
+ private
225
+
226
+ # @rbs node1: VNode | String
227
+ # @rbs node2: VNode | String
228
+ # @rbs return: bool
229
+ def changed?(node1, node2)
230
+ return true if node1.class != node2.class
231
+ return node1 != node2 if node1.is_a?(String)
232
+
233
+ # @type var node1: VNode
234
+ # @type var node2: VNode
235
+
236
+ node1.tag != node2.tag
237
+ end
238
+
239
+ # @rbs parent_el: untyped
240
+ # @rbs index: Integer
241
+ # @rbs return: untyped
242
+ def child_at(parent_el, index)
243
+ parent_el[:childNodes].item(index)
244
+ end
245
+
246
+ # @rbs parent_el: untyped
247
+ # @rbs new_vnode: VNode | String | nil
248
+ # @rbs return: void
249
+ def append_node(parent_el, new_vnode)
250
+ parent_el.appendChild(create_element(new_vnode)) if new_vnode
251
+ end
252
+
253
+ # @rbs parent_el: untyped
254
+ # @rbs current_el: untyped
255
+ # @rbs return: void
256
+ def remove_node(parent_el, current_el)
257
+ parent_el.removeChild(current_el) unless current_el == JS::Null
258
+ end
259
+
260
+ # @rbs parent_el: untyped
261
+ # @rbs current_el: untyped
262
+ # @rbs new_vnode: VNode | String
263
+ # @rbs return: void
264
+ def replace_node(parent_el, current_el, new_vnode)
265
+ parent_el.replaceChild(create_element(new_vnode), current_el)
266
+ end
267
+
268
+ # @rbs current_el: untyped
269
+ # @rbs old_vnode: VNode
270
+ # @rbs new_vnode: VNode
271
+ # @rbs return: void
272
+ def patch_element(current_el, old_vnode, new_vnode)
273
+ update_props(current_el, old_vnode.props, new_vnode.props)
274
+
275
+ old_children = old_vnode.children
276
+ new_children = new_vnode.children
277
+
278
+ patch_children(current_el, old_children, new_children)
279
+ end
280
+
281
+ # @rbs current_el: untyped
282
+ # @rbs old_children: Array[VNode | String]
283
+ # @rbs new_children: Array[VNode | String]
284
+ # @rbs return: void
285
+ def patch_children(current_el, old_children, new_children)
286
+ remove_extra_children(current_el, old_children, new_children)
287
+ patch_common_children(current_el, old_children, new_children)
288
+
289
+ return unless new_children.length > old_children.length
290
+
291
+ append_missing_children(current_el, old_children, new_children)
292
+ end
293
+
294
+ # @rbs current_el: untyped
295
+ # @rbs old_children: Array[VNode | String]
296
+ # @rbs new_children: Array[VNode | String]
297
+ # @rbs return: void
298
+ def remove_extra_children(current_el, old_children, new_children)
299
+ return unless old_children.length > new_children.length
300
+
301
+ (old_children.length - 1).downto(new_children.length) do |i|
302
+ child_to_remove = current_el[:childNodes].item(i)
303
+ current_el.removeChild(child_to_remove) unless child_to_remove == JS::Null
304
+ end
305
+ end
306
+
307
+ # @rbs current_el: untyped
308
+ # @rbs old_children: Array[VNode | String]
309
+ # @rbs new_children: Array[VNode | String]
310
+ # @rbs return: void
311
+ def patch_common_children(current_el, old_children, new_children)
312
+ [old_children.length, new_children.length].min.times do |i|
313
+ patch(current_el, old_children[i], new_children[i], i)
314
+ end
315
+ end
316
+
317
+ # @rbs current_el: untyped
318
+ # @rbs old_children: Array[VNode | String]
319
+ # @rbs new_children: Array[VNode | String]
320
+ # @rbs return: void
321
+ def append_missing_children(current_el, old_children, new_children)
322
+ (old_children.length...new_children.length).each do |i|
323
+ current_el.appendChild(create_element(new_children[i]))
324
+ end
325
+ end
326
+ end
327
+ end
328
+ `,`# frozen_string_literal: true
329
+ # rbs_inline: enabled
330
+
331
+ module RbWasmVdom
332
+ class Interpolator
333
+ PLACEHOLDER_PATTERN = /\\{\\{\\s*\\w+\\s*\\}\\}/
334
+ KEY_PATTERN = /\\w+/
335
+
336
+ # @rbs state: ReactiveState
337
+ def initialize(state)
338
+ @state = state
339
+ end
340
+
341
+ # @rbs text: String
342
+ def call(text)
343
+ text.to_s.gsub(PLACEHOLDER_PATTERN) do |placeholder|
344
+ interpolate_placeholder(placeholder)
345
+ end
346
+ end
347
+
348
+ private
349
+
350
+ # @rbs placeholder: String
351
+ # @rbs return: String
352
+ def interpolate_placeholder(placeholder)
353
+ key = placeholder.match(KEY_PATTERN)&.[](0)
354
+ return placeholder unless key
355
+
356
+ @state[key.to_sym].to_s
357
+ end
358
+ end
359
+ end
360
+ `,`# frozen_string_literal: true
361
+ # rbs_inline: enabled
362
+
363
+ module RbWasmVdom
364
+ # Framework Core
365
+ class App
366
+ include Patcher
367
+
368
+ # @rbs selector: String
369
+ # @rbs template: String
370
+ # @rbs state: Hash[Symbol, untyped]
371
+ # @rbs methods: Hash[Symbol, Proc]
372
+ # @rbs return: void
373
+ def initialize(selector, template:, state:, methods:)
374
+ @el = JS.global[:document].querySelector(selector)
375
+ @template_ast = TemplateParser.parse(template)
376
+ @methods = methods
377
+ @current_vnode = nil
378
+
379
+ @state = ReactiveState.new(state) do
380
+ render_cycle
381
+ end
382
+
383
+ @interpolator = Interpolator.new(@state)
384
+
385
+ render_cycle
386
+ end
387
+
388
+ private
389
+
390
+ # @rbs return: void
391
+ def render_cycle
392
+ new_vnode = build_vdom(@template_ast)
393
+
394
+ if @current_vnode.nil?
395
+ @el[:innerHTML] = ""
396
+ @el.appendChild(create_element(new_vnode))
397
+ else
398
+ patch(@el, @current_vnode, new_vnode, 0)
399
+ end
400
+
401
+ @current_vnode = new_vnode
402
+ end
403
+
404
+ # @rbs ast_node: VNode | String
405
+ # @rbs return: VNode | String
406
+ def build_vdom(ast_node)
407
+ return @interpolator.call(ast_node) if ast_node.is_a?(String)
408
+
409
+ new_props = {} #: Hash[String, String]
410
+ ast_node.props.each do |k, v|
411
+ new_props[k] = @interpolator.call(v)
412
+ end
413
+
414
+ new_children = ast_node.children.map { |child| build_vdom(child) }
415
+ VNode.new(ast_node.tag, new_props, new_children)
416
+ end
417
+ end
418
+ end
419
+ `,`# frozen_string_literal: true
420
+ # rbs_inline: enabled
421
+
422
+ require "js"
423
+ require "json"
424
+
425
+ module RbWasmVdom
426
+ # @rbs selector: String
427
+ # @rbs template: String
428
+ # @rbs state: Hash[Symbol, untyped]
429
+ # @rbs methods: Hash[Symbol, Proc]
430
+ # @rbs return: App
431
+ def self.create_app(selector, template:, state:, methods:)
432
+ App.new(selector, template: template, state: state, methods: methods)
433
+ end
434
+ end
435
+ `].join(`
436
+
437
+ `),n=!1;function r(e,{el:r,template:i,state:a,methods:o}){n||=(e.eval(t),!0),window.__rb_vdom_state=a,window.__rb_vdom_methods=o,e.eval(`
438
+ state_hash = JS.global[:__rb_vdom_state]
439
+ methods_hash = JS.global[:__rb_vdom_methods]
440
+
441
+ RbWasmVdom.create_app("${r}", template: """${i}""", state: state_hash, methods: methods_hash)
442
+ `)}return e.createApp=r,e})({});
@@ -0,0 +1,448 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module RbWasmVdom
5
+ # Virtual DOM Node Definition
6
+ class VNode
7
+ attr_reader :tag #: String
8
+ attr_reader :props #: Hash[String, String]
9
+ attr_reader :children #: Array[VNode | String]
10
+
11
+ # @rbs tag: String
12
+ # @rbs props: Hash[String, String]
13
+ # @rbs children: Array[VNode | String]
14
+ # @rbs return: void
15
+ def initialize(tag, props = {}, children = [])
16
+ @tag = tag
17
+ @props = props
18
+ @children = children
19
+ end
20
+ end
21
+ end
22
+
23
+
24
+ # frozen_string_literal: true
25
+ # rbs_inline: enabled
26
+
27
+ module RbWasmVdom
28
+ # Reactive State Management
29
+ class ReactiveState
30
+ # @rbs initial_data: Hash[Symbol, untyped]
31
+ # @rbs &on_change: () -> void
32
+ # @rbs return: void
33
+ def initialize(initial_data, &on_change)
34
+ @data = initial_data
35
+ @on_change = on_change
36
+ end
37
+
38
+ # @rbs key: Symbol
39
+ # @rbs return: untyped
40
+ def [](key)
41
+ @data[key]
42
+ end
43
+
44
+ # @rbs key: Symbol
45
+ # @rbs value: untyped
46
+ # @rbs return: void
47
+ def []=(key, value)
48
+ return if @data[key] == value
49
+
50
+ @data[key] = value
51
+ @on_change.call
52
+ end
53
+ end
54
+ end
55
+
56
+
57
+ # frozen_string_literal: true
58
+ # rbs_inline: enabled
59
+
60
+ module RbWasmVdom
61
+ # HTML Template Parser
62
+ class TemplateParser
63
+ # @rbs return: void
64
+ def self.setup_js_parser # rubocop:disable Metrics/MethodLength
65
+ # Add a prefix to the function name to prevent global namespace pollution in JS
66
+ JS.eval(<<~JS)
67
+ window.__RbWasmVdom_parseHTMLToJSON = function(html) {
68
+ const doc = new DOMParser().parseFromString(html, "text/html");
69
+ function walk(node) {
70
+ if (node.nodeType === 3) {
71
+ const text = node.textContent.trim();
72
+ return text ? text : null;
73
+ }
74
+ if (node.nodeType === 1) {
75
+ const obj = { tag: node.tagName.toLowerCase(), props: {}, children: [] };
76
+ for (let i = 0; i < node.attributes.length; i++) {
77
+ obj.props[node.attributes[i].name] = node.attributes[i].value;
78
+ }
79
+ for (let i = 0; i < node.childNodes.length; i++) {
80
+ const childRes = walk(node.childNodes[i]);
81
+ if (childRes !== null) obj.children.push(childRes);
82
+ }
83
+ return obj;
84
+ }
85
+ return null;
86
+ }
87
+ const root = doc.body.firstElementChild;
88
+ return root ? JSON.stringify(walk(root)) : "null";
89
+ }
90
+ JS
91
+ @setup_done = true
92
+ end
93
+
94
+ # @rbs html_string: String
95
+ # @rbs return: VNode | String | nil
96
+ def self.parse(html_string)
97
+ setup_js_parser unless @setup_done
98
+ json_str = JS.global.__RbWasmVdom_parseHTMLToJSON(html_string).to_s
99
+ return nil if json_str == "null" || json_str.empty?
100
+
101
+ build_ast(JSON.parse(json_str))
102
+ end
103
+
104
+ # @rbs data: Hash[String, untyped] | String
105
+ # @rbs return: VNode | String
106
+ def self.build_ast(data)
107
+ return data if data.is_a?(String)
108
+
109
+ children = data["children"].map { |child| build_ast(child) }
110
+ VNode.new(data["tag"], data["props"], children)
111
+ end
112
+ end
113
+ end
114
+
115
+
116
+ # frozen_string_literal: true
117
+ # rbs_inline: enabled
118
+
119
+ module RbWasmVdom
120
+ module DomRenderer
121
+ private
122
+
123
+ # @rbs vnode: VNode | String
124
+ # @rbs return: untyped
125
+ def create_element(vnode)
126
+ document = JS.global[:document]
127
+ return document.createTextNode(vnode) if vnode.is_a?(String)
128
+
129
+ el = document.createElement(vnode.tag)
130
+ update_props(el, {}, vnode.props)
131
+
132
+ vnode.children.each do |child|
133
+ el.appendChild(create_element(child))
134
+ end
135
+ el
136
+ end
137
+
138
+ # @rbs el: untyped
139
+ # @rbs old_props: Hash[String, String]
140
+ # @rbs new_props: Hash[String, String]
141
+ # @rbs return: void
142
+ def update_props(el, old_props, new_props)
143
+ remove_old_props(el, old_props, new_props)
144
+ apply_new_props(el, old_props, new_props)
145
+ end
146
+
147
+ # @rbs el: untyped
148
+ # @rbs old_props: Hash[String, String]
149
+ # @rbs new_props: Hash[String, String]
150
+ # @rbs return: void
151
+ def remove_old_props(el, old_props, new_props)
152
+ old_props.each_key do |key|
153
+ el.removeAttribute(key) unless new_props.key?(key) || key.start_with?("@")
154
+ end
155
+ end
156
+
157
+ # @rbs el: untyped
158
+ # @rbs old_props: Hash[String, String]
159
+ # @rbs new_props: Hash[String, String]
160
+ # @rbs return: void
161
+ def apply_new_props(el, old_props, new_props)
162
+ new_props.each do |key, value|
163
+ next if old_props[key] == value
164
+
165
+ apply_prop(el, old_props, key, value)
166
+ end
167
+ end
168
+
169
+ # @rbs el: untyped
170
+ # @rbs old_props: Hash[String, String]
171
+ # @rbs key: String
172
+ # @rbs value: String
173
+ # @rbs return: void
174
+ def apply_prop(el, old_props, key, value)
175
+ if key.start_with?("@")
176
+ add_event_listener(el, old_props, key, value)
177
+ elsif key == "value"
178
+ update_value_prop(el, key, value)
179
+ else
180
+ el.setAttribute(key, value)
181
+ end
182
+ end
183
+
184
+ # @rbs el: untyped
185
+ # @rbs old_props: Hash[String, String]
186
+ # @rbs key: String
187
+ # @rbs value: String
188
+ # @rbs return: void
189
+ def add_event_listener(el, old_props, key, value)
190
+ return if old_props.key?(key)
191
+
192
+ event_name = key.sub(/^@/, "")
193
+ el.addEventListener(event_name) do |e|
194
+ @methods[value.to_sym].call(e, @state)
195
+ end
196
+ end
197
+
198
+ # @rbs el: untyped
199
+ # @rbs key: String
200
+ # @rbs value: String
201
+ # @rbs return: void
202
+ def update_value_prop(el, key, value)
203
+ el[:value] = value
204
+ el.setAttribute(key, value)
205
+ end
206
+ end
207
+ end
208
+
209
+
210
+ # frozen_string_literal: true
211
+ # rbs_inline: enabled
212
+
213
+ module RbWasmVdom
214
+ module Patcher
215
+ include DomRenderer
216
+
217
+ # @rbs parent_el: untyped
218
+ # @rbs old_vnode: VNode | String | nil
219
+ # @rbs new_vnode: VNode | String | nil
220
+ # @rbs index: Integer
221
+ # @rbs return: void
222
+ def patch(parent_el, old_vnode, new_vnode, index)
223
+ current_el = child_at(parent_el, index)
224
+
225
+ return append_node(parent_el, new_vnode) if old_vnode.nil?
226
+ return remove_node(parent_el, current_el) if new_vnode.nil?
227
+ return replace_node(parent_el, current_el, new_vnode) if changed?(old_vnode, new_vnode)
228
+
229
+ patch_element(current_el, old_vnode, new_vnode) if old_vnode.is_a?(VNode) && new_vnode.is_a?(VNode)
230
+ end
231
+
232
+ private
233
+
234
+ # @rbs node1: VNode | String
235
+ # @rbs node2: VNode | String
236
+ # @rbs return: bool
237
+ def changed?(node1, node2)
238
+ return true if node1.class != node2.class
239
+ return node1 != node2 if node1.is_a?(String)
240
+
241
+ # @type var node1: VNode
242
+ # @type var node2: VNode
243
+
244
+ node1.tag != node2.tag
245
+ end
246
+
247
+ # @rbs parent_el: untyped
248
+ # @rbs index: Integer
249
+ # @rbs return: untyped
250
+ def child_at(parent_el, index)
251
+ parent_el[:childNodes].item(index)
252
+ end
253
+
254
+ # @rbs parent_el: untyped
255
+ # @rbs new_vnode: VNode | String | nil
256
+ # @rbs return: void
257
+ def append_node(parent_el, new_vnode)
258
+ parent_el.appendChild(create_element(new_vnode)) if new_vnode
259
+ end
260
+
261
+ # @rbs parent_el: untyped
262
+ # @rbs current_el: untyped
263
+ # @rbs return: void
264
+ def remove_node(parent_el, current_el)
265
+ parent_el.removeChild(current_el) unless current_el == JS::Null
266
+ end
267
+
268
+ # @rbs parent_el: untyped
269
+ # @rbs current_el: untyped
270
+ # @rbs new_vnode: VNode | String
271
+ # @rbs return: void
272
+ def replace_node(parent_el, current_el, new_vnode)
273
+ parent_el.replaceChild(create_element(new_vnode), current_el)
274
+ end
275
+
276
+ # @rbs current_el: untyped
277
+ # @rbs old_vnode: VNode
278
+ # @rbs new_vnode: VNode
279
+ # @rbs return: void
280
+ def patch_element(current_el, old_vnode, new_vnode)
281
+ update_props(current_el, old_vnode.props, new_vnode.props)
282
+
283
+ old_children = old_vnode.children
284
+ new_children = new_vnode.children
285
+
286
+ patch_children(current_el, old_children, new_children)
287
+ end
288
+
289
+ # @rbs current_el: untyped
290
+ # @rbs old_children: Array[VNode | String]
291
+ # @rbs new_children: Array[VNode | String]
292
+ # @rbs return: void
293
+ def patch_children(current_el, old_children, new_children)
294
+ remove_extra_children(current_el, old_children, new_children)
295
+ patch_common_children(current_el, old_children, new_children)
296
+
297
+ return unless new_children.length > old_children.length
298
+
299
+ append_missing_children(current_el, old_children, new_children)
300
+ end
301
+
302
+ # @rbs current_el: untyped
303
+ # @rbs old_children: Array[VNode | String]
304
+ # @rbs new_children: Array[VNode | String]
305
+ # @rbs return: void
306
+ def remove_extra_children(current_el, old_children, new_children)
307
+ return unless old_children.length > new_children.length
308
+
309
+ (old_children.length - 1).downto(new_children.length) do |i|
310
+ child_to_remove = current_el[:childNodes].item(i)
311
+ current_el.removeChild(child_to_remove) unless child_to_remove == JS::Null
312
+ end
313
+ end
314
+
315
+ # @rbs current_el: untyped
316
+ # @rbs old_children: Array[VNode | String]
317
+ # @rbs new_children: Array[VNode | String]
318
+ # @rbs return: void
319
+ def patch_common_children(current_el, old_children, new_children)
320
+ [old_children.length, new_children.length].min.times do |i|
321
+ patch(current_el, old_children[i], new_children[i], i)
322
+ end
323
+ end
324
+
325
+ # @rbs current_el: untyped
326
+ # @rbs old_children: Array[VNode | String]
327
+ # @rbs new_children: Array[VNode | String]
328
+ # @rbs return: void
329
+ def append_missing_children(current_el, old_children, new_children)
330
+ (old_children.length...new_children.length).each do |i|
331
+ current_el.appendChild(create_element(new_children[i]))
332
+ end
333
+ end
334
+ end
335
+ end
336
+
337
+
338
+ # frozen_string_literal: true
339
+ # rbs_inline: enabled
340
+
341
+ module RbWasmVdom
342
+ class Interpolator
343
+ PLACEHOLDER_PATTERN = /\{\{\s*\w+\s*\}\}/
344
+ KEY_PATTERN = /\w+/
345
+
346
+ # @rbs state: ReactiveState
347
+ def initialize(state)
348
+ @state = state
349
+ end
350
+
351
+ # @rbs text: String
352
+ def call(text)
353
+ text.to_s.gsub(PLACEHOLDER_PATTERN) do |placeholder|
354
+ interpolate_placeholder(placeholder)
355
+ end
356
+ end
357
+
358
+ private
359
+
360
+ # @rbs placeholder: String
361
+ # @rbs return: String
362
+ def interpolate_placeholder(placeholder)
363
+ key = placeholder.match(KEY_PATTERN)&.[](0)
364
+ return placeholder unless key
365
+
366
+ @state[key.to_sym].to_s
367
+ end
368
+ end
369
+ end
370
+
371
+
372
+ # frozen_string_literal: true
373
+ # rbs_inline: enabled
374
+
375
+ module RbWasmVdom
376
+ # Framework Core
377
+ class App
378
+ include Patcher
379
+
380
+ # @rbs selector: String
381
+ # @rbs template: String
382
+ # @rbs state: Hash[Symbol, untyped]
383
+ # @rbs methods: Hash[Symbol, Proc]
384
+ # @rbs return: void
385
+ def initialize(selector, template:, state:, methods:)
386
+ @el = JS.global[:document].querySelector(selector)
387
+ @template_ast = TemplateParser.parse(template)
388
+ @methods = methods
389
+ @current_vnode = nil
390
+
391
+ @state = ReactiveState.new(state) do
392
+ render_cycle
393
+ end
394
+
395
+ @interpolator = Interpolator.new(@state)
396
+
397
+ render_cycle
398
+ end
399
+
400
+ private
401
+
402
+ # @rbs return: void
403
+ def render_cycle
404
+ new_vnode = build_vdom(@template_ast)
405
+
406
+ if @current_vnode.nil?
407
+ @el[:innerHTML] = ""
408
+ @el.appendChild(create_element(new_vnode))
409
+ else
410
+ patch(@el, @current_vnode, new_vnode, 0)
411
+ end
412
+
413
+ @current_vnode = new_vnode
414
+ end
415
+
416
+ # @rbs ast_node: VNode | String
417
+ # @rbs return: VNode | String
418
+ def build_vdom(ast_node)
419
+ return @interpolator.call(ast_node) if ast_node.is_a?(String)
420
+
421
+ new_props = {} #: Hash[String, String]
422
+ ast_node.props.each do |k, v|
423
+ new_props[k] = @interpolator.call(v)
424
+ end
425
+
426
+ new_children = ast_node.children.map { |child| build_vdom(child) }
427
+ VNode.new(ast_node.tag, new_props, new_children)
428
+ end
429
+ end
430
+ end
431
+
432
+
433
+ # frozen_string_literal: true
434
+ # rbs_inline: enabled
435
+
436
+ require "js"
437
+ require "json"
438
+
439
+ module RbWasmVdom
440
+ # @rbs selector: String
441
+ # @rbs template: String
442
+ # @rbs state: Hash[Symbol, untyped]
443
+ # @rbs methods: Hash[Symbol, Proc]
444
+ # @rbs return: App
445
+ def self.create_app(selector, template:, state:, methods:)
446
+ App.new(selector, template: template, state: state, methods: methods)
447
+ end
448
+ end
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@sue445/rb-wasm-vdom",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "A reactive Virtual DOM library for ruby.wasm and Picoruby.wasm",
5
+ "keywords": [
6
+ "ruby.wasm",
7
+ "picoruby.wasm"
8
+ ],
9
+ "homepage": "https://github.com/sue445/rb-wasm-vdom#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/sue445/rb-wasm-vdom/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/sue445/rb-wasm-vdom.git"
16
+ },
17
+ "license": "MIT",
18
+ "author": "sue445",
19
+ "type": "module",
20
+ "main": "./dist/rb-wasm-vdom.es.js",
21
+ "module": "./dist/rb-wasm-vdom.es.js",
22
+ "exports": {
23
+ ".": "./dist/rb-wasm-vdom.es.js",
24
+ "./rb": "./dist/rb-wasm-vdom.rb"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "build": "vite build && node scripts/build-ruby.js",
33
+ "test": "npm run test:unit && npm run test:integration",
34
+ "test:integration": "npm run build && playwright test test/integration/",
35
+ "test:unit": "npm run test:unit:ruby-wasm-head",
36
+ "test:unit:ruby-wasm-head": "node scripts/run-unit-tests.js ruby-wasm-head"
37
+ },
38
+ "devDependencies": {
39
+ "@playwright/test": "^1.60.0",
40
+ "@ruby/head-wasm-wasi": "^2.9.3-2.9.4",
41
+ "vite": "^8.0.16"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }