makiri 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8998ec8f7ae9b6e9f31fb3b880f50a53602e2cdea51b42faa33613638e2ceedc
4
- data.tar.gz: df9c18e5917eb79e2b0311a65c70e1b648e40c62fd1b14ecec1d64b4394f3930
3
+ metadata.gz: ece0ed1298dbb761fbaeecc7e497b20ee69766c988ef5e0c671e282a3a455de9
4
+ data.tar.gz: a909b7cbd715ad23e58f5c4f649298c6ca4472c9004423eb73930baadea205ec
5
5
  SHA512:
6
- metadata.gz: 5bd1a20a81195609e360ffc71108cef27a62803a387526dfdb38cf7cbf0ae6bde69e48815717ba1d236300c2aedd376589b4799c610a883ebf088d8a37e0f3ce
7
- data.tar.gz: 1104a702f5348a2e52705c81153af1baaad12bcc3a649e5e863d5e8a6c98da3566ede4efa8cb0f0b8d091b0ba3ee9abef0c67dcf83ddae33d48706999df9cd74
6
+ metadata.gz: 265f7671dd689039fb98a41513b45fa2b3dae542f9a99942832fe25e36c1b4e7430caf8c14f39c3832947819258a850cacca556ef278785356d616d5b132cd16
7
+ data.tar.gz: 7bed0126338689b1e5c2bbf47b60d9550352b471162f80b030cad11141158efbd7a293f48551be73bbdfe1ac9d24cab6c0074ff46ef5d01e2aa404888e147c0e
@@ -157,6 +157,55 @@ jobs:
157
157
  # poisoning on), catching intra-arena overflows that a plain ASan build cannot
158
158
  # see - the class the v3.0.0 :lexbor-contains overflow belonged to. Heavy
159
159
  # (full instrumented Lexbor rebuild), so nightly only.
160
+ # The randomised property checks (spec/invariants/). Cheap enough to gate a PR
161
+ # on, so they run on every push; the ASan pass is nightly because an
162
+ # instrumented build is slow.
163
+ invariants:
164
+ name: Invariant checks
165
+ runs-on: ubuntu-latest
166
+ if: github.event_name != 'schedule'
167
+ steps:
168
+ - name: Checkout (with vendored Lexbor submodule)
169
+ uses: actions/checkout@v6
170
+ with:
171
+ submodules: recursive
172
+
173
+ - name: Ensure cmake is available
174
+ uses: lukka/get-cmake@latest
175
+
176
+ - name: Set up Ruby
177
+ uses: ruby/setup-ruby@v1
178
+ with:
179
+ ruby-version: "3.4"
180
+ bundler-cache: true
181
+
182
+ - name: Run the invariant checks
183
+ run: bundle exec rake invariants
184
+
185
+ invariants-sanitize:
186
+ name: Nightly invariant checks under ASan + UBSan
187
+ runs-on: ubuntu-latest
188
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
189
+ steps:
190
+ - name: Checkout (with vendored Lexbor submodule)
191
+ uses: actions/checkout@v6
192
+ with:
193
+ submodules: recursive
194
+
195
+ - name: Ensure cmake is available
196
+ uses: lukka/get-cmake@latest
197
+
198
+ - name: Set up Ruby
199
+ uses: ruby/setup-ruby@v1
200
+ with:
201
+ ruby-version: "3.4"
202
+ bundler-cache: true
203
+
204
+ # The text index hands out borrowed slices, so a stale one is a memory bug
205
+ # as much as a wrong answer - this is the pass that would see it.
206
+ - name: Run the invariant checks under sanitizers
207
+ run: bundle exec rake "invariants:sanitize"
208
+
160
209
  security-sanitize-lexbor:
161
210
  name: Nightly instrumented-Lexbor ASan suite
162
211
  runs-on: ubuntu-latest
data/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-09-11
4
+
5
+ ### Added
6
+
7
+ * `Node#attribute_by_qualified_name(name)` and
8
+ `Node#attribute_value_by_qualified_name(name)`: the attribute whose
9
+ **qualified** name is exactly `name` — its node, and its value — or nil.
10
+ `#[]` cannot answer this: it also finds a prefixed attribute by its local
11
+ name (`svg_a["href"]` returns `xlink:href`), and it lower-cases what it looks
12
+ up (`el["DATA-X"]` finds `data-x`). The new match is byte-exact.
13
+
14
+ ### Changed
15
+
16
+ * **`Makiri::XML` follows the DOM namespace model.** A node's namespace URI is
17
+ decided once — by the parser, or by the context it is first inserted into —
18
+ and does not change afterwards; the serializer emits whatever xmlns
19
+ declarations the output needs:
20
+
21
+ - moving a node under an element that binds its prefix to a different URI
22
+ keeps `namespace_uri`, and the output declares the prefix at the moved node;
23
+ - inserting a node whose prefix is bound nowhere in the destination now
24
+ succeeds instead of raising;
25
+ - `import_node` keeps the namespace of what it copied;
26
+ - an element in no namespace stays in no namespace under a default namespace,
27
+ serialized as `xmlns=""`;
28
+ - `#to_xml` on a node below the root declares the prefixes its subtree uses,
29
+ so the output re-parses to the same namespaces standing alone.
30
+
31
+ Nodes from the factories (`create_element` and friends) still take their
32
+ namespace from the context they are first inserted into.
33
+
34
+ * **Inserting a node from another document adopts it.** `add_child` / `before` /
35
+ `after` / `replace` bring the node over and remove it from the document it
36
+ came from, instead of copying it (`Makiri::XML`) or raising (`Makiri::HTML`).
37
+ A spliced fragment is left empty; a rejected insert leaves the source
38
+ document untouched.
39
+
40
+ The node handed back is a **different object** than the one passed in, so use
41
+ the return value afterwards rather than the argument. `Document#import_node`
42
+ is unchanged: it copies and leaves the source alone.
43
+
44
+ * **XML serialization is capped at the nesting depth the parser accepts.**
45
+ `#to_xml` and `#canonicalize` raise `Makiri::Error` past it, rather than
46
+ emitting XML that Makiri could not read back. A deeper tree is still fine to
47
+ hold, walk and query.
48
+
49
+ ### Fixed
50
+
51
+ * XPath axes from an **attribute** context node on the XML backend now follow
52
+ XPath 1.0 §2.2: `following-sibling` and `preceding-sibling` are empty, and
53
+ `following` / `preceding` exclude attribute nodes. They used to return the
54
+ element's later attributes.
55
+
56
+ * A rejected insert no longer leaves part of the moved subtree carrying
57
+ namespace URIs resolved against the scope it was refused from.
58
+
3
59
  ## [0.8.0] - 2026-07-12
4
60
 
5
61
  ### Fixed
data/README.md CHANGED
@@ -110,7 +110,9 @@ doc.css("feed > entry").map { |e| e.at_css("title").text } # => ["Hello", "Worl
110
110
 
111
111
  # Serialize back to XML
112
112
  doc.to_xml # => "<?xml version=\"1.0\"?>\n<feed ...>...</feed>\n"
113
- doc.at_xpath("//a:entry", ns).to_xml # => "<entry><title>Hello</title></entry>" (no declaration)
113
+ # A node below the root serializes self-contained: no XML declaration, but the
114
+ # namespace declarations its subtree needs, so the output re-parses the same.
115
+ doc.at_xpath("//a:entry", ns).to_xml # => "<entry xmlns=\"http://www.w3.org/2005/Atom\"><title>Hello</title></entry>"
114
116
  doc.to_xml(pretty: true) # indented, element-only content
115
117
 
116
118
  # DOCTYPE is recognized but the DTD is not processed (no entities, no I/O):
@@ -138,8 +140,11 @@ doc.root.to_xml # => "<feed xmlns:dc=\"urn:dc\"><post dc:k=\"v\">Bye</
138
140
  ```
139
141
 
140
142
  XML subtrees can be built with `Document#create_element` and related node factory methods,
141
- then inserted with `#add_child`, `#before`, `#after`, or `#replace`;
142
- namespaces are resolved at insertion time, and cross-document nodes are deep-copied.
143
+ then inserted with `#add_child`, `#before`, `#after`, or `#replace`. A factory-built
144
+ node takes its namespace from the context it is first inserted into; one that already
145
+ has a namespace keeps it. A node from another document is **adopted** — brought over
146
+ and removed from the document it came from — and the method returns the node now in
147
+ the tree, which is a different object than the one passed in.
143
148
 
144
149
  `Document#import_node(node, deep = false)` brings a node into a document as a
145
150
  detached copy, and works **across representations**: importing a `Makiri::HTML`
@@ -240,6 +245,34 @@ Detailed, test-backed notes live in `spec/conformance/README.md`.
240
245
  (QNames), never PI targets. Nokogiri/libxml2 rejects it (`colons are forbidden
241
246
  from PI names`); Makiri follows the normative text. Only the reserved `xml`
242
247
  (any case) target is rejected.
248
+ * A node's **namespace URI is its identity**, not something re-derived from the
249
+ declarations around it - the WHATWG DOM model, measured against Chrome 152
250
+ (`DOMParser` + `XMLSerializer`).
251
+ * Moving a node under an element that binds its prefix to a different URI does
252
+ **not** change `namespace_uri`; the serializer emits the declaration the
253
+ output needs (`<p:x xmlns:p="urn:a"/>`), and nothing when the destination
254
+ already agrees. libxml2 keeps the URI on an in-document move but does *not*
255
+ emit the declaration, so Nokogiri's tree and its own output disagree there.
256
+ * `#to_xml` on a node below the root is **self-contained**: it declares the
257
+ prefixes its subtree uses, so the output re-parses to the same namespaces
258
+ standing alone. Nokogiri omits them, and its subtree output does not
259
+ round-trip.
260
+ * Where one prefix would have to mean two things at once, the serializer
261
+ invents one (`ns1`, `ns2`, ...) rather than shadow the other, as browsers do.
262
+ * An element in **no** namespace stays that way under a default namespace,
263
+ serialized as `xmlns=""`.
264
+ * Nodes from the factories (`create_element` and friends) still take their
265
+ namespace from the context they are first inserted into, so a subtree can be
266
+ built detached and attached afterwards. Only later moves carry.
267
+ * Inserting a node **from another document adopts it** (`add_child` / `before` /
268
+ `after` / `replace`): it is brought over and taken out of the document it came
269
+ from, as `appendChild` does in the DOM and in both Chrome and Nokogiri.
270
+ * Each arena owns its own nodes, so the node cannot be relinked across them: it
271
+ is copied here and removed there. The one visible difference from Nokogiri
272
+ and browsers is that the node handed back is a **different object** than the
273
+ one passed in - use the return value afterwards, not the argument.
274
+ * `Document#import_node` is the copy: it leaves the source alone, like DOM
275
+ `importNode`.
243
276
  * Otherwise the parsed tree is byte-identical to `Nokogiri::XML`'s (verified by
244
277
  the property-based differential), including namespaces, prolog/epilog comments
245
278
  and PIs, and adjacent-CDATA coalescing.
@@ -267,6 +300,21 @@ Detailed, test-backed notes live in `spec/conformance/README.md`.
267
300
  * Type selectors are ASCII case-insensitive (CSS-correct for HTML; `LI` matches `<li>`)
268
301
  * `Nokogiri::HTML5` is case-sensitive there.
269
302
 
303
+ ### Serialization
304
+
305
+ * **Comment data is written literally**, as the WHATWG serialization algorithm
306
+ says and as browsers do: `comment.content = "a-->b"` serializes to
307
+ `<!--a-->b-->`, which re-parses as the comment `"a"` followed by text.
308
+ * `Nokogiri::HTML5` escapes it to `<!--a--&gt;b-->` instead. That does not
309
+ round-trip either - comments do not decode entities, so the data comes back
310
+ as `"a--&gt;b"`. Neither library round-trips this; Makiri matches Chrome.
311
+ * The same applies to the children of `style` / `script` / `xmp` / `iframe` /
312
+ `noembed` / `noframes` / `plaintext`, which the algorithm also writes
313
+ literally. `noscript` is **escaped**, because Makiri parses it with scripting
314
+ disabled (its children are elements, not raw text) and escaping is what makes
315
+ that round-trip; `Nokogiri::HTML5` writes it literally and contradicts its own
316
+ parser there.
317
+
270
318
  ### Text input (mutation APIs)
271
319
 
272
320
  * Programmatic string arguments must be **valid UTF-8** (invalid bytes raise
data/Rakefile CHANGED
@@ -226,6 +226,63 @@ task :sanitize do
226
226
  sh(env, "#{FileUtils::RUBY} -S rspec")
227
227
  end
228
228
 
229
+ # The randomised property checks in spec/invariants/. They are not *_spec.rb, so
230
+ # `rake spec` skips them; they run here and nightly in CI. Each takes
231
+ # [documents] [seed] [html|xml] and is deterministic, so a finding replays.
232
+ INVARIANT_CHECKS = [
233
+ ["check_ns_reresolve.rb", %w[]],
234
+ ["check_import_clone.rb", %w[]],
235
+ ["check_tree_invariants.rb", %w[html xml]],
236
+ ["check_index_staleness.rb", %w[html xml]],
237
+ ["check_serialize_fixpoint.rb", %w[html xml]],
238
+ ["check_text_input.rb", nil], # takes no count
239
+ ].freeze
240
+
241
+ # [[script, argv], ...] for a given document count.
242
+ def invariant_runs(count)
243
+ INVARIANT_CHECKS.flat_map do |script, backends|
244
+ path = "spec/invariants/#{script}"
245
+ next [[path, []]] if backends.nil?
246
+ next [[path, [count.to_s]]] if backends.empty?
247
+
248
+ backends.map { |b| [path, [count.to_s, "20260911", b]] }
249
+ end
250
+ end
251
+
252
+ desc "Run the invariant checks (override the document count via INVARIANT_COUNT)"
253
+ task invariants: :compile do
254
+ count = (ENV["INVARIANT_COUNT"] || 2000).to_i
255
+ invariant_runs(count).each do |script, argv|
256
+ sh "#{FileUtils::RUBY} -Ilib #{script} #{argv.join(' ')}"
257
+ end
258
+ end
259
+
260
+ desc "Run the invariant checks under AddressSanitizer + UBSan (the text index " \
261
+ "holds borrowed slices, so staleness there is a memory bug too)"
262
+ task "invariants:sanitize" do
263
+ sanitize = ENV["MAKIRI_SANITIZE"] || "address,undefined"
264
+ sh({ "MAKIRI_SANITIZE" => sanitize }, "#{FileUtils::RUBY} -S rake clean compile")
265
+
266
+ env = {
267
+ "ASAN_OPTIONS" => "detect_leaks=0:detect_container_overflow=0:" \
268
+ "detect_odr_violation=0:abort_on_error=1:halt_on_error=1",
269
+ "UBSAN_OPTIONS" => "print_stacktrace=1:halt_on_error=1",
270
+ }
271
+ if sanitize.include?("address")
272
+ runtime = asan_runtime_path or
273
+ abort "invariants:sanitize: could not locate the ASan runtime for #{RbConfig::CONFIG['CC']}"
274
+ preload = RbConfig::CONFIG["target_os"] =~ /darwin/ ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD"
275
+ env[preload] = runtime
276
+ puts "invariants:sanitize: preloading #{runtime} via #{preload}"
277
+ end
278
+
279
+ # Instrumented builds are slow; a smaller sweep still exercises every path.
280
+ count = (ENV["INVARIANT_COUNT"] || 500).to_i
281
+ invariant_runs(count).each do |script, argv|
282
+ sh(env, "#{FileUtils::RUBY} -Ilib #{script} #{argv.join(' ')}")
283
+ end
284
+ end
285
+
229
286
  desc "Measure C coverage of OUR sources (clang source-based) over the spec suite. " \
230
287
  "Prints an llvm-cov region+branch report (excludes vendored Lexbor) and writes " \
231
288
  "a line-level detail file to tmp/coverage/show.txt."
@@ -124,6 +124,7 @@ lxb_dom_node_t *mkr_run_fragment_parser(VALUE html, mkr_fragment_parse_fn parse,
124
124
  * Implemented in ruby_doc.c (next to the import machinery), bound in
125
125
  * mkr_init_node. */
126
126
  VALUE mkr_node_clone_node(int argc, VALUE *argv, VALUE self);
127
+ lxb_dom_node_t *mkr_html_import_deep(lxb_dom_document_t *doc, lxb_dom_node_t *src);
127
128
 
128
129
  /* NodeSet bridge (glue/ruby_node_set.c). mkr_raw_node_t (above): callers cast
129
130
  * their typed node to it when pushing (forgetting the type is the safe, store
@@ -339,6 +339,20 @@ mkr_run_fragment_parser(VALUE html, mkr_fragment_parse_fn parse, void *ctx)
339
339
  return root;
340
340
  }
341
341
 
342
+ /* Deep-import +src+ into +doc+, <template> contents included - the copy step of
343
+ * adopting a node from another document (ruby_html_mutate.c). Raises rather than
344
+ * returning a partial node. */
345
+ lxb_dom_node_t *
346
+ mkr_html_import_deep(lxb_dom_document_t *doc, lxb_dom_node_t *src)
347
+ {
348
+ lxb_dom_node_t *imp = lxb_dom_document_import_node(doc, src, true);
349
+ if (imp == NULL) {
350
+ rb_raise(mkr_eError, "failed to import node");
351
+ }
352
+ mkr_fixup_template_content(doc, src, imp);
353
+ return imp;
354
+ }
355
+
342
356
  /* Node#clone_node(deep = false): a shallow (or deep, with deep truthy) copy of
343
357
  * this node, owned by the same document and detached from any parent - the DOM
344
358
  * cloneNode, whose `deep` defaults to false (a missing/nil/false argument =>
@@ -26,11 +26,13 @@ lxb_dom_attr_qualified_name_append(lexbor_hash_t *hash, const lxb_char_t *name,
26
26
  * DOM mutation (v0.2). Thin wrappers over Lexbor's insert/remove/create
27
27
  * functions, with the safety checks Lexbor itself omits:
28
28
  *
29
- * - same-document: a node can only be inserted into its own document
30
- * (cross-document moves would splice foreign-arena pointers);
31
29
  * - no cycles: a node cannot become a descendant of itself;
32
30
  * - attribute nodes are not tree children.
33
31
  *
32
+ * A node from another document is ADOPTED, as the DOM says appendChild does: it
33
+ * cannot be relinked across arenas, so it is copied here and released there
34
+ * (mkr_adopt_copy / mkr_adopt_release), and the verb hands back the copy.
35
+ *
34
36
  * We never destroy detached nodes: the document arena owns all node memory and
35
37
  * frees it wholesale, and live Ruby wrappers may still point at a removed node.
36
38
  * `remove`/`unlink` therefore only detach.
@@ -57,26 +59,81 @@ mkr_arg_node(VALUE v)
57
59
  return mkr_html_node_unwrap(v);
58
60
  }
59
61
 
60
- /* Validate that `incoming` may be placed relative to `ref` and detach it from
61
- * any current parent (move semantics). Raises on the unsafe cases. */
62
+ /* Copy +node+ into +doc+, for a node that came from another document. Lexbor's
63
+ * arenas own their own nodes, so it cannot be relinked across them; the copy is
64
+ * this half of the DOM's adopt, and mkr_adopt_release is the other. */
65
+ static lxb_dom_node_t *
66
+ mkr_adopt_copy(lxb_dom_document_t *doc, lxb_dom_node_t *node)
67
+ {
68
+ return mkr_html_import_deep(doc, node);
69
+ }
70
+
71
+ /* The other half: take +node+ out of the document it came from, so the whole
72
+ * thing reads as the move the DOM says appendChild performs.
73
+ *
74
+ * Called only AFTER the insert has gone through, so a refused one leaves the
75
+ * source document alone - the same ordering the XML backend uses. */
62
76
  static void
63
- mkr_prepare_insert(lxb_dom_node_t *ref, lxb_dom_node_t *incoming)
77
+ mkr_adopt_release(lxb_dom_node_t *node)
78
+ {
79
+ if (node->type == LXB_DOM_NODE_TYPE_DOCUMENT_FRAGMENT) {
80
+ /* A fragment contributes its children; the DOM leaves a spliced one
81
+ * empty, so empty the source rather than detaching it. */
82
+ lxb_dom_node_t *c;
83
+ while ((c = node->first_child) != NULL) lxb_dom_node_remove(c);
84
+ } else if (node->parent != NULL) {
85
+ lxb_dom_node_remove(node);
86
+ }
87
+ }
88
+
89
+ /* Validate that `rb_incoming` may be placed relative to `ref`, detach it from
90
+ * any current parent (move semantics), and return the node to actually insert.
91
+ *
92
+ * For a node from another document that is its copy (see mkr_adopt_copy), so
93
+ * the returned node is not always the one passed in and the caller must insert
94
+ * - and hand back - what this returns. *+adopt_from+ receives the argument in
95
+ * that case, for mkr_inserted_result to release once the insert has gone
96
+ * through; Qnil otherwise. Holding the Ruby VALUE, rather than the raw node,
97
+ * also keeps the source document reachable until then - the same protocol the
98
+ * XML backend uses (mkr_xml_incoming_node / mkr_xml_adopt_finish).
99
+ *
100
+ * Raises on the unsafe cases. */
101
+ static lxb_dom_node_t *
102
+ mkr_prepare_insert(lxb_dom_node_t *ref, VALUE rb_incoming, VALUE *adopt_from)
64
103
  {
104
+ lxb_dom_node_t *incoming = mkr_arg_node(rb_incoming);
105
+
106
+ *adopt_from = Qnil;
65
107
  if (incoming->type == LXB_DOM_NODE_TYPE_ATTRIBUTE) {
66
108
  rb_raise(mkr_eError, "an attribute node cannot be inserted into the tree");
67
109
  }
68
- if (ref->owner_document != incoming->owner_document) {
69
- rb_raise(mkr_eError, "cannot move a node between documents");
70
- }
71
110
  /* incoming must not be an inclusive ancestor of ref. */
72
111
  for (lxb_dom_node_t *p = ref; p != NULL; p = p->parent) {
73
112
  if (p == incoming) {
74
113
  rb_raise(mkr_eError, "cannot insert a node into its own subtree");
75
114
  }
76
115
  }
116
+ if (ref->owner_document != incoming->owner_document) {
117
+ *adopt_from = rb_incoming;
118
+ return mkr_adopt_copy(ref->owner_document, incoming);
119
+ }
77
120
  if (incoming->parent != NULL) {
78
121
  lxb_dom_node_remove(incoming);
79
122
  }
123
+ return incoming;
124
+ }
125
+
126
+ /* The value an insertion verb hands back: its argument, or - when the node was
127
+ * adopted - the node now in the tree, which is a different object. Finishing the
128
+ * adoption here keeps the release after the insert, where it belongs. */
129
+ static VALUE
130
+ mkr_inserted_result(VALUE self, VALUE rb_arg, lxb_dom_node_t *inserted,
131
+ VALUE adopt_from)
132
+ {
133
+ if (NIL_P(adopt_from)) return rb_arg;
134
+
135
+ mkr_adopt_release(mkr_arg_node(adopt_from));
136
+ return mkr_wrap_html_node(inserted, mkr_node_document(self));
80
137
  }
81
138
 
82
139
  /* WHATWG doctype ordering at the document node (https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity),
@@ -194,12 +251,12 @@ static VALUE
194
251
  mkr_node_add_child(VALUE self, VALUE rb_child)
195
252
  {
196
253
  lxb_dom_node_t *parent = mkr_node_unwrap_mutable(self);
197
- lxb_dom_node_t *child = mkr_arg_node(rb_child);
198
- mkr_guard_doc_child_order(parent, NULL, NULL, child); /* append: before == NULL */
199
- mkr_prepare_insert(parent, child);
200
- mkr_splice_or_insert(parent, child, lxb_dom_node_insert_child, 0);
254
+ mkr_guard_doc_child_order(parent, NULL, NULL, mkr_arg_node(rb_child)); /* append */
255
+ VALUE adopt_from;
256
+ lxb_dom_node_t *ins = mkr_prepare_insert(parent, rb_child, &adopt_from);
257
+ mkr_splice_or_insert(parent, ins, lxb_dom_node_insert_child, 0);
201
258
  mkr_invalidate_index(self);
202
- return rb_child;
259
+ return mkr_inserted_result(self, rb_child, ins, adopt_from);
203
260
  }
204
261
 
205
262
  /* node << child -> node (chainable). */
@@ -214,30 +271,30 @@ static VALUE
214
271
  mkr_node_add_previous_sibling(VALUE self, VALUE rb_node)
215
272
  {
216
273
  lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
217
- lxb_dom_node_t *node = mkr_arg_node(rb_node);
218
274
  if (ref->parent == NULL) {
219
275
  rb_raise(mkr_eError, "cannot add a sibling to a node with no parent");
220
276
  }
221
- mkr_guard_doc_child_order(ref->parent, ref, NULL, node); /* inserted before ref */
222
- mkr_prepare_insert(ref, node);
223
- mkr_splice_or_insert(ref, node, lxb_dom_node_insert_before, 0);
277
+ mkr_guard_doc_child_order(ref->parent, ref, NULL, mkr_arg_node(rb_node));
278
+ VALUE adopt_from;
279
+ lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_node, &adopt_from);
280
+ mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_before, 0);
224
281
  mkr_invalidate_index(self);
225
- return rb_node;
282
+ return mkr_inserted_result(self, rb_node, ins, adopt_from);
226
283
  }
227
284
 
228
285
  static VALUE
229
286
  mkr_node_add_next_sibling(VALUE self, VALUE rb_node)
230
287
  {
231
288
  lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
232
- lxb_dom_node_t *node = mkr_arg_node(rb_node);
233
289
  if (ref->parent == NULL) {
234
290
  rb_raise(mkr_eError, "cannot add a sibling to a node with no parent");
235
291
  }
236
- mkr_guard_doc_child_order(ref->parent, ref->next, NULL, node); /* inserted after ref */
237
- mkr_prepare_insert(ref, node);
238
- mkr_splice_or_insert(ref, node, lxb_dom_node_insert_after, 1);
292
+ mkr_guard_doc_child_order(ref->parent, ref->next, NULL, mkr_arg_node(rb_node));
293
+ VALUE adopt_from;
294
+ lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_node, &adopt_from);
295
+ mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_after, 1);
239
296
  mkr_invalidate_index(self);
240
- return rb_node;
297
+ return mkr_inserted_result(self, rb_node, ins, adopt_from);
241
298
  }
242
299
 
243
300
  /* node.remove / node.unlink -> node. Detaches from the tree (still usable). */
@@ -260,16 +317,16 @@ static VALUE
260
317
  mkr_node_replace(VALUE self, VALUE rb_other)
261
318
  {
262
319
  lxb_dom_node_t *ref = mkr_node_unwrap_mutable(self);
263
- lxb_dom_node_t *other = mkr_arg_node(rb_other);
264
320
  if (ref->parent == NULL) {
265
321
  rb_raise(mkr_eError, "cannot replace a node with no parent");
266
322
  }
267
- mkr_guard_doc_child_order(ref->parent, ref, ref, other); /* other takes ref's slot */
268
- mkr_prepare_insert(ref, other);
269
- mkr_splice_or_insert(ref, other, lxb_dom_node_insert_before, 0);
323
+ mkr_guard_doc_child_order(ref->parent, ref, ref, mkr_arg_node(rb_other));
324
+ VALUE adopt_from;
325
+ lxb_dom_node_t *ins = mkr_prepare_insert(ref, rb_other, &adopt_from);
326
+ mkr_splice_or_insert(ref, ins, lxb_dom_node_insert_before, 0);
270
327
  lxb_dom_node_remove(ref);
271
328
  mkr_invalidate_index(self);
272
- return rb_other;
329
+ return mkr_inserted_result(self, rb_other, ins, adopt_from);
273
330
  }
274
331
 
275
332
  /* ------------------------------------------------------------------ */
@@ -8,6 +8,8 @@
8
8
 
9
9
  #include <lexbor/ns/ns.h> /* lxb_ns_by_id, LXB_NS__UNDEF (namespaceURI) */
10
10
 
11
+ #include "../core/mkr_span.h" /* mkr_bytes_eq */
12
+
11
13
  /* ------------------------------------------------------------------ */
12
14
  /* wrap / unwrap */
13
15
  /* ------------------------------------------------------------------ */
@@ -720,6 +722,81 @@ mkr_node_attribute_nodes(VALUE self)
720
722
  return set;
721
723
  }
722
724
 
725
+ /* element.attribute_by_qualified_name(name) -> the Attr node whose QUALIFIED
726
+ * name is exactly `name`, or nil. Nil for non-elements.
727
+ *
728
+ * `#[]` / `#key?` cannot answer this: they go through Lexbor's attribute-name
729
+ * hash, which is keyed by LOCAL name (lxb_dom_element_attr_by_name), so on an
730
+ * element carrying a prefixed attribute - `<a xlink:href>` in an inline <svg>,
731
+ * say - `el["href"]` hands that attribute back. The DOM's by-name family
732
+ * (getAttribute, setAttribute, removeAttribute) is defined on the qualified
733
+ * name, where `getAttribute("href")` there is null, and needs the exact match.
734
+ *
735
+ * The match is also BYTE-EXACT, where `#[]` lower-cases what it looks up
736
+ * (`el["DATA-X"]` finds `data-x`). getAttribute's ASCII-lowercasing applies only
737
+ * to an HTML element in an HTML document, so the caller does that step.
738
+ *
739
+ * The scan is the element's own attribute list (elements carry a handful), and
740
+ * compares the same string #name reports for an Attr. */
741
+ static VALUE
742
+ mkr_node_attribute_by_qualified_name(VALUE self, VALUE rb_name)
743
+ {
744
+ lxb_dom_node_t *node = mkr_html_node_unwrap(self);
745
+ if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) {
746
+ return Qnil;
747
+ }
748
+
749
+ mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "attribute name");
750
+ VALUE out = Qnil;
751
+ lxb_dom_attr_t *attr =
752
+ lxb_dom_element_first_attribute(lxb_dom_interface_element(node));
753
+ while (attr != NULL) {
754
+ size_t len = 0;
755
+ const lxb_char_t *qname = lxb_dom_attr_qualified_name(attr, &len);
756
+ if (mkr_bytes_eq(qname, len, nv.ptr, nv.len)) {
757
+ out = mkr_wrap_html_node(lxb_dom_interface_node(attr),
758
+ mkr_node_document(self));
759
+ break;
760
+ }
761
+ attr = lxb_dom_element_next_attribute(attr);
762
+ }
763
+ RB_GC_GUARD(nv.value);
764
+ return out;
765
+ }
766
+
767
+ /* element.attribute_value_by_qualified_name(name) -> the value String of that
768
+ * attribute, or nil. The same match as #attribute_by_qualified_name, without
769
+ * wrapping an Attr node: this is the shape a DOM `getAttribute` / `hasAttribute`
770
+ * wants, and those run often enough for the wrapper to show up. An empty value
771
+ * answers "", which is how `hasAttribute` tells it from an absent attribute. */
772
+ static VALUE
773
+ mkr_node_attribute_value_by_qualified_name(VALUE self, VALUE rb_name)
774
+ {
775
+ lxb_dom_node_t *node = mkr_html_node_unwrap(self);
776
+ if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) {
777
+ return Qnil;
778
+ }
779
+
780
+ mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "attribute name");
781
+ VALUE out = Qnil;
782
+ lxb_dom_attr_t *attr =
783
+ lxb_dom_element_first_attribute(lxb_dom_interface_element(node));
784
+ while (attr != NULL) {
785
+ size_t len = 0;
786
+ const lxb_char_t *qname = lxb_dom_attr_qualified_name(attr, &len);
787
+ if (mkr_bytes_eq(qname, len, nv.ptr, nv.len)) {
788
+ size_t vlen = 0;
789
+ const lxb_char_t *val = lxb_dom_attr_value(attr, &vlen);
790
+ out = mkr_ruby_str_from_borrowed(
791
+ mkr_borrowed_text((const char *)val, vlen));
792
+ break;
793
+ }
794
+ attr = lxb_dom_element_next_attribute(attr);
795
+ }
796
+ RB_GC_GUARD(nv.value);
797
+ return out;
798
+ }
799
+
723
800
  /* attr.value -> the attribute's value String. For non-attribute nodes, falls
724
801
  * back to text content (matching the loose Nokogiri-ish meaning of #value). */
725
802
  static VALUE
@@ -862,6 +939,10 @@ mkr_init_node(void)
862
939
  rb_define_method(mkr_mHtmlNodeMethods, "keys", mkr_node_keys, 0);
863
940
  rb_define_method(mkr_mHtmlNodeMethods, "values", mkr_node_values, 0);
864
941
  rb_define_method(mkr_mHtmlNodeMethods, "attribute_nodes", mkr_node_attribute_nodes, 0);
942
+ rb_define_method(mkr_mHtmlNodeMethods, "attribute_by_qualified_name",
943
+ mkr_node_attribute_by_qualified_name, 1);
944
+ rb_define_method(mkr_mHtmlNodeMethods, "attribute_value_by_qualified_name",
945
+ mkr_node_attribute_value_by_qualified_name, 1);
865
946
  rb_define_method(mkr_mHtmlNodeMethods, "value", mkr_node_value, 0);
866
947
  rb_define_method(mkr_mHtmlNodeMethods, "line", mkr_node_line, 0);
867
948