dommy 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +213 -0
- data/lib/dommy/attr.rb +200 -0
- data/lib/dommy/blob.rb +182 -0
- data/lib/dommy/bridge.rb +141 -0
- data/lib/dommy/css.rb +283 -0
- data/lib/dommy/custom_elements.rb +125 -0
- data/lib/dommy/data_transfer.rb +98 -0
- data/lib/dommy/document.rb +674 -0
- data/lib/dommy/dom_exception.rb +258 -0
- data/lib/dommy/dom_parser.rb +88 -0
- data/lib/dommy/element.rb +1975 -0
- data/lib/dommy/event.rb +589 -0
- data/lib/dommy/fetch.rb +241 -0
- data/lib/dommy/form_data.rb +208 -0
- data/lib/dommy/html_collection.rb +207 -0
- data/lib/dommy/html_elements.rb +4455 -0
- data/lib/dommy/internal/cookie_jar.rb +27 -0
- data/lib/dommy/internal/dom_matching.rb +141 -0
- data/lib/dommy/internal/mutation_coordinator.rb +172 -0
- data/lib/dommy/internal/node_traversal.rb +36 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
- data/lib/dommy/internal/observer_manager.rb +31 -0
- data/lib/dommy/internal/observer_matcher.rb +31 -0
- data/lib/dommy/internal/scope_resolution.rb +27 -0
- data/lib/dommy/internal/shadow_root_registry.rb +35 -0
- data/lib/dommy/internal/template_content_registry.rb +97 -0
- data/lib/dommy/minitest/assertions.rb +105 -0
- data/lib/dommy/minitest.rb +17 -0
- data/lib/dommy/navigator.rb +271 -0
- data/lib/dommy/node.rb +218 -0
- data/lib/dommy/observer.rb +199 -0
- data/lib/dommy/parser.rb +29 -0
- data/lib/dommy/promise.rb +199 -0
- data/lib/dommy/router.rb +275 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
- data/lib/dommy/rspec/matchers.rb +230 -0
- data/lib/dommy/rspec.rb +18 -0
- data/lib/dommy/scheduler.rb +135 -0
- data/lib/dommy/shadow_root.rb +255 -0
- data/lib/dommy/storage.rb +112 -0
- data/lib/dommy/test_helpers.rb +78 -0
- data/lib/dommy/tree_walker.rb +425 -0
- data/lib/dommy/url.rb +479 -0
- data/lib/dommy/version.rb +5 -0
- data/lib/dommy/world.rb +209 -0
- data/lib/dommy.rb +119 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 217a4ff53f58e6c12e424951b057cc0d3cbab3b898fdb85d5fd69ca7c34cd3c5
|
|
4
|
+
data.tar.gz: bedaba24772a900d85e62a27729b8e62f8db8a18e0b48360f1717799108432f8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3a9687cf21dde61fa2c65cd47ca90f18e4d65da61912ffdbd8366cbf75f1110a96da52c43a27abee498dc9f2a19ffc306a491e14bb6aa4d985f7dd74fec88816
|
|
7
|
+
data.tar.gz: 201bb5b9217bc0373923f89e36cc968cb55328f249d902acdfa973f9f01179d9912463b5ee9d165c8c5e862fb7ff3f40ed531db9f2ee811432e6f77db8c42faa
|
data/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Dommy
|
|
2
|
+
|
|
3
|
+
A pure-Ruby DOM polyfill on top of [Nokogiri::HTML5](https://nokogiri.org/) — a Ruby-side analogue to happy-dom / jsdom.
|
|
4
|
+
Dommy exposes browser-like DOM semantics (events, MutationObserver, Custom Elements, Shadow DOM, File API, timers, Storage) so view / component / request specs can verify DOM structure and behavior without spinning up a real browser.
|
|
5
|
+
|
|
6
|
+
## Quick start
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require "dommy"
|
|
10
|
+
|
|
11
|
+
win = Dommy.parse("<div id='root'><button class='primary'>Click me</button></div>")
|
|
12
|
+
btn = win.document.query_selector(".primary")
|
|
13
|
+
|
|
14
|
+
clicks = 0
|
|
15
|
+
btn.on("click") { clicks += 1 }
|
|
16
|
+
btn.click
|
|
17
|
+
clicks #=> 1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "dommy"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Highlights
|
|
28
|
+
|
|
29
|
+
### DOM operations
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
doc = win.document
|
|
33
|
+
li = doc.create_element("li")
|
|
34
|
+
li.text_content = "added"
|
|
35
|
+
doc.body.append_child(li)
|
|
36
|
+
doc.query_selector_all("li").length #=> 1
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Custom Elements + lifecycle callbacks
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class MyButton < Dommy::HTMLElement
|
|
43
|
+
def self.observed_attributes = ["data-state"]
|
|
44
|
+
def connected_callback
|
|
45
|
+
end
|
|
46
|
+
def attribute_changed_callback(name, old, new)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
win.custom_elements.define("my-button", MyButton)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Shadow DOM
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
host = win.document.create_element("my-card")
|
|
57
|
+
sr = host.attach_shadow(mode: "open")
|
|
58
|
+
sr.inner_html = "<slot></slot>"
|
|
59
|
+
|
|
60
|
+
# Outer queries can't reach inside the shadow tree
|
|
61
|
+
win.document.query_selector("p") # light DOM only
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Form validation
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
input = win.document.create_element("input")
|
|
68
|
+
input.type = "email"
|
|
69
|
+
input.set_attribute("required", "")
|
|
70
|
+
input.check_validity #=> false
|
|
71
|
+
input.validation_message #=> "Please fill out this field."
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### File API (Blob / File / FormData / DataTransfer)
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
file = Dommy::File.new(["pdf body"], "doc.pdf", "type" => "application/pdf")
|
|
78
|
+
|
|
79
|
+
# Seed a file input for tests
|
|
80
|
+
input = dom.query_selector("input[type='file']")
|
|
81
|
+
input.__set_files__([file])
|
|
82
|
+
|
|
83
|
+
# FormData picks it up
|
|
84
|
+
fd = Dommy::FormData.new(dom.query_selector("form"))
|
|
85
|
+
fd.entries.to_a #=> [["attachment", #<Dommy::File doc.pdf>]]
|
|
86
|
+
|
|
87
|
+
# Drag-and-drop simulation
|
|
88
|
+
dt = Dommy::DataTransfer.new(files: [file])
|
|
89
|
+
ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
|
|
90
|
+
dropzone.dispatch_event(ev)
|
|
91
|
+
|
|
92
|
+
# Blob URLs
|
|
93
|
+
url = Dommy::URL.create_object_url(blob) # "blob:dommy/..."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Async / Promise#await
|
|
97
|
+
|
|
98
|
+
Dommy's async surfaces (fetch, custom JS promises) return `PromiseValue`. Use `.await` from Ruby to unwrap synchronously:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
response = win.__js_call__("fetch", ["/api"]).await
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> [!WARNING]
|
|
105
|
+
> Most Dommy accessors (`Blob#text`, `Response#text`, `localStorage.get_item`) return synchronous Ruby values — not Promises. `.await` is for the JS-bridged async surface.
|
|
106
|
+
|
|
107
|
+
## Test helpers
|
|
108
|
+
|
|
109
|
+
Dommy ships test-side modules you can `include` into RSpec / Minitest. Matchers accept a `Dommy::Document` / element or a raw HTML string (auto-parsed), matching Capybara's `expect(rendered).to ...` ergonomics.
|
|
110
|
+
|
|
111
|
+
### Minitest
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
require "dommy/minitest"
|
|
115
|
+
|
|
116
|
+
class UserCardTest < Minitest::Test
|
|
117
|
+
include Dommy::TestHelpers
|
|
118
|
+
include Dommy::Minitest::Assertions
|
|
119
|
+
|
|
120
|
+
def test_renders
|
|
121
|
+
dom = parse_html(render(UserCardComponent.new(name: "Alice")))
|
|
122
|
+
assert_dom_contains(dom, "h2", text: "Alice")
|
|
123
|
+
assert_dom_contains(dom, "li", count: 3)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Assertions: `assert_dom_contains`, `assert_dom_contains_text`, `assert_dom_has_attribute`, `assert_dom_has_class`, `assert_dom_html_equal` (each with a `refute_` counterpart).
|
|
129
|
+
|
|
130
|
+
### RSpec — two matcher flavors
|
|
131
|
+
|
|
132
|
+
`require "dommy/rspec"` to get both flavors:
|
|
133
|
+
|
|
134
|
+
#### 1. `Dommy::RSpec::Matchers`
|
|
135
|
+
|
|
136
|
+
`_dom_` infix names, coexist with Capybara and `rails-dom-testing`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
expect(rendered).to contain_dom("h2", text: "Alice")
|
|
140
|
+
expect(button).to have_dom_attribute("type", "submit")
|
|
141
|
+
expect(button).to have_dom_class("primary")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### 2. `Dommy::RSpec::CapyStyleMatchers`
|
|
145
|
+
|
|
146
|
+
Capybara-compatible names for drop-in replacement in view / component / request specs:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
expect(rendered).to have_selector("h1", text: "Products")
|
|
150
|
+
expect(rendered).to have_link("Sign up", href: "/signup")
|
|
151
|
+
expect(rendered).to have_button("Submit")
|
|
152
|
+
expect(rendered).to have_no_selector(".hidden")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Use a `type:` split to keep real-browser Capybara on feature specs while letting Dommy run the rest:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
RSpec.configure do |c|
|
|
159
|
+
c.include Capybara::DSL, type: :feature
|
|
160
|
+
c.include Capybara::RSpecMatchers, type: :feature
|
|
161
|
+
|
|
162
|
+
%i[view component request controller helper].each do |t|
|
|
163
|
+
c.include Dommy::TestHelpers, type: t
|
|
164
|
+
c.include Dommy::RSpec::CapyStyleMatchers, type: t
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Supported Capybara-style options: `text:` / `exact:` / `count:` (Integer or Range) / `visible:` / `href:` / `with:` / `type:`. `wait:` is accepted and ignored (Dommy is synchronous).
|
|
170
|
+
|
|
171
|
+
> [!CAUTION]
|
|
172
|
+
> `:visible` is HTML-level only. Dommy has no CSS engine, so `display: none` set via a CSS class is **not** detected. Detection covers the `hidden` attribute, `<input type=hidden>`, non-rendering ancestors (`head`/`script`/`style`/`template`), and inline `style="display: none"` / `visibility: hidden`. If you toggle visibility through a CSS class, assert on the class instead (`have_dom_class("hidden")`) or keep that spec on Capybara + a real browser.
|
|
173
|
+
|
|
174
|
+
## What's in scope
|
|
175
|
+
|
|
176
|
+
Implemented:
|
|
177
|
+
|
|
178
|
+
- Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
|
|
179
|
+
- 26 specialized HTMLElement subclasses
|
|
180
|
+
- events with composedPath / AbortSignal
|
|
181
|
+
- MutationObserver (childList / attributes / characterData / subtree)
|
|
182
|
+
- Custom Elements lifecycle
|
|
183
|
+
- Shadow DOM (open/closed, slots, event composition)
|
|
184
|
+
- form validation
|
|
185
|
+
- Scheduler (timers + microtasks with `advance_time`)
|
|
186
|
+
- Promise
|
|
187
|
+
- Location / History / URL
|
|
188
|
+
- Storage
|
|
189
|
+
- fetch (stub)
|
|
190
|
+
- Navigator / Clipboard
|
|
191
|
+
- TreeWalker / NodeIterator / NodeFilter
|
|
192
|
+
- File API (Blob / File / FileList / FormData / DataTransfer)
|
|
193
|
+
|
|
194
|
+
> [!IMPORTANT]
|
|
195
|
+
> Out of scope:
|
|
196
|
+
>
|
|
197
|
+
> - requires a layout / CSS engine or media subsystems: real `getBoundingClientRect` / scroll metrics
|
|
198
|
+
> - CSS scoping (`:host`, `::slotted`, computed styles)
|
|
199
|
+
> - JS evaluation
|
|
200
|
+
> - Canvas / WebGL / media playback
|
|
201
|
+
> - layout-dependent Range / Selection
|
|
202
|
+
> - SVG specialized classes
|
|
203
|
+
|
|
204
|
+
## Running the tests
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
$ bundle install
|
|
208
|
+
$ bundle exec rake test
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT
|
data/lib/dommy/attr.rb
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `Attr` — wraps an HTML attribute as a Node-like object. In real
|
|
5
|
+
# DOM each attribute on an element is an Attr; `el.getAttributeNode`
|
|
6
|
+
# returns the instance, `attr.value = "x"` mutates the element's
|
|
7
|
+
# attribute, `attr.ownerElement` points back to the element.
|
|
8
|
+
#
|
|
9
|
+
# We represent two states:
|
|
10
|
+
# - "owned" — the Attr is attached to an Element. value reads/writes
|
|
11
|
+
# go through the element's Nokogiri attribute slot.
|
|
12
|
+
# - "detached" — created via `document.createAttribute(name)` but
|
|
13
|
+
# not yet attached. Value is stored locally; `setAttributeNode`
|
|
14
|
+
# transfers it to an element.
|
|
15
|
+
class Attr
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
def initialize(name, owner: nil, value: "")
|
|
19
|
+
@name = name.to_s.downcase
|
|
20
|
+
@owner = owner
|
|
21
|
+
@detached_value = value.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The Element this attr is on, or nil if detached.
|
|
25
|
+
def owner_element
|
|
26
|
+
@owner
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def value
|
|
30
|
+
if @owner
|
|
31
|
+
@owner.__node__[@name].to_s
|
|
32
|
+
else
|
|
33
|
+
@detached_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def value=(new_value)
|
|
38
|
+
if @owner
|
|
39
|
+
@owner.set_attribute(@name, new_value.to_s)
|
|
40
|
+
else
|
|
41
|
+
@detached_value = new_value.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def __js_get__(key)
|
|
46
|
+
case key
|
|
47
|
+
when "name"
|
|
48
|
+
@name
|
|
49
|
+
when "value"
|
|
50
|
+
value
|
|
51
|
+
when "nodeName"
|
|
52
|
+
@name
|
|
53
|
+
when "nodeValue"
|
|
54
|
+
value
|
|
55
|
+
when "ownerElement"
|
|
56
|
+
@owner
|
|
57
|
+
when "localName"
|
|
58
|
+
@name
|
|
59
|
+
when "namespaceURI"
|
|
60
|
+
nil
|
|
61
|
+
when "nodeType"
|
|
62
|
+
2
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def __js_set__(key, val)
|
|
67
|
+
case key
|
|
68
|
+
when "value", "nodeValue"
|
|
69
|
+
self.value = val
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def __js_call__(method, _args)
|
|
76
|
+
case method
|
|
77
|
+
when "cloneNode"
|
|
78
|
+
Attr.new(@name, owner: nil, value: value)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Internal: called by Element when the attr is being transferred
|
|
83
|
+
# to (or detached from) an Element.
|
|
84
|
+
def __attach__(element)
|
|
85
|
+
@owner = element
|
|
86
|
+
@detached_value = ""
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def __detach__
|
|
91
|
+
cached = value
|
|
92
|
+
@owner = nil
|
|
93
|
+
@detached_value = cached
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `Element.attributes` returns this. Iterable, `.length`, `.item(i)`,
|
|
99
|
+
# `.getNamedItem(name)`, `.removeNamedItem(name)`, `.setNamedItem(attr)`,
|
|
100
|
+
# plus property-style access (`attributes.id`, `attributes.class`).
|
|
101
|
+
#
|
|
102
|
+
# NamedNodeMap is *live* — it re-reads the element's Nokogiri
|
|
103
|
+
# attributes on every access so DOM mutations are reflected.
|
|
104
|
+
class NamedNodeMap
|
|
105
|
+
include Enumerable
|
|
106
|
+
|
|
107
|
+
def initialize(element)
|
|
108
|
+
@element = element
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def length
|
|
112
|
+
@element.__node__.attribute_nodes.size
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
alias size length
|
|
116
|
+
|
|
117
|
+
def item(index)
|
|
118
|
+
name = @element.__node__.attribute_nodes[index.to_i]&.name
|
|
119
|
+
name && Attr.new(name, owner: @element)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def get_named_item(name)
|
|
123
|
+
key = name.to_s.downcase
|
|
124
|
+
return nil unless @element.__node__.key?(key)
|
|
125
|
+
|
|
126
|
+
Attr.new(key, owner: @element)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def set_named_item(attr)
|
|
130
|
+
return nil unless attr.is_a?(Attr)
|
|
131
|
+
|
|
132
|
+
key = attr.name
|
|
133
|
+
val = attr.value
|
|
134
|
+
attr.__attach__(@element)
|
|
135
|
+
@element.set_attribute(key, val)
|
|
136
|
+
attr
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def remove_named_item(name)
|
|
140
|
+
key = name.to_s.downcase
|
|
141
|
+
return nil unless @element.__node__.key?(key)
|
|
142
|
+
|
|
143
|
+
attr = Attr.new(key, owner: nil, value: @element.__node__[key].to_s)
|
|
144
|
+
@element.remove_attribute(key)
|
|
145
|
+
attr
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def each(&blk)
|
|
149
|
+
@element.__node__.attribute_nodes.each do |a|
|
|
150
|
+
yield Attr.new(a.name, owner: @element)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Property-style access — `el.attributes.id`, `el.attributes["class"]`.
|
|
155
|
+
def [](key)
|
|
156
|
+
case key
|
|
157
|
+
when Integer
|
|
158
|
+
item(key)
|
|
159
|
+
else
|
|
160
|
+
get_named_item(key)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def __js_get__(key)
|
|
165
|
+
case key
|
|
166
|
+
when "length"
|
|
167
|
+
length
|
|
168
|
+
else
|
|
169
|
+
# Numeric key = item(i); string key = named item
|
|
170
|
+
if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
|
|
171
|
+
item(key.to_i)
|
|
172
|
+
else
|
|
173
|
+
get_named_item(key)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def __js_call__(method, args)
|
|
179
|
+
case method
|
|
180
|
+
when "item"
|
|
181
|
+
item(args[0])
|
|
182
|
+
when "getNamedItem"
|
|
183
|
+
get_named_item(args[0])
|
|
184
|
+
when "setNamedItem"
|
|
185
|
+
set_named_item(args[0])
|
|
186
|
+
when "removeNamedItem"
|
|
187
|
+
remove_named_item(args[0])
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def method_missing(name, *args)
|
|
192
|
+
attr = get_named_item(name)
|
|
193
|
+
attr || super
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def respond_to_missing?(name, include_private = false)
|
|
197
|
+
@element.__node__.key?(name.to_s.downcase) || super
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/dommy/blob.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `Blob` — opaque binary chunk with a MIME type, mirroring the
|
|
5
|
+
# File API's `Blob` interface. Used by File, FormData, and any
|
|
6
|
+
# code that needs to round-trip bytes through the DOM (e.g. a
|
|
7
|
+
# `<input type="file">` test scenario).
|
|
8
|
+
#
|
|
9
|
+
# Spec: https://w3c.github.io/FileAPI/#blob-section
|
|
10
|
+
class Blob
|
|
11
|
+
attr_reader :size, :type
|
|
12
|
+
|
|
13
|
+
# Construct a Blob from a list of parts. Each part can be:
|
|
14
|
+
# - String (treated as binary bytes)
|
|
15
|
+
# - Blob / File (their bytes are concatenated)
|
|
16
|
+
# - Array<Integer> (byte values, like ArrayBuffer)
|
|
17
|
+
# - anything else: coerced via to_s
|
|
18
|
+
#
|
|
19
|
+
# `options["type"]` sets the MIME type (lowercased per spec).
|
|
20
|
+
def initialize(parts = [], options = {})
|
|
21
|
+
parts = [parts] unless parts.is_a?(Array)
|
|
22
|
+
@data = collect_bytes(parts)
|
|
23
|
+
@size = @data.bytesize
|
|
24
|
+
raw_type = options["type"] || options[:type] || ""
|
|
25
|
+
@type = raw_type.to_s.downcase
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return a new Blob over a byte range of this one.
|
|
29
|
+
# Negative indices are treated as offsets from the end (per spec).
|
|
30
|
+
def slice(start = 0, last = @size, content_type = "")
|
|
31
|
+
s = clamp_index(start.to_i, @size)
|
|
32
|
+
e = clamp_index(last.to_i, @size)
|
|
33
|
+
e = s if e < s
|
|
34
|
+
Blob.new([@data.byteslice(s, e - s) || ""], "type" => content_type.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Read the bytes as UTF-8 text. The DOM spec returns a Promise,
|
|
38
|
+
# but Dommy is synchronous, so callers can use the result directly.
|
|
39
|
+
def text
|
|
40
|
+
@data.dup.force_encoding(Encoding::UTF_8)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Read the bytes as an Array<Integer>. The DOM spec returns a
|
|
44
|
+
# Promise<ArrayBuffer>; Dommy is synchronous.
|
|
45
|
+
def array_buffer
|
|
46
|
+
@data.bytes
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
|
|
50
|
+
# fetch when serializing multipart bodies.
|
|
51
|
+
def __bytes__
|
|
52
|
+
@data
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def __js_get__(key)
|
|
56
|
+
case key
|
|
57
|
+
when "size"
|
|
58
|
+
@size
|
|
59
|
+
when "type"
|
|
60
|
+
@type
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def __js_call__(method, args)
|
|
65
|
+
case method
|
|
66
|
+
when "slice"
|
|
67
|
+
slice(args[0] || 0, args[1] || @size, args[2] || "")
|
|
68
|
+
when "text"
|
|
69
|
+
text
|
|
70
|
+
when "arrayBuffer"
|
|
71
|
+
array_buffer
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def collect_bytes(parts)
|
|
78
|
+
buf = String.new(encoding: Encoding::ASCII_8BIT)
|
|
79
|
+
parts.each do |part|
|
|
80
|
+
case part
|
|
81
|
+
when Blob
|
|
82
|
+
buf << part.__bytes__
|
|
83
|
+
when String
|
|
84
|
+
buf << part.dup.force_encoding(Encoding::ASCII_8BIT)
|
|
85
|
+
when Array
|
|
86
|
+
buf << part.pack("C*")
|
|
87
|
+
else
|
|
88
|
+
buf << part.to_s.dup.force_encoding(Encoding::ASCII_8BIT)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
buf
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clamp_index(idx, length)
|
|
96
|
+
idx = length + idx if idx.negative?
|
|
97
|
+
idx.clamp(0, length)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `File` — Blob with a filename and an optional last-modified
|
|
102
|
+
# timestamp. Returned from `<input type="file">` / drag-and-drop,
|
|
103
|
+
# and accepted by FormData.
|
|
104
|
+
#
|
|
105
|
+
# Spec: https://w3c.github.io/FileAPI/#file-section
|
|
106
|
+
class File < Blob
|
|
107
|
+
attr_reader :name, :last_modified
|
|
108
|
+
|
|
109
|
+
def initialize(parts, name, options = {})
|
|
110
|
+
super(parts, options)
|
|
111
|
+
@name = name.to_s
|
|
112
|
+
raw_lm = options["lastModified"] || options[:lastModified]
|
|
113
|
+
@last_modified = (raw_lm || (Time.now.to_f * 1000)).to_i
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def __js_get__(key)
|
|
117
|
+
case key
|
|
118
|
+
when "name"
|
|
119
|
+
@name
|
|
120
|
+
when "lastModified"
|
|
121
|
+
@last_modified
|
|
122
|
+
else
|
|
123
|
+
super
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `FileList` — immutable, ordered collection of File objects.
|
|
129
|
+
# Returned by `<input type="file">#files` and DataTransfer#files.
|
|
130
|
+
#
|
|
131
|
+
# Spec: https://w3c.github.io/FileAPI/#filelist-section
|
|
132
|
+
class FileList
|
|
133
|
+
include Enumerable
|
|
134
|
+
|
|
135
|
+
def initialize(files = [])
|
|
136
|
+
@files = files.to_a.freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def length
|
|
140
|
+
@files.length
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
alias size length
|
|
144
|
+
|
|
145
|
+
def item(index)
|
|
146
|
+
@files[index.to_i]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def [](index)
|
|
150
|
+
item(index)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def each(&block)
|
|
154
|
+
@files.each(&block)
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def empty?
|
|
159
|
+
@files.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def to_a
|
|
163
|
+
@files.dup
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def __js_get__(key)
|
|
167
|
+
case key
|
|
168
|
+
when "length"
|
|
169
|
+
length
|
|
170
|
+
else
|
|
171
|
+
item(key.to_i) if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def __js_call__(method, args)
|
|
176
|
+
case method
|
|
177
|
+
when "item"
|
|
178
|
+
item(args[0])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|