├── .gitignore ├── Gemfile ├── Gemfile.lock ├── README.md └── docs ├── demo └── index.html ├── index.js ├── ruby_wasm_vdom.rb └── ruby_wasm_vdom ├── app.rb ├── dom_manager.rb ├── dom_parser.rb ├── init.rb └── router.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-version 2 | .bundle 3 | vendor 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # gem "rails" 8 | gem 'rubocop', require: false 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | ast (2.4.2) 5 | json (2.7.1) 6 | language_server-protocol (3.17.0.3) 7 | parallel (1.24.0) 8 | parser (3.2.2.4) 9 | ast (~> 2.4.1) 10 | racc 11 | racc (1.7.3) 12 | rainbow (3.1.1) 13 | regexp_parser (2.8.3) 14 | rexml (3.2.6) 15 | rubocop (1.59.0) 16 | json (~> 2.3) 17 | language_server-protocol (>= 3.17.0) 18 | parallel (~> 1.10) 19 | parser (>= 3.2.2.4) 20 | rainbow (>= 2.2.2, < 4.0) 21 | regexp_parser (>= 1.8, < 3.0) 22 | rexml (>= 3.2.5, < 4.0) 23 | rubocop-ast (>= 1.30.0, < 2.0) 24 | ruby-progressbar (~> 1.7) 25 | unicode-display_width (>= 2.4.0, < 3.0) 26 | rubocop-ast (1.30.0) 27 | parser (>= 3.2.1.0) 28 | ruby-progressbar (1.13.0) 29 | unicode-display_width (2.5.0) 30 | 31 | PLATFORMS 32 | arm64-darwin-23 33 | x86_64-darwin-19 34 | 35 | DEPENDENCIES 36 | rubocop 37 | 38 | BUNDLED WITH 39 | 2.2.9 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-wasm-vdom 2 | This is a library for using virtual dom in ruby with web assembly. 3 | 4 | ## How to use 5 | 6 | You can use this library by adding the following to your html file. 7 | 8 | ```html 9 | 10 |
11 | 12 | 13 | 14 | 15 | 40 | 41 | 42 | ``` 43 | 44 | You can also write vdom with like jsx signature with `DomParser` 45 | 46 | ```html 47 | 48 | 49 | 50 | 51 | 52 | 53 | 80 | 81 | 82 | ``` 83 | 84 | ## Examples 85 | 86 | - getty104/ruby-brainfuck-interpreter 87 | - Source Code: https://github.com/getty104/ruby-brainfuck-interpreter/ 88 | - Demo: https://getty104.github.io/ruby-brainfuck-interpreter/ 89 | -------------------------------------------------------------------------------- /docs/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/index.js: -------------------------------------------------------------------------------- 1 | //Load ruby.wasm 2 | 3 | let scriptElement = document.createElement('script'); 4 | scriptElement.src = "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js"; 5 | document.head.appendChild(scriptElement); 6 | 7 | function loadRubyScript(filePath) { 8 | const baseUrl = "https://getty104.github.io/ruby-wasm-vdom"; 9 | let rubyScriptElement = document.createElement('script'); 10 | rubyScriptElement.type = "text/ruby" 11 | rubyScriptElement.chrset = "utf-8" 12 | rubyScriptElement.src = `${baseUrl}/${filePath}`; 13 | document.head.appendChild(rubyScriptElement); 14 | } 15 | 16 | //Load ruby_wasm_vdom.rb 17 | loadRubyScript("ruby_wasm_vdom.rb"); 18 | loadRubyScript("ruby_wasm_vdom/init.rb"); 19 | loadRubyScript("ruby_wasm_vdom/dom_parser.rb"); 20 | loadRubyScript("ruby_wasm_vdom/dom_manager.rb"); 21 | loadRubyScript("ruby_wasm_vdom/app.rb"); 22 | loadRubyScript("ruby_wasm_vdom/router.rb"); 23 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyWasmVdom 4 | end 5 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyWasmVdom 4 | class App 5 | def initialize(state:, view:, actions:, el: '#app') 6 | @document = JS.global[:document] 7 | @element = el.is_a?(String) ? @document.querySelector(el) : el 8 | 9 | @view = view 10 | @state = state 11 | @actions = dispatch_actions(actions) 12 | resolve_node 13 | end 14 | 15 | private 16 | 17 | def dispatch_actions(actions) 18 | actions.transform_values do |action| 19 | lambda { |state, data| 20 | action.call(state, data) 21 | resolve_node 22 | } 23 | end 24 | end 25 | 26 | def resolve_node 27 | @new_node_obj = @view.call(@state, @actions) 28 | schedule_render 29 | end 30 | 31 | def schedule_render 32 | return if @skip_render 33 | 34 | @skip_render = true 35 | 36 | render = lambda { 37 | if @current_node_obj 38 | DomManager.update_element(@element, @current_node_obj, @new_node_obj) 39 | else 40 | @element.appendChild(DomManager.create_element(@new_node_obj)) 41 | end 42 | 43 | @current_node_obj = @new_node_obj 44 | @skip_render = false 45 | } 46 | 47 | JS.global.call(:setTimeout, JS.try_convert(render)) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom/dom_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyWasmVdom 4 | module DomManager 5 | module ChangeType 6 | None = 1 7 | Type = 2 8 | Text = 3 9 | Node = 4 10 | Value = 5 11 | Attr = 6 12 | end 13 | 14 | module_function 15 | 16 | def create_element(node_obj) 17 | document = JS.global[:document] 18 | 19 | return document.createTextNode(node_obj.to_s) unless v_node?(node_obj) 20 | 21 | element = document.createElement(node_obj[:node_name]) 22 | set_attributes(element, node_obj[:attributes]) 23 | node_obj[:children].each do |child| 24 | element.appendChild(create_element(child)) 25 | end 26 | element 27 | end 28 | 29 | def set_attributes(target_node, attributes) 30 | attributes.each do |key, value| 31 | if event_attr?(key) 32 | event_name = key[2..] 33 | target_node.addEventListener(event_name, value) 34 | else 35 | target_node.setAttribute(key.to_s, value) 36 | end 37 | end 38 | end 39 | 40 | # Return -1 if the current_node is deleted. 41 | # Return 0 otherwise. 42 | def update_element(parent_node, current_node_obj, new_node_obj, current_node_index = 0) 43 | unless current_node_obj 44 | parent_node.appendChild(create_element(new_node_obj)) 45 | return 0 46 | end 47 | 48 | current_node = parent_node[:childNodes][current_node_index] 49 | 50 | unless new_node_obj 51 | parent_node.removeChild(current_node) 52 | return -1 53 | end 54 | 55 | change_type = change_type(current_node_obj, new_node_obj) 56 | 57 | case change_type 58 | when ChangeType::Type, ChangeType::Text, ChangeType::Node 59 | parent_node.replaceChild(create_element(new_node_obj), current_node) 60 | when ChangeType::Value 61 | update_value( 62 | current_node, 63 | new_node_obj[:attributes][:value] 64 | ) 65 | when ChangeType::Attr 66 | update_attributes( 67 | current_node, 68 | current_node_obj[:attributes], 69 | new_node_obj[:attributes] 70 | ) 71 | end 72 | 73 | return 0 unless v_node?(current_node_obj) && v_node?(new_node_obj) 74 | 75 | shift = 0 76 | [current_node_obj[:children].size, new_node_obj[:children].size].max.times do |i| 77 | current_node_child_obj = i < current_node_obj[:children].size ? current_node_obj[:children][i] : nil 78 | new_node_child_obj = i < new_node_obj[:children].size ? new_node_obj[:children][i] : nil 79 | 80 | # When the current_node is deleted, the next index have to be shifted. 81 | shift += update_element( 82 | current_node, 83 | current_node_child_obj, 84 | new_node_child_obj, 85 | i + shift 86 | ) 87 | end 88 | 89 | 0 90 | end 91 | 92 | def update_attributes(target_node, current_attributes, new_attributes) 93 | current_attribute_keys = current_attributes.keys 94 | new_attribute_keys = new_attributes.keys 95 | 96 | (current_attribute_keys - new_attribute_keys).each do |key| 97 | target_node.removeAttribute(key.to_s) unless event_attr?(key.to_s) 98 | end 99 | 100 | new_attributes.each do |key, value| 101 | target_node.setAttribute(key.to_s, value) unless event_attr?(key.to_s) && current_attributes[key] != value 102 | end 103 | end 104 | 105 | def update_value(target, new_value) 106 | target[:value] = new_value 107 | end 108 | 109 | def change_type(a, b) 110 | return ChangeType::Type if a.class != b.class 111 | 112 | return ChangeType::Text if !v_node?(a) && a != b 113 | 114 | if v_node?(a) && v_node?(b) 115 | return ChangeType::Node if a[:node_name] != b[:node_name] 116 | 117 | return ChangeType::Value if a[:attributes][:value] != b[:attributes][:value] 118 | 119 | return ChangeType::Attr if a[:attributes].to_s != b[:attributes].to_s 120 | end 121 | 122 | ChangeType::None 123 | end 124 | 125 | def v_node?(node) 126 | node.is_a?(Hash) 127 | end 128 | 129 | def event_attr?(attribute) 130 | /^on/.match?(attribute) 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom/dom_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyWasmVdom 4 | module DomParser 5 | module_function 6 | 7 | def parse(doc) 8 | parser = JS.eval('return new DOMParser()') 9 | document = parser.call(:parseFromString, JS.try_convert(doc), 'text/html') 10 | elements = document.getElementsByTagName('body')[0][:childNodes] 11 | 12 | build_vdom(elements) 13 | end 14 | 15 | def build_vdom(elements) 16 | vdom = [] 17 | elements.forEach do |element| 18 | if element[:nodeType] == JS.global[:Node][:TEXT_NODE] 19 | value = element[:nodeValue].to_s.chomp.strip 20 | 21 | next if value.empty? 22 | 23 | vdom << if embed_script?(value) 24 | get_embed_script(value) 25 | else 26 | "'#{value}'" 27 | end 28 | 29 | next 30 | end 31 | 32 | attributes_str = [] 33 | attributes = element[:attributes] 34 | length = attributes[:length].to_i 35 | length.times do |i| 36 | attribute = attributes[i] 37 | key = attribute[:name].to_s 38 | value = attribute[:value].to_s 39 | 40 | result = if embed_script?(value) 41 | script = get_embed_script(value) 42 | ":#{key} => #{script}" 43 | else 44 | ":#{key} => '#{value}'" 45 | end 46 | attributes_str << result 47 | end 48 | 49 | vdom << "h(:#{element[:tagName].to_s.downcase}, {#{attributes_str.join(', ')}}, [#{build_vdom(element[:childNodes])}])" 50 | end 51 | vdom.join(',') 52 | end 53 | 54 | def embed_script?(doc) 55 | doc.match?(/\{.+\}/) 56 | end 57 | 58 | def get_embed_script(script) 59 | script.gsub(/\{(.+)\}/) { ::Regexp.last_match(1) } 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom/init.rb: -------------------------------------------------------------------------------- 1 | require 'js' 2 | 3 | def h(node_name, attributes, children) 4 | { node_name: node_name.to_s, attributes:, children: } 5 | end 6 | -------------------------------------------------------------------------------- /docs/ruby_wasm_vdom/router.rb: -------------------------------------------------------------------------------- 1 | module RubyWasmVdom 2 | class Router 3 | class << self 4 | private attr_reader :routes 5 | 6 | def define(routes) 7 | @routes = routes 8 | 9 | document = JS.global[:document] 10 | 11 | document.addEventListener('popstate') do |event| 12 | event.state => { state:, view:, actions: } 13 | App.new(state:, view:, actions:) 14 | end 15 | 16 | path = JS.global[:location][:pathname] 17 | render(path) 18 | end 19 | 20 | def navigate(path, current_page) 21 | history = JS.global[:history] 22 | history.pushState(current_page, '', path) 23 | render(path) 24 | end 25 | 26 | private 27 | 28 | def render(path) 29 | next_page = routes[path] 30 | App.new(next_page) 31 | end 32 | end 33 | end 34 | end 35 | --------------------------------------------------------------------------------