@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 +21 -0
- package/README.md +20 -0
- package/dist/rb-wasm-vdom.es.js +22 -0
- package/dist/rb-wasm-vdom.iife.js +442 -0
- package/dist/rb-wasm-vdom.rb +448 -0
- package/package.json +46 -0
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
|
+
[](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
|
+
}
|