From b047cb6dc166fca24325947e50a10be0c072f865 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 18 Oct 2025 07:49:15 +0800 Subject: [PATCH 001/257] remove libdom --- .github/actions/install/action.yml | 2 +- .github/workflows/zig-fmt.yml | 2 +- .gitignore | 7 +- .gitmodules | 18 - Dockerfile | 2 +- Makefile | 131 +- README.md | 2 +- build.zig | 160 +- flake.nix | 2 +- src/Scheduler.zig | 88 + src/TestHTTPServer.zig | 1 + src/app.zig | 152 +- src/browser/DataURI.zig | 52 - src/browser/EventManager.zig | 297 ++ src/browser/Factory.zig | 367 ++ src/browser/Mime.zig | 518 +++ src/browser/Renderer.zig | 109 + src/browser/Scheduler.zig | 166 +- src/browser/ScriptManager.zig | 264 +- src/browser/SlotChangeMonitor.zig | 189 - src/browser/State.zig | 77 - src/browser/URL.zig | 264 ++ src/browser/browser.zig | 154 +- src/browser/console/console.zig | 177 - src/browser/crypto/crypto.zig | 71 - src/browser/css/README.md | 218 -- src/browser/css/css.zig | 191 - src/browser/css/libdom.zig | 423 --- src/browser/css/parser.zig | 996 ------ src/browser/css/selector.zig | 1417 -------- src/browser/cssom/CSSParser.zig | 289 -- src/browser/cssom/CSSRule.zig | 41 - src/browser/cssom/CSSRuleList.zig | 51 - src/browser/cssom/CSSStyleDeclaration.zig | 958 ----- src/browser/cssom/CSSStyleSheet.zig | 95 - src/browser/cssom/StyleSheet.zig | 55 - src/browser/cssom/cssom.zig | 25 - src/browser/dom/Animation.zig | 107 - src/browser/dom/IntersectionObserver.zig | 329 -- src/browser/dom/MessageChannel.zig | 288 -- src/browser/dom/attribute.zig | 75 - src/browser/dom/cdata_section.zig | 28 - src/browser/dom/character_data.zig | 134 - src/browser/dom/comment.zig | 45 - src/browser/dom/css.zig | 80 - src/browser/dom/document.zig | 321 -- src/browser/dom/document_fragment.zig | 96 - src/browser/dom/document_type.zig | 67 - src/browser/dom/dom.zig | 56 - src/browser/dom/dom_parser.zig | 41 - src/browser/dom/element.zig | 686 ---- src/browser/dom/event_target.zig | 168 - src/browser/dom/exceptions.zig | 224 -- src/browser/dom/html_collection.zig | 454 --- src/browser/dom/implementation.zig | 56 - src/browser/dom/mutation_observer.zig | 407 --- src/browser/dom/namednodemap.zig | 121 - src/browser/dom/node.zig | 637 ---- src/browser/dom/node_filter.zig | 83 - src/browser/dom/node_iterator.zig | 302 -- src/browser/dom/nodelist.zig | 188 - src/browser/dom/performance.zig | 206 -- src/browser/dom/performance_observer.zig | 58 - src/browser/dom/processing_instruction.zig | 92 - src/browser/dom/range.zig | 390 --- src/browser/dom/resize_observer.zig | 54 - src/browser/dom/shadow_root.zig | 101 - src/browser/dom/text.zig | 62 - src/browser/dom/token_list.zig | 174 - src/browser/dom/tree_walker.zig | 315 -- src/browser/dom/walker.zig | 102 - src/browser/dump.zig | 373 +- src/browser/encoding/TextDecoder.zig | 102 - src/browser/encoding/TextEncoder.zig | 48 - src/browser/encoding/encoding.zig | 22 - src/browser/events/custom_event.zig | 86 - src/browser/events/event.zig | 402 --- src/browser/events/keyboard_event.zig | 159 - src/browser/events/mouse_event.zig | 111 - src/browser/fetch/Headers.zig | 225 -- src/browser/fetch/Request.zig | 283 -- src/browser/fetch/Response.zig | 209 -- src/browser/fetch/fetch.zig | 243 -- src/browser/html/AbortController.zig | 143 - src/browser/html/DataSet.zig | 82 - src/browser/html/History.zig | 215 -- src/browser/html/document.zig | 322 -- src/browser/html/elements.zig | 1361 -------- src/browser/html/error_event.zig | 86 - src/browser/html/form.zig | 37 - src/browser/html/html.zig | 43 - src/browser/html/iframe.zig | 28 - src/browser/html/location.zig | 96 - src/browser/html/media_query_list.zig | 45 - src/browser/html/navigator.zig | 86 - src/browser/html/screen.zig | 103 - src/browser/html/select.zig | 204 -- src/browser/html/svg_elements.zig | 36 - src/browser/html/window.zig | 497 --- src/browser/iterator/iterator.zig | 226 -- src/browser/js/Caller.zig | 349 +- src/browser/js/Context.zig | 386 ++- src/browser/js/Env.zig | 466 +-- src/browser/js/ExecutionWorld.zig | 129 +- src/browser/js/Function.zig | 25 +- src/browser/js/Inspector.zig | 18 + src/browser/js/Object.zig | 38 +- src/browser/js/Platform.zig | 18 + src/browser/js/This.zig | 18 + src/browser/js/TryCatch.zig | 18 + src/browser/js/bridge.zig | 471 +++ src/browser/js/generate.zig | 231 -- src/browser/js/js.zig | 65 +- src/browser/js/types.zig | 183 - src/browser/key_value.zig | 284 -- src/browser/mimalloc.zig | 110 - src/browser/mime.zig | 519 --- src/browser/netsurf.zig | 3083 ----------------- src/browser/page.zig | 2192 ++++++------ src/browser/parser/Parser.zig | 243 ++ src/browser/parser/html5ever.zig | 134 + src/browser/polyfill/polyfill.zig | 2 +- src/browser/polyfill/webcomponents.zig | 2 +- src/browser/reflect.zig | 46 + src/browser/renderer.zig | 116 - src/browser/session.zig | 298 +- src/browser/storage/storage.zig | 238 -- src/browser/streams/ReadableStream.zig | 205 -- .../ReadableStreamDefaultController.zig | 79 - .../streams/ReadableStreamDefaultReader.zig | 79 - src/browser/streams/streams.zig | 24 - src/browser/tests/cdata/data.html | 10 + src/browser/tests/crypto.html | 58 + src/browser/tests/document/collections.html | 23 + .../tests/document/create_element.html | 13 + .../tests/document/create_element_ns.html | 32 + src/browser/tests/document/document.html | 41 + .../tests/document/get_element_by_id.html | 35 + .../document/get_elements_by_class_name.html | 98 + .../document/get_elements_by_tag_name.html | 155 + .../tests/document/query_selector.html | 271 ++ .../tests/document/query_selector_all.html | 378 ++ .../document/query_selector_attributes.html | 113 + .../document/query_selector_edge_cases.html | 202 ++ .../tests/document/query_selector_not.html | 119 + .../document_fragment/document_fragment.html | 102 + src/browser/tests/document_head_body.html | 9 + src/browser/tests/element/append.html | 29 + src/browser/tests/element/attributes.html | 85 + src/browser/tests/element/class_list.html | 334 ++ .../tests/element/css_style_properties.html | 133 + src/browser/tests/element/element.html | 54 + .../element/get_elements_by_class_name.html | 187 + .../element/get_elements_by_tag_name.html | 186 + src/browser/tests/element/html/anchor.html | 13 + src/browser/tests/element/html/button.html | 55 + src/browser/tests/element/html/input.html | 246 ++ .../tests/element/html/input_radio.html | 140 + src/browser/tests/element/html/option.html | 67 + .../tests/element/html/script/dynamic.html | 43 + .../tests/element/html/script/dynamic1.js | 1 + .../tests/element/html/script/dynamic2.js | 1 + src/browser/tests/element/html/select.html | 83 + src/browser/tests/element/html/textarea.html | 78 + src/browser/tests/element/inner.html | 131 + src/browser/tests/element/inner.js | 1 + src/browser/tests/element/query_selector.html | 65 + .../tests/element/query_selector_all.html | 188 + src/browser/tests/element/remove.html | 26 + src/browser/tests/element/styles.html | 129 + src/browser/tests/element/svg/svg.html | 28 + src/browser/tests/encoding/text_decoder.html | 64 + src/browser/tests/encoding/text_encoder.html | 10 + src/browser/tests/event/abort_controller.html | 213 ++ src/browser/tests/event/error.html | 60 + src/browser/tests/events.html | 283 ++ src/browser/tests/navigator.html | 29 + src/browser/tests/net/form_data.html | 252 ++ src/browser/tests/net/url_search_params.html | 354 ++ src/browser/tests/net/xhr.html | 10 + src/browser/tests/node/append_child.html | 30 + src/browser/tests/node/child_nodes.html | 88 + src/browser/tests/node/clone_node.html | 292 ++ .../tests/node/compare_document_position.html | 259 ++ src/browser/tests/node/insert_before.html | 42 + src/browser/tests/node/node.html | 191 + src/browser/tests/node/node_iterator.html | 473 +++ src/browser/tests/node/normalize.html | 30 + src/browser/tests/node/remove_child.html | 18 + src/browser/tests/node/replace_child.html | 40 + src/browser/tests/node/text_content.html | 35 + src/browser/tests/node/tree.html | 25 + src/browser/tests/node/tree_walker.html | 385 ++ src/browser/tests/page/load_event.html | 18 + src/browser/tests/page/meta.html | 12 + src/browser/tests/page/mod1.js | 2 + src/browser/tests/page/module.html | 159 + src/browser/tests/page/modules/base.js | 1 + src/browser/tests/page/modules/circular-a.js | 7 + src/browser/tests/page/modules/circular-b.js | 11 + .../tests/page/modules/dynamic-chain-a.js | 6 + .../tests/page/modules/dynamic-chain-b.js | 6 + .../tests/page/modules/dynamic-chain-c.js | 1 + .../tests/page/modules/dynamic-circular-x.js | 6 + .../tests/page/modules/dynamic-circular-y.js | 6 + src/browser/tests/page/modules/importer.js | 4 + .../page/modules/mixed-circular-dynamic.js | 7 + .../page/modules/mixed-circular-static.js | 6 + src/browser/tests/page/modules/re-exporter.js | 2 + src/browser/tests/page/modules/shared.js | 9 + .../tests/page/modules/syntax-error.js | 2 + src/browser/tests/page/modules/test-404.js | 2 + .../tests/page/modules/test-syntax-error.js | 2 + src/browser/tests/storage.html | 62 + src/browser/tests/testing.js | 201 ++ src/browser/tests/url.html | 316 ++ src/browser/tests/window/body_onload1.html | 17 + src/browser/tests/window/body_onload2.html | 15 + src/browser/tests/window/location.html | 7 + src/browser/tests/window/navigator.html | 70 + src/browser/tests/window/report_error.html | 187 + src/browser/tests/window/timers.html | 24 + src/browser/tests/window/window.html | 95 + src/browser/url/url.zig | 516 --- src/browser/webapi/AbortController.zig | 44 + src/browser/webapi/AbortSignal.zig | 101 + src/browser/webapi/CData.zig | 70 + src/browser/webapi/Console.zig | 53 + src/browser/webapi/Crypto.zig | 64 + src/browser/webapi/DOMException.zig | 71 + src/browser/webapi/DOMNodeIterator.zig | 169 + src/browser/webapi/DOMTreeWalker.zig | 263 ++ src/browser/webapi/Document.zig | 252 ++ src/browser/webapi/DocumentFragment.zig | 147 + src/browser/webapi/Element.zig | 714 ++++ src/browser/webapi/Event.zig | 131 + src/browser/webapi/EventTarget.zig | 80 + src/browser/webapi/Location.zig | 67 + src/browser/webapi/Navigator.zig | 108 + src/browser/webapi/Node.zig | 692 ++++ src/browser/webapi/NodeFilter.zig | 89 + src/browser/webapi/TreeWalker.zig | 123 + src/browser/webapi/URL.zig | 255 ++ src/browser/webapi/Window.zig | 275 ++ src/browser/webapi/cdata/Comment.zig | 17 + src/browser/webapi/cdata/Text.zig | 23 + src/browser/webapi/children.zig | 39 + src/browser/webapi/collections.zig | 16 + src/browser/webapi/collections/ChildNodes.zig | 116 + .../webapi/collections/DOMTokenList.zig | 216 ++ .../webapi/collections/HTMLCollection.zig | 98 + src/browser/webapi/collections/NodeList.zig | 82 + src/browser/webapi/collections/iterator.zig | 92 + src/browser/webapi/collections/node_live.zig | 225 ++ .../webapi/css/CSSStyleDeclaration.zig | 223 ++ src/browser/webapi/css/CSSStyleProperties.zig | 179 + src/browser/webapi/element/Attribute.zig | 467 +++ src/browser/webapi/element/Html.zig | 153 + src/browser/webapi/element/Svg.zig | 61 + src/browser/webapi/element/html/Anchor.zig | 40 + src/browser/webapi/element/html/BR.zig | 25 + src/browser/webapi/element/html/Body.zig | 40 + src/browser/webapi/element/html/Button.zig | 81 + src/browser/webapi/element/html/Custom.zig | 28 + src/browser/webapi/element/html/Div.zig | 24 + src/browser/webapi/element/html/Form.zig | 117 + src/browser/webapi/element/html/Generic.zig | 28 + src/browser/webapi/element/html/HR.zig | 24 + src/browser/webapi/element/html/Head.zig | 24 + src/browser/webapi/element/html/Heading.zig | 29 + src/browser/webapi/element/html/Html.zig | 24 + src/browser/webapi/element/html/Image.zig | 24 + src/browser/webapi/element/html/Input.zig | 259 ++ src/browser/webapi/element/html/LI.zig | 24 + src/browser/webapi/element/html/Link.zig | 24 + src/browser/webapi/element/html/Meta.zig | 28 + src/browser/webapi/element/html/OL.zig | 24 + src/browser/webapi/element/html/Option.zig | 116 + src/browser/webapi/element/html/Paragraph.zig | 24 + src/browser/webapi/element/html/Script.zig | 95 + src/browser/webapi/element/html/Select.zig | 143 + src/browser/webapi/element/html/Style.zig | 24 + src/browser/webapi/element/html/TextArea.zig | 110 + src/browser/webapi/element/html/Title.zig | 25 + src/browser/webapi/element/html/UL.zig | 24 + src/browser/webapi/element/html/Unknown.zig | 28 + src/browser/webapi/element/svg/Generic.zig | 29 + src/browser/webapi/element/svg/Rect.zig | 28 + src/browser/webapi/encoding/TextDecoder.zig | 100 + src/browser/webapi/encoding/TextEncoder.zig | 40 + src/browser/webapi/event/ErrorEvent.zig | 93 + src/browser/webapi/event/ProgressEvent.zig | 48 + src/browser/webapi/net/Fetch.zig | 22 + src/browser/webapi/net/Request.zig | 39 + src/browser/webapi/net/Response.zig | 53 + src/browser/webapi/net/URLSearchParams.zig | 346 ++ src/browser/webapi/net/XMLHttpRequest.zig | 335 ++ .../webapi/net/XMLHttpRequestEventTarget.zig | 167 + src/browser/webapi/selector/List.zig | 722 ++++ src/browser/webapi/selector/Parser.zig | 1154 ++++++ src/browser/webapi/selector/Selector.zig | 175 + src/browser/{ => webapi}/storage/cookie.zig | 10 +- src/browser/webapi/storage/storage.zig | 107 + src/browser/xhr/File.zig | 34 - src/browser/xhr/event_target.zig | 137 - src/browser/xhr/form_data.zig | 301 -- src/browser/xhr/progress_event.zig | 72 - src/browser/xhr/xhr.zig | 759 ---- src/browser/xmlserializer/xmlserializer.zig | 50 - src/datetime.zig | 41 +- src/html5ever/Cargo.lock | 478 +++ src/html5ever/Cargo.toml | 20 + src/html5ever/lib.rs | 260 ++ src/html5ever/sink.rs | 226 ++ src/html5ever/types.rs | 119 + src/http/Client.zig | 29 +- src/lightpanda.zig | 53 + src/log.zig | 27 +- src/main.zig | 281 +- src/notification.zig | 364 +- src/server.zig | 239 +- src/string.zig | 207 ++ src/telemetry/telemetry.zig | 4 +- src/test_runner.zig | 426 +-- src/testing.zig | 254 +- src/tests/browser.html | 6 - src/tests/crypto.html | 26 - src/tests/css.html | 6 - src/tests/cssom/css_rule_list.html | 8 - src/tests/cssom/css_style_declaration.html | 102 - src/tests/cssom/css_stylesheet.html | 16 - src/tests/dom/animation.html | 15 - src/tests/dom/attribute.html | 33 - src/tests/dom/character_data.html | 48 - src/tests/dom/comment.html | 9 - src/tests/dom/document.html | 190 - src/tests/dom/document_fragment.html | 34 - src/tests/dom/document_type.html | 13 - src/tests/dom/dom_parser.html | 7 - src/tests/dom/element.html | 341 -- src/tests/dom/event_target.html | 116 - src/tests/dom/exceptions.html | 40 - src/tests/dom/html_collection.html | 67 - src/tests/dom/implementation.html | 14 - src/tests/dom/intersection_observer.html | 163 - src/tests/dom/message_channel.html | 60 - src/tests/dom/mutation_observer.html | 76 - src/tests/dom/named_node_map.html | 19 - src/tests/dom/node.html | 245 -- src/tests/dom/node_filter.html | 219 -- src/tests/dom/node_iterator.html | 62 - src/tests/dom/node_list.html | 19 - src/tests/dom/node_owner.html | 34 - src/tests/dom/performance.html | 16 - src/tests/dom/performance_observer.html | 5 - src/tests/dom/processing_instruction.html | 22 - src/tests/dom/range.html | 41 - src/tests/dom/shadow_root.html | 49 - src/tests/dom/text.html | 19 - src/tests/dom/token_list.html | 64 - src/tests/encoding/decoder.html | 60 - src/tests/encoding/encoder.html | 14 - src/tests/events/custom.html | 25 - src/tests/events/event.html | 139 - src/tests/events/keyboard.html | 88 - src/tests/events/mouse.html | 34 - src/tests/fetch/fetch.html | 34 - src/tests/fetch/headers.html | 102 - src/tests/fetch/request.html | 22 - src/tests/fetch/response.html | 50 - src/tests/html/abort_controller.html | 41 - src/tests/html/dataset.html | 30 - src/tests/html/document.html | 85 - src/tests/html/element.html | 53 - src/tests/html/error_event.html | 25 - src/tests/html/history.html | 41 - src/tests/html/image.html | 32 - src/tests/html/input.html | 111 - src/tests/html/link.html | 60 - src/tests/html/location.html | 15 - src/tests/html/navigator.html | 8 - src/tests/html/screen.html | 21 - src/tests/html/script/dynamic_import.html | 32 - src/tests/html/script/import.html | 15 - src/tests/html/script/import.js | 2 - src/tests/html/script/import2.js | 2 - src/tests/html/script/importmap.html | 24 - src/tests/html/script/inline_defer.html | 28 - src/tests/html/script/inline_defer.js | 1 - src/tests/html/script/script.html | 21 - src/tests/html/select.html | 80 - src/tests/html/slot.html | 179 - src/tests/html/style.html | 8 - src/tests/html/svg.html | 38 - src/tests/html/template.html | 22 - src/tests/polyfill/webcomponents.html | 23 - src/tests/storage/local_storage.html | 29 - src/tests/streams/readable_stream.html | 134 - src/tests/testing.js | 223 -- src/tests/url/url.html | 83 - src/tests/url/url_search_params.html | 94 - src/tests/window/frames.html | 13 - src/tests/window/window.html | 151 - src/tests/xhr/file.html | 6 - src/tests/xhr/form_data.html | 130 - src/tests/xhr/progress_event.html | 17 - src/tests/xhr/xhr.html | 110 - src/tests/xmlserializer.html | 8 - src/url.zig | 555 --- vendor/mimalloc | 1 - vendor/netsurf/libdom | 1 - vendor/netsurf/libhubbub | 1 - vendor/netsurf/libparserutils | 1 - vendor/netsurf/libwapcaplet | 1 - vendor/netsurf/share/netsurf-buildsystem | 1 - 415 files changed, 26294 insertions(+), 33558 deletions(-) create mode 100644 src/Scheduler.zig delete mode 100644 src/browser/DataURI.zig create mode 100644 src/browser/EventManager.zig create mode 100644 src/browser/Factory.zig create mode 100644 src/browser/Mime.zig create mode 100644 src/browser/Renderer.zig delete mode 100644 src/browser/SlotChangeMonitor.zig delete mode 100644 src/browser/State.zig create mode 100644 src/browser/URL.zig delete mode 100644 src/browser/console/console.zig delete mode 100644 src/browser/crypto/crypto.zig delete mode 100644 src/browser/css/README.md delete mode 100644 src/browser/css/css.zig delete mode 100644 src/browser/css/libdom.zig delete mode 100644 src/browser/css/parser.zig delete mode 100644 src/browser/css/selector.zig delete mode 100644 src/browser/cssom/CSSParser.zig delete mode 100644 src/browser/cssom/CSSRule.zig delete mode 100644 src/browser/cssom/CSSRuleList.zig delete mode 100644 src/browser/cssom/CSSStyleDeclaration.zig delete mode 100644 src/browser/cssom/CSSStyleSheet.zig delete mode 100644 src/browser/cssom/StyleSheet.zig delete mode 100644 src/browser/cssom/cssom.zig delete mode 100644 src/browser/dom/Animation.zig delete mode 100644 src/browser/dom/IntersectionObserver.zig delete mode 100644 src/browser/dom/MessageChannel.zig delete mode 100644 src/browser/dom/attribute.zig delete mode 100644 src/browser/dom/cdata_section.zig delete mode 100644 src/browser/dom/character_data.zig delete mode 100644 src/browser/dom/comment.zig delete mode 100644 src/browser/dom/css.zig delete mode 100644 src/browser/dom/document.zig delete mode 100644 src/browser/dom/document_fragment.zig delete mode 100644 src/browser/dom/document_type.zig delete mode 100644 src/browser/dom/dom.zig delete mode 100644 src/browser/dom/dom_parser.zig delete mode 100644 src/browser/dom/element.zig delete mode 100644 src/browser/dom/event_target.zig delete mode 100644 src/browser/dom/exceptions.zig delete mode 100644 src/browser/dom/html_collection.zig delete mode 100644 src/browser/dom/implementation.zig delete mode 100644 src/browser/dom/mutation_observer.zig delete mode 100644 src/browser/dom/namednodemap.zig delete mode 100644 src/browser/dom/node.zig delete mode 100644 src/browser/dom/node_filter.zig delete mode 100644 src/browser/dom/node_iterator.zig delete mode 100644 src/browser/dom/nodelist.zig delete mode 100644 src/browser/dom/performance.zig delete mode 100644 src/browser/dom/performance_observer.zig delete mode 100644 src/browser/dom/processing_instruction.zig delete mode 100644 src/browser/dom/range.zig delete mode 100644 src/browser/dom/resize_observer.zig delete mode 100644 src/browser/dom/shadow_root.zig delete mode 100644 src/browser/dom/text.zig delete mode 100644 src/browser/dom/token_list.zig delete mode 100644 src/browser/dom/tree_walker.zig delete mode 100644 src/browser/dom/walker.zig delete mode 100644 src/browser/encoding/TextDecoder.zig delete mode 100644 src/browser/encoding/TextEncoder.zig delete mode 100644 src/browser/encoding/encoding.zig delete mode 100644 src/browser/events/custom_event.zig delete mode 100644 src/browser/events/event.zig delete mode 100644 src/browser/events/keyboard_event.zig delete mode 100644 src/browser/events/mouse_event.zig delete mode 100644 src/browser/fetch/Headers.zig delete mode 100644 src/browser/fetch/Request.zig delete mode 100644 src/browser/fetch/Response.zig delete mode 100644 src/browser/fetch/fetch.zig delete mode 100644 src/browser/html/AbortController.zig delete mode 100644 src/browser/html/DataSet.zig delete mode 100644 src/browser/html/History.zig delete mode 100644 src/browser/html/document.zig delete mode 100644 src/browser/html/elements.zig delete mode 100644 src/browser/html/error_event.zig delete mode 100644 src/browser/html/form.zig delete mode 100644 src/browser/html/html.zig delete mode 100644 src/browser/html/iframe.zig delete mode 100644 src/browser/html/location.zig delete mode 100644 src/browser/html/media_query_list.zig delete mode 100644 src/browser/html/navigator.zig delete mode 100644 src/browser/html/screen.zig delete mode 100644 src/browser/html/select.zig delete mode 100644 src/browser/html/svg_elements.zig delete mode 100644 src/browser/html/window.zig delete mode 100644 src/browser/iterator/iterator.zig create mode 100644 src/browser/js/bridge.zig delete mode 100644 src/browser/js/generate.zig delete mode 100644 src/browser/js/types.zig delete mode 100644 src/browser/key_value.zig delete mode 100644 src/browser/mimalloc.zig delete mode 100644 src/browser/mime.zig delete mode 100644 src/browser/netsurf.zig create mode 100644 src/browser/parser/Parser.zig create mode 100644 src/browser/parser/html5ever.zig create mode 100644 src/browser/reflect.zig delete mode 100644 src/browser/renderer.zig delete mode 100644 src/browser/storage/storage.zig delete mode 100644 src/browser/streams/ReadableStream.zig delete mode 100644 src/browser/streams/ReadableStreamDefaultController.zig delete mode 100644 src/browser/streams/ReadableStreamDefaultReader.zig delete mode 100644 src/browser/streams/streams.zig create mode 100644 src/browser/tests/cdata/data.html create mode 100644 src/browser/tests/crypto.html create mode 100644 src/browser/tests/document/collections.html create mode 100644 src/browser/tests/document/create_element.html create mode 100644 src/browser/tests/document/create_element_ns.html create mode 100644 src/browser/tests/document/document.html create mode 100644 src/browser/tests/document/get_element_by_id.html create mode 100644 src/browser/tests/document/get_elements_by_class_name.html create mode 100644 src/browser/tests/document/get_elements_by_tag_name.html create mode 100644 src/browser/tests/document/query_selector.html create mode 100644 src/browser/tests/document/query_selector_all.html create mode 100644 src/browser/tests/document/query_selector_attributes.html create mode 100644 src/browser/tests/document/query_selector_edge_cases.html create mode 100644 src/browser/tests/document/query_selector_not.html create mode 100644 src/browser/tests/document_fragment/document_fragment.html create mode 100644 src/browser/tests/document_head_body.html create mode 100644 src/browser/tests/element/append.html create mode 100644 src/browser/tests/element/attributes.html create mode 100644 src/browser/tests/element/class_list.html create mode 100644 src/browser/tests/element/css_style_properties.html create mode 100644 src/browser/tests/element/element.html create mode 100644 src/browser/tests/element/get_elements_by_class_name.html create mode 100644 src/browser/tests/element/get_elements_by_tag_name.html create mode 100644 src/browser/tests/element/html/anchor.html create mode 100644 src/browser/tests/element/html/button.html create mode 100644 src/browser/tests/element/html/input.html create mode 100644 src/browser/tests/element/html/input_radio.html create mode 100644 src/browser/tests/element/html/option.html create mode 100644 src/browser/tests/element/html/script/dynamic.html create mode 100644 src/browser/tests/element/html/script/dynamic1.js create mode 100644 src/browser/tests/element/html/script/dynamic2.js create mode 100644 src/browser/tests/element/html/select.html create mode 100644 src/browser/tests/element/html/textarea.html create mode 100644 src/browser/tests/element/inner.html create mode 100644 src/browser/tests/element/inner.js create mode 100644 src/browser/tests/element/query_selector.html create mode 100644 src/browser/tests/element/query_selector_all.html create mode 100644 src/browser/tests/element/remove.html create mode 100644 src/browser/tests/element/styles.html create mode 100644 src/browser/tests/element/svg/svg.html create mode 100644 src/browser/tests/encoding/text_decoder.html create mode 100644 src/browser/tests/encoding/text_encoder.html create mode 100644 src/browser/tests/event/abort_controller.html create mode 100644 src/browser/tests/event/error.html create mode 100644 src/browser/tests/events.html create mode 100644 src/browser/tests/navigator.html create mode 100644 src/browser/tests/net/form_data.html create mode 100644 src/browser/tests/net/url_search_params.html create mode 100644 src/browser/tests/net/xhr.html create mode 100644 src/browser/tests/node/append_child.html create mode 100644 src/browser/tests/node/child_nodes.html create mode 100644 src/browser/tests/node/clone_node.html create mode 100644 src/browser/tests/node/compare_document_position.html create mode 100644 src/browser/tests/node/insert_before.html create mode 100644 src/browser/tests/node/node.html create mode 100644 src/browser/tests/node/node_iterator.html create mode 100644 src/browser/tests/node/normalize.html create mode 100644 src/browser/tests/node/remove_child.html create mode 100644 src/browser/tests/node/replace_child.html create mode 100644 src/browser/tests/node/text_content.html create mode 100644 src/browser/tests/node/tree.html create mode 100644 src/browser/tests/node/tree_walker.html create mode 100644 src/browser/tests/page/load_event.html create mode 100644 src/browser/tests/page/meta.html create mode 100644 src/browser/tests/page/mod1.js create mode 100644 src/browser/tests/page/module.html create mode 100644 src/browser/tests/page/modules/base.js create mode 100644 src/browser/tests/page/modules/circular-a.js create mode 100644 src/browser/tests/page/modules/circular-b.js create mode 100644 src/browser/tests/page/modules/dynamic-chain-a.js create mode 100644 src/browser/tests/page/modules/dynamic-chain-b.js create mode 100644 src/browser/tests/page/modules/dynamic-chain-c.js create mode 100644 src/browser/tests/page/modules/dynamic-circular-x.js create mode 100644 src/browser/tests/page/modules/dynamic-circular-y.js create mode 100644 src/browser/tests/page/modules/importer.js create mode 100644 src/browser/tests/page/modules/mixed-circular-dynamic.js create mode 100644 src/browser/tests/page/modules/mixed-circular-static.js create mode 100644 src/browser/tests/page/modules/re-exporter.js create mode 100644 src/browser/tests/page/modules/shared.js create mode 100644 src/browser/tests/page/modules/syntax-error.js create mode 100644 src/browser/tests/page/modules/test-404.js create mode 100644 src/browser/tests/page/modules/test-syntax-error.js create mode 100644 src/browser/tests/storage.html create mode 100644 src/browser/tests/testing.js create mode 100644 src/browser/tests/url.html create mode 100644 src/browser/tests/window/body_onload1.html create mode 100644 src/browser/tests/window/body_onload2.html create mode 100644 src/browser/tests/window/location.html create mode 100644 src/browser/tests/window/navigator.html create mode 100644 src/browser/tests/window/report_error.html create mode 100644 src/browser/tests/window/timers.html create mode 100644 src/browser/tests/window/window.html delete mode 100644 src/browser/url/url.zig create mode 100644 src/browser/webapi/AbortController.zig create mode 100644 src/browser/webapi/AbortSignal.zig create mode 100644 src/browser/webapi/CData.zig create mode 100644 src/browser/webapi/Console.zig create mode 100644 src/browser/webapi/Crypto.zig create mode 100644 src/browser/webapi/DOMException.zig create mode 100644 src/browser/webapi/DOMNodeIterator.zig create mode 100644 src/browser/webapi/DOMTreeWalker.zig create mode 100644 src/browser/webapi/Document.zig create mode 100644 src/browser/webapi/DocumentFragment.zig create mode 100644 src/browser/webapi/Element.zig create mode 100644 src/browser/webapi/Event.zig create mode 100644 src/browser/webapi/EventTarget.zig create mode 100644 src/browser/webapi/Location.zig create mode 100644 src/browser/webapi/Navigator.zig create mode 100644 src/browser/webapi/Node.zig create mode 100644 src/browser/webapi/NodeFilter.zig create mode 100644 src/browser/webapi/TreeWalker.zig create mode 100644 src/browser/webapi/URL.zig create mode 100644 src/browser/webapi/Window.zig create mode 100644 src/browser/webapi/cdata/Comment.zig create mode 100644 src/browser/webapi/cdata/Text.zig create mode 100644 src/browser/webapi/children.zig create mode 100644 src/browser/webapi/collections.zig create mode 100644 src/browser/webapi/collections/ChildNodes.zig create mode 100644 src/browser/webapi/collections/DOMTokenList.zig create mode 100644 src/browser/webapi/collections/HTMLCollection.zig create mode 100644 src/browser/webapi/collections/NodeList.zig create mode 100644 src/browser/webapi/collections/iterator.zig create mode 100644 src/browser/webapi/collections/node_live.zig create mode 100644 src/browser/webapi/css/CSSStyleDeclaration.zig create mode 100644 src/browser/webapi/css/CSSStyleProperties.zig create mode 100644 src/browser/webapi/element/Attribute.zig create mode 100644 src/browser/webapi/element/Html.zig create mode 100644 src/browser/webapi/element/Svg.zig create mode 100644 src/browser/webapi/element/html/Anchor.zig create mode 100644 src/browser/webapi/element/html/BR.zig create mode 100644 src/browser/webapi/element/html/Body.zig create mode 100644 src/browser/webapi/element/html/Button.zig create mode 100644 src/browser/webapi/element/html/Custom.zig create mode 100644 src/browser/webapi/element/html/Div.zig create mode 100644 src/browser/webapi/element/html/Form.zig create mode 100644 src/browser/webapi/element/html/Generic.zig create mode 100644 src/browser/webapi/element/html/HR.zig create mode 100644 src/browser/webapi/element/html/Head.zig create mode 100644 src/browser/webapi/element/html/Heading.zig create mode 100644 src/browser/webapi/element/html/Html.zig create mode 100644 src/browser/webapi/element/html/Image.zig create mode 100644 src/browser/webapi/element/html/Input.zig create mode 100644 src/browser/webapi/element/html/LI.zig create mode 100644 src/browser/webapi/element/html/Link.zig create mode 100644 src/browser/webapi/element/html/Meta.zig create mode 100644 src/browser/webapi/element/html/OL.zig create mode 100644 src/browser/webapi/element/html/Option.zig create mode 100644 src/browser/webapi/element/html/Paragraph.zig create mode 100644 src/browser/webapi/element/html/Script.zig create mode 100644 src/browser/webapi/element/html/Select.zig create mode 100644 src/browser/webapi/element/html/Style.zig create mode 100644 src/browser/webapi/element/html/TextArea.zig create mode 100644 src/browser/webapi/element/html/Title.zig create mode 100644 src/browser/webapi/element/html/UL.zig create mode 100644 src/browser/webapi/element/html/Unknown.zig create mode 100644 src/browser/webapi/element/svg/Generic.zig create mode 100644 src/browser/webapi/element/svg/Rect.zig create mode 100644 src/browser/webapi/encoding/TextDecoder.zig create mode 100644 src/browser/webapi/encoding/TextEncoder.zig create mode 100644 src/browser/webapi/event/ErrorEvent.zig create mode 100644 src/browser/webapi/event/ProgressEvent.zig create mode 100644 src/browser/webapi/net/Fetch.zig create mode 100644 src/browser/webapi/net/Request.zig create mode 100644 src/browser/webapi/net/Response.zig create mode 100644 src/browser/webapi/net/URLSearchParams.zig create mode 100644 src/browser/webapi/net/XMLHttpRequest.zig create mode 100644 src/browser/webapi/net/XMLHttpRequestEventTarget.zig create mode 100644 src/browser/webapi/selector/List.zig create mode 100644 src/browser/webapi/selector/Parser.zig create mode 100644 src/browser/webapi/selector/Selector.zig rename src/browser/{ => webapi}/storage/cookie.zig (99%) create mode 100644 src/browser/webapi/storage/storage.zig delete mode 100644 src/browser/xhr/File.zig delete mode 100644 src/browser/xhr/event_target.zig delete mode 100644 src/browser/xhr/form_data.zig delete mode 100644 src/browser/xhr/progress_event.zig delete mode 100644 src/browser/xhr/xhr.zig delete mode 100644 src/browser/xmlserializer/xmlserializer.zig create mode 100644 src/html5ever/Cargo.lock create mode 100644 src/html5ever/Cargo.toml create mode 100644 src/html5ever/lib.rs create mode 100644 src/html5ever/sink.rs create mode 100644 src/html5ever/types.rs create mode 100644 src/lightpanda.zig create mode 100644 src/string.zig delete mode 100644 src/tests/browser.html delete mode 100644 src/tests/crypto.html delete mode 100644 src/tests/css.html delete mode 100644 src/tests/cssom/css_rule_list.html delete mode 100644 src/tests/cssom/css_style_declaration.html delete mode 100644 src/tests/cssom/css_stylesheet.html delete mode 100644 src/tests/dom/animation.html delete mode 100644 src/tests/dom/attribute.html delete mode 100644 src/tests/dom/character_data.html delete mode 100644 src/tests/dom/comment.html delete mode 100644 src/tests/dom/document.html delete mode 100644 src/tests/dom/document_fragment.html delete mode 100644 src/tests/dom/document_type.html delete mode 100644 src/tests/dom/dom_parser.html delete mode 100644 src/tests/dom/element.html delete mode 100644 src/tests/dom/event_target.html delete mode 100644 src/tests/dom/exceptions.html delete mode 100644 src/tests/dom/html_collection.html delete mode 100644 src/tests/dom/implementation.html delete mode 100644 src/tests/dom/intersection_observer.html delete mode 100644 src/tests/dom/message_channel.html delete mode 100644 src/tests/dom/mutation_observer.html delete mode 100644 src/tests/dom/named_node_map.html delete mode 100644 src/tests/dom/node.html delete mode 100644 src/tests/dom/node_filter.html delete mode 100644 src/tests/dom/node_iterator.html delete mode 100644 src/tests/dom/node_list.html delete mode 100644 src/tests/dom/node_owner.html delete mode 100644 src/tests/dom/performance.html delete mode 100644 src/tests/dom/performance_observer.html delete mode 100644 src/tests/dom/processing_instruction.html delete mode 100644 src/tests/dom/range.html delete mode 100644 src/tests/dom/shadow_root.html delete mode 100644 src/tests/dom/text.html delete mode 100644 src/tests/dom/token_list.html delete mode 100644 src/tests/encoding/decoder.html delete mode 100644 src/tests/encoding/encoder.html delete mode 100644 src/tests/events/custom.html delete mode 100644 src/tests/events/event.html delete mode 100644 src/tests/events/keyboard.html delete mode 100644 src/tests/events/mouse.html delete mode 100644 src/tests/fetch/fetch.html delete mode 100644 src/tests/fetch/headers.html delete mode 100644 src/tests/fetch/request.html delete mode 100644 src/tests/fetch/response.html delete mode 100644 src/tests/html/abort_controller.html delete mode 100644 src/tests/html/dataset.html delete mode 100644 src/tests/html/document.html delete mode 100644 src/tests/html/element.html delete mode 100644 src/tests/html/error_event.html delete mode 100644 src/tests/html/history.html delete mode 100644 src/tests/html/image.html delete mode 100644 src/tests/html/input.html delete mode 100644 src/tests/html/link.html delete mode 100644 src/tests/html/location.html delete mode 100644 src/tests/html/navigator.html delete mode 100644 src/tests/html/screen.html delete mode 100644 src/tests/html/script/dynamic_import.html delete mode 100644 src/tests/html/script/import.html delete mode 100644 src/tests/html/script/import.js delete mode 100644 src/tests/html/script/import2.js delete mode 100644 src/tests/html/script/importmap.html delete mode 100644 src/tests/html/script/inline_defer.html delete mode 100644 src/tests/html/script/inline_defer.js delete mode 100644 src/tests/html/script/script.html delete mode 100644 src/tests/html/select.html delete mode 100644 src/tests/html/slot.html delete mode 100644 src/tests/html/style.html delete mode 100644 src/tests/html/svg.html delete mode 100644 src/tests/html/template.html delete mode 100644 src/tests/polyfill/webcomponents.html delete mode 100644 src/tests/storage/local_storage.html delete mode 100644 src/tests/streams/readable_stream.html delete mode 100644 src/tests/testing.js delete mode 100644 src/tests/url/url.html delete mode 100644 src/tests/url/url_search_params.html delete mode 100644 src/tests/window/frames.html delete mode 100644 src/tests/window/window.html delete mode 100644 src/tests/xhr/file.html delete mode 100644 src/tests/xhr/form_data.html delete mode 100644 src/tests/xhr/progress_event.html delete mode 100644 src/tests/xhr/xhr.html delete mode 100644 src/tests/xmlserializer.html delete mode 100644 src/url.zig delete mode 160000 vendor/mimalloc delete mode 160000 vendor/netsurf/libdom delete mode 160000 vendor/netsurf/libhubbub delete mode 160000 vendor/netsurf/libparserutils delete mode 160000 vendor/netsurf/libwapcaplet delete mode 160000 vendor/netsurf/share/netsurf-buildsystem diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 17c027593..e9864c01d 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -5,7 +5,7 @@ inputs: zig: description: 'Zig version to install' required: false - default: '0.15.1' + default: '0.15.2' arch: description: 'CPU arch used to select the v8 lib' required: false diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml index 2a1fdd527..106e557a1 100644 --- a/.github/workflows/zig-fmt.yml +++ b/.github/workflows/zig-fmt.yml @@ -1,7 +1,7 @@ name: zig-fmt env: - ZIG_VERSION: 0.15.1 + ZIG_VERSION: 0.15.2 on: pull_request: diff --git a/.gitignore b/.gitignore index ad9ae7b45..9a7968b9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ -zig-cache /.zig-cache/ -zig-out -/vendor/netsurf/out -/vendor/libiconv/ +/zig-out/ lightpanda.id /v8/ +/build/ +src/html5ever/target/ diff --git a/.gitmodules b/.gitmodules index 717d079bb..3358b9a3e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,24 +1,6 @@ -[submodule "vendor/netsurf/libwapcaplet"] - path = vendor/netsurf/libwapcaplet - url = https://github.com/lightpanda-io/libwapcaplet.git/ -[submodule "vendor/netsurf/libparserutils"] - path = vendor/netsurf/libparserutils - url = https://github.com/lightpanda-io/libparserutils.git/ -[submodule "vendor/netsurf/libdom"] - path = vendor/netsurf/libdom - url = https://github.com/lightpanda-io/libdom.git/ -[submodule "vendor/netsurf/share/netsurf-buildsystem"] - path = vendor/netsurf/share/netsurf-buildsystem - url = https://github.com/lightpanda-io/netsurf-buildsystem.git -[submodule "vendor/netsurf/libhubbub"] - path = vendor/netsurf/libhubbub - url = https://github.com/lightpanda-io/libhubbub.git/ [submodule "tests/wpt"] path = tests/wpt url = https://github.com/lightpanda-io/wpt -[submodule "vendor/mimalloc"] - path = vendor/mimalloc - url = https://github.com/microsoft/mimalloc.git/ [submodule "vendor/nghttp2"] path = vendor/nghttp2 url = https://github.com/nghttp2/nghttp2.git diff --git a/Dockerfile b/Dockerfile index bcb613f7f..919a9a658 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM debian:stable ARG MINISIG=0.12 -ARG ZIG=0.15.1 +ARG ZIG=0.15.2 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 ARG ZIG_V8=v0.1.33 diff --git a/Makefile b/Makefile index b0ae69015..957705e2b 100644 --- a/Makefile +++ b/Makefile @@ -96,9 +96,16 @@ wpt-summary: @printf "\e[36mBuilding wpt...\e[0m\n" @$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;) -## Test +## Test - `grep` is used to filter out the huge compile command on build +ifeq ($(OS), macos) test: - @TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all + @script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' 2>&1 \ + | grep --line-buffered -v "^/.*zig test -freference-trace" +else +test: + @script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' /dev/null 2>&1 \ + | grep --line-buffered -v "^/.*zig test -freference-trace" +endif ## Run demo/runner end to end tests end2end: @@ -120,128 +127,24 @@ build-v8: # Install and build required dependencies commands # ------------ -.PHONY: install-submodule -.PHONY: install-libiconv -.PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev -.PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc -.PHONY: install-dev install +.PHONY: install-html5ever install-html5ever-dev +.PHONY: install install-dev ## Install and build dependencies for release -install: install-submodule install-libiconv install-netsurf install-mimalloc +install: install-submodule install-html5ever ## Install and build dependencies for dev -install-dev: install-submodule install-libiconv install-netsurf-dev install-mimalloc-dev - -install-netsurf-dev: _install-netsurf -install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG - -install-netsurf: _install-netsurf -install-netsurf: OPTCFLAGS := -DNDEBUG - -BC_NS := $(BC)vendor/netsurf/out/$(OS)-$(ARCH) -ICONV := $(BC)vendor/libiconv/out/$(OS)-$(ARCH) -# TODO: add Linux iconv path (I guess it depends on the distro) -# TODO: this way of linking libiconv is not ideal. We should have a more generic way -# and stick to a specif version. Maybe build from source. Anyway not now. -_install-netsurf: clean-netsurf - @printf "\e[36mInstalling NetSurf...\e[0m\n" && \ - ls $(ICONV)/lib/libiconv.a 1> /dev/null || (printf "\e[33mERROR: you need to execute 'make install-libiconv'\e[0m\n"; exit 1;) && \ - mkdir -p $(BC_NS) && \ - cp -R vendor/netsurf/share $(BC_NS) && \ - export PREFIX=$(BC_NS) && \ - export OPTLDFLAGS="-L$(ICONV)/lib" && \ - export OPTCFLAGS="$(OPTCFLAGS) -I$(ICONV)/include" && \ - printf "\e[33mInstalling libwapcaplet...\e[0m\n" && \ - cd vendor/netsurf/libwapcaplet && \ - BUILDDIR=$(BC_NS)/build/libwapcaplet make install && \ - cd ../libparserutils && \ - printf "\e[33mInstalling libparserutils...\e[0m\n" && \ - BUILDDIR=$(BC_NS)/build/libparserutils make install && \ - cd ../libhubbub && \ - printf "\e[33mInstalling libhubbub...\e[0m\n" && \ - BUILDDIR=$(BC_NS)/build/libhubbub make install && \ - rm src/treebuilder/autogenerated-element-type.c && \ - cd ../libdom && \ - printf "\e[33mInstalling libdom...\e[0m\n" && \ - BUILDDIR=$(BC_NS)/build/libdom make install && \ - printf "\e[33mRunning libdom example...\e[0m\n" && \ - cd examples && \ - $(ZIG) cc \ - -I$(ICONV)/include \ - -I$(BC_NS)/include \ - -L$(ICONV)/lib \ - -L$(BC_NS)/lib \ - -liconv \ - -ldom \ - -lhubbub \ - -lparserutils \ - -lwapcaplet \ - -o a.out \ - dom-structure-dump.c \ - $(ICONV)/lib/libiconv.a && \ - ./a.out > /dev/null && \ - rm a.out && \ - printf "\e[36mDone NetSurf $(OS)\e[0m\n" - -clean-netsurf: - @printf "\e[36mCleaning NetSurf build...\e[0m\n" && \ - rm -Rf $(BC_NS) - -test-netsurf: - @printf "\e[36mTesting NetSurf...\e[0m\n" && \ - export PREFIX=$(BC_NS) && \ - export LDFLAGS="-L$(ICONV)/lib -L$(BC_NS)/lib" && \ - export CFLAGS="-I$(ICONV)/include -I$(BC_NS)/include" && \ - cd vendor/netsurf/libdom && \ - BUILDDIR=$(BC_NS)/build/libdom make test - -download-libiconv: -ifeq ("$(wildcard vendor/libiconv/libiconv-1.17)","") - @mkdir -p vendor/libiconv - @cd vendor/libiconv && \ - curl -L https://github.com/lightpanda-io/libiconv/releases/download/1.17/libiconv-1.17.tar.gz | tar -xvzf - -endif +install-dev: install-submodule install-html5ever-dev -build-libiconv: clean-libiconv - @cd vendor/libiconv/libiconv-1.17 && \ - ./configure --prefix=$(ICONV) --enable-static && \ - make && make install +install-html5ever: + cd src/html5ever && cargo build --release --target-dir ../../build/html5ever/ -install-libiconv: download-libiconv build-libiconv - -clean-libiconv: -ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","") - @cd vendor/libiconv/libiconv-1.17 && \ - make clean -endif +install-html5ever-dev: + cd src/html5ever && cargo build --target-dir ../../build/html5ever/ data: cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig -.PHONY: _build_mimalloc - -MIMALLOC := $(BC)vendor/mimalloc/out/$(OS)-$(ARCH) -_build_mimalloc: clean-mimalloc - @mkdir -p $(MIMALLOC)/build && \ - cd $(MIMALLOC)/build && \ - cmake -DMI_BUILD_SHARED=OFF -DMI_BUILD_OBJECT=OFF -DMI_BUILD_TESTS=OFF -DMI_OVERRIDE=OFF $(OPTS) ../../.. && \ - make && \ - mkdir -p $(MIMALLOC)/lib - -install-mimalloc-dev: _build_mimalloc -install-mimalloc-dev: OPTS=-DCMAKE_BUILD_TYPE=Debug -install-mimalloc-dev: - @cd $(MIMALLOC) && \ - mv build/libmimalloc-debug.a lib/libmimalloc.a - -install-mimalloc: _build_mimalloc -install-mimalloc: - @cd $(MIMALLOC) && \ - mv build/libmimalloc.a lib/libmimalloc.a - -clean-mimalloc: - @rm -Rf $(MIMALLOC)/build - ## Init and update git submodule install-submodule: @git submodule init && \ diff --git a/README.md b/README.md index a1009e7f1..87c393a52 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ You can also follow the progress of our Javascript support in our dedicated [zig ### Prerequisites -Lightpanda is written with [Zig](https://ziglang.org/) `0.15.1`. You have to +Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to install it with the right version in order to build the project. Lightpanda also depends on diff --git a/build.zig b/build.zig index 3437dfad0..d7effb26b 100644 --- a/build.zig +++ b/build.zig @@ -23,7 +23,7 @@ const Build = std.Build; /// Do not rename this constant. It is scanned by some scripts to determine /// which zig version to install. -const recommended_zig_version = "0.15.1"; +const recommended_zig_version = "0.15.2"; pub fn build(b: *Build) !void { switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) { @@ -49,87 +49,93 @@ pub fn build(b: *Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - // We're still using llvm because the new x86 backend seems to crash - // with v8. This can be reproduced in zig-v8-fork. + const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer"); + const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers"); - const lightpanda_module = b.addModule("lightpanda", .{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .link_libcpp = true, - }); - try addDependencies(b, lightpanda_module, opts); + const lightpanda_module = blk: { + const mod = b.addModule("lightpanda", .{ + .root_source_file = b.path("src/lightpanda.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .link_libcpp = true, + .sanitize_c = enable_csan, + .sanitize_thread = enable_tsan, + }); + + try addDependencies(b, mod, opts); + + if (optimize == .ReleaseFast or optimize == .ReleaseSmall) { + mod.addLibraryPath(b.path("build/html5ever/release")); + } else { + mod.addLibraryPath(b.path("build/html5ever/debug")); + } + mod.linkSystemLibrary("litefetch_html5ever", .{}); + + break :blk mod; + }; { // browser - // ------- - - // compile and install const exe = b.addExecutable(.{ .name = "lightpanda", .use_llvm = true, - .root_module = lightpanda_module, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = enable_csan, + .sanitize_thread = enable_tsan, + .imports = &.{ + .{.name = "lightpanda", .module = lightpanda_module}, + }, + }), }); b.installArtifact(exe); - // run const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { run_cmd.addArgs(args); } - - // step const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); } { - // tests - // ---- - - // compile + // test const tests = b.addTest(.{ .root_module = lightpanda_module, - .use_llvm = true, .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, }); - const run_tests = b.addRunArtifact(tests); - if (b.args) |args| { - run_tests.addArgs(args); - } - - // step - const tests_step = b.step("test", "Run unit tests"); - tests_step.dependOn(&run_tests.step); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_tests.step); } { // wpt - // ----- - const wpt_module = b.createModule(.{ - .root_source_file = b.path("src/main_wpt.zig"), - .target = target, - .optimize = optimize, - }); - try addDependencies(b, wpt_module, opts); - - // compile and install - const wpt = b.addExecutable(.{ + const exe = b.addExecutable(.{ .name = "lightpanda-wpt", .use_llvm = true, - .root_module = wpt_module, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_wpt.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = enable_csan, + .sanitize_thread = enable_tsan, + .imports = &.{ + .{.name = "lightpanda", .module = lightpanda_module}, + }, + }), }); + b.installArtifact(exe); - // run - const wpt_cmd = b.addRunArtifact(wpt); + const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { - wpt_cmd.addArgs(args); + run_cmd.addArgs(args); } - // step - const wpt_step = b.step("wpt", "WPT tests"); - wpt_step.dependOn(&wpt_cmd.step); + const run_step = b.step("wpt", "Run WPT tests"); + run_step.dependOn(&run_cmd.step); } { @@ -152,7 +158,6 @@ pub fn build(b: *Build) !void { } fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !void { - try moduleNetSurf(b, mod); mod.addImport("build_config", opts.createModule()); const target = mod.resolved_target.?; @@ -397,63 +402,6 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo } } -fn moduleNetSurf(b: *Build, mod: *Build.Module) !void { - const target = mod.resolved_target.?; - const os = target.result.os.tag; - const arch = target.result.cpu.arch; - - // iconv - const libiconv_lib_path = try std.fmt.allocPrint( - b.allocator, - "vendor/libiconv/out/{s}-{s}/lib/libiconv.a", - .{ @tagName(os), @tagName(arch) }, - ); - const libiconv_include_path = try std.fmt.allocPrint( - b.allocator, - "vendor/libiconv/out/{s}-{s}/lib/libiconv.a", - .{ @tagName(os), @tagName(arch) }, - ); - mod.addObjectFile(b.path(libiconv_lib_path)); - mod.addIncludePath(b.path(libiconv_include_path)); - - { - // mimalloc - const mimalloc = "vendor/mimalloc"; - const lib_path = try std.fmt.allocPrint( - b.allocator, - mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a", - .{ @tagName(os), @tagName(arch) }, - ); - mod.addObjectFile(b.path(lib_path)); - mod.addIncludePath(b.path(mimalloc ++ "/include")); - } - - // netsurf libs - const ns = "vendor/netsurf"; - const ns_include_path = try std.fmt.allocPrint( - b.allocator, - ns ++ "/out/{s}-{s}/include", - .{ @tagName(os), @tagName(arch) }, - ); - mod.addIncludePath(b.path(ns_include_path)); - - const libs: [4][]const u8 = .{ - "libdom", - "libhubbub", - "libparserutils", - "libwapcaplet", - }; - inline for (libs) |lib| { - const ns_lib_path = try std.fmt.allocPrint( - b.allocator, - ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a", - .{ @tagName(os), @tagName(arch) }, - ); - mod.addObjectFile(b.path(ns_lib_path)); - mod.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src")); - } -} - fn buildZlib(b: *Build, m: *Build.Module) !void { const zlib = b.addLibrary(.{ .name = "zlib", diff --git a/flake.nix b/flake.nix index 971f0f44c..fd5fbef87 100644 --- a/flake.nix +++ b/flake.nix @@ -49,7 +49,7 @@ targetPkgs = pkgs: with pkgs; [ # Build Tools - zigpkgs."0.15.1" + zigpkgs."0.15.2" zls python3 pkg-config diff --git a/src/Scheduler.zig b/src/Scheduler.zig new file mode 100644 index 000000000..0898d19b3 --- /dev/null +++ b/src/Scheduler.zig @@ -0,0 +1,88 @@ +const std = @import("std"); +const log = @import("log.zig"); + +const timestamp = @import("datetime.zig").milliTimestamp; + +const Queue = std.PriorityQueue(Task, void, struct { + fn compare(_: void, a: Task, b: Task) std.math.Order { + return std.math.order(a.run_at, b.run_at); + } +}.compare); + +const Scheduler = @This(); + +low_priority: Queue, +high_priority: Queue, + +pub fn init(allocator: std.mem.Allocator) Scheduler { + return .{ + .low_priority = Queue.init(allocator, {}), + .high_priority = Queue.init(allocator, {}), + }; +} + +pub fn reset(self: *Scheduler) void { + self.low_priority.cap = 0; + self.low_priority.items.len = 0; + + self.high_priority.cap = 0; + self.high_priority.items.len = 0; +} + +const AddOpts = struct { + name: []const u8 = "", + low_priority: bool = false, +}; +pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts: AddOpts) !void { + log.debug(.scheduler, "scheduler.add", .{ .name = opts.name, .run_in_ms = run_in_ms, .low_priority = opts.low_priority }); + var queue = if (opts.low_priority) &self.low_priority else &self.high_priority; + return queue.add(.{ + .ctx = ctx, + .callback = cb, + .name = opts.name, + .run_at = timestamp(.monotonic) + run_in_ms, + }); +} + +pub fn run(self: *Scheduler) !?u64 { + _ = try self.runQueue(&self.low_priority); + return self.runQueue(&self.high_priority); +} + +fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { + if (queue.count() == 0) { + return null; + } + + const now = timestamp(.monotonic); + + while (queue.peek()) |*task_| { + if (task_.run_at > now) { + return @intCast(task_.run_at - now); + } + var task = queue.remove(); + log.debug(.scheduler, "scheduler.runTask", .{ .name = task.name }); + + const repeat_in_ms = task.callback(task.ctx) catch |err| { + log.warn(.scheduler, "task.callback", .{ .name = task.name, .err = err }); + continue; + }; + + if (repeat_in_ms) |ms| { + // Task cannot be repeated immediately, and they should know that + std.debug.assert(ms != 0); + task.run_at = now + ms; + try self.low_priority.add(task); + } + } + return null; +} + +const Task = struct { + run_at: u64, + ctx: *anyopaque, + name: []const u8, + callback: Callback, +}; + +const Callback = *const fn (ctx: *anyopaque) anyerror!?u32; diff --git a/src/TestHTTPServer.zig b/src/TestHTTPServer.zig index 9867600d0..fdc51b904 100644 --- a/src/TestHTTPServer.zig +++ b/src/TestHTTPServer.zig @@ -61,6 +61,7 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi return err; }, }; + self.handler(&req) catch |err| { std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); try req.respond("server error", .{ .status = .internal_server_error }); diff --git a/src/app.zig b/src/app.zig index 719dd9b72..ef94486b1 100644 --- a/src/app.zig +++ b/src/app.zig @@ -6,93 +6,87 @@ const log = @import("log.zig"); const Http = @import("http/Http.zig"); const Platform = @import("browser/js/Platform.zig"); +const Notification = @import("Notification.zig"); const Telemetry = @import("telemetry/telemetry.zig").Telemetry; -const Notification = @import("notification.zig").Notification; // Container for global state / objects that various parts of the system // might need. -pub const App = struct { - http: Http, - config: Config, - platform: Platform, - allocator: Allocator, - telemetry: Telemetry, - app_dir_path: ?[]const u8, - notification: *Notification, - - pub const RunMode = enum { - help, - fetch, - serve, - version, - }; +const App = @This(); + +http: Http, +config: Config, +platform: Platform, +telemetry: Telemetry, +allocator: Allocator, +app_dir_path: ?[]const u8, +notification: *Notification, + +pub const RunMode = enum { + help, + fetch, + serve, + version, +}; - pub const Config = struct { - run_mode: RunMode, - tls_verify_host: bool = true, - http_proxy: ?[:0]const u8 = null, - proxy_bearer_token: ?[:0]const u8 = null, - http_timeout_ms: ?u31 = null, - http_connect_timeout_ms: ?u31 = null, - http_max_host_open: ?u8 = null, - http_max_concurrent: ?u8 = null, - user_agent: [:0]const u8, - }; +pub const Config = struct { + run_mode: RunMode, + tls_verify_host: bool = true, + http_proxy: ?[:0]const u8 = null, + proxy_bearer_token: ?[:0]const u8 = null, + http_timeout_ms: ?u31 = null, + http_connect_timeout_ms: ?u31 = null, + http_max_host_open: ?u8 = null, + http_max_concurrent: ?u8 = null, + user_agent: [:0]const u8, +}; - pub fn init(allocator: Allocator, config: Config) !*App { - const app = try allocator.create(App); - errdefer allocator.destroy(app); - - const notification = try Notification.init(allocator, null); - errdefer notification.deinit(); - - var http = try Http.init(allocator, .{ - .max_host_open = config.http_max_host_open orelse 4, - .max_concurrent = config.http_max_concurrent orelse 10, - .timeout_ms = config.http_timeout_ms orelse 5000, - .connect_timeout_ms = config.http_connect_timeout_ms orelse 0, - .http_proxy = config.http_proxy, - .tls_verify_host = config.tls_verify_host, - .proxy_bearer_token = config.proxy_bearer_token, - .user_agent = config.user_agent, - }); - errdefer http.deinit(); - - const platform = try Platform.init(); - errdefer platform.deinit(); - - const app_dir_path = getAndMakeAppDir(allocator); - - app.* = .{ - .http = http, - .allocator = allocator, - .telemetry = undefined, - .platform = platform, - .app_dir_path = app_dir_path, - .notification = notification, - .config = config, - }; - - app.telemetry = try Telemetry.init(app, config.run_mode); - errdefer app.telemetry.deinit(); - - try app.telemetry.register(app.notification); - - return app; - } +pub fn init(allocator: Allocator, config: Config) !*App { + const app = try allocator.create(App); + errdefer allocator.destroy(app); - pub fn deinit(self: *App) void { - const allocator = self.allocator; - if (self.app_dir_path) |app_dir_path| { - allocator.free(app_dir_path); - } - self.telemetry.deinit(); - self.notification.deinit(); - self.http.deinit(); - self.platform.deinit(); - allocator.destroy(self); + app.config = config; + app.allocator = allocator; + + app.notification = try Notification.init(allocator, null); + errdefer app.notification.deinit(); + + app.http = try Http.init(allocator, .{ + .max_host_open = config.http_max_host_open orelse 4, + .max_concurrent = config.http_max_concurrent orelse 10, + .timeout_ms = config.http_timeout_ms orelse 5000, + .connect_timeout_ms = config.http_connect_timeout_ms orelse 0, + .http_proxy = config.http_proxy, + .tls_verify_host = config.tls_verify_host, + .proxy_bearer_token = config.proxy_bearer_token, + .user_agent = config.user_agent, + }); + errdefer app.http.deinit(); + + app.platform = try Platform.init(); + errdefer app.platform.deinit(); + + app.app_dir_path = getAndMakeAppDir(allocator); + + app.telemetry = try Telemetry.init(app, config.run_mode); + errdefer app.telemetry.deinit(); + + try app.telemetry.register(app.notification); + + return app; +} + +pub fn deinit(self: *App) void { + const allocator = self.allocator; + if (self.app_dir_path) |app_dir_path| { + allocator.free(app_dir_path); } -}; + self.telemetry.deinit(); + self.notification.deinit(); + self.http.deinit(); + self.platform.deinit(); + + allocator.destroy(self); +} fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 { if (@import("builtin").is_test) { diff --git a/src/browser/DataURI.zig b/src/browser/DataURI.zig deleted file mode 100644 index 00d3792f1..000000000 --- a/src/browser/DataURI.zig +++ /dev/null @@ -1,52 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -// Parses data:[][;base64], -pub fn parse(allocator: Allocator, src: []const u8) !?[]const u8 { - if (!std.mem.startsWith(u8, src, "data:")) { - return null; - } - - const uri = src[5..]; - const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null; - - var data = uri[data_starts + 1 ..]; - - // Extract the encoding. - const metadata = uri[0..data_starts]; - if (std.mem.endsWith(u8, metadata, ";base64")) { - const decoder = std.base64.standard.Decoder; - const decoded_size = try decoder.calcSizeForSlice(data); - - const buffer = try allocator.alloc(u8, decoded_size); - errdefer allocator.free(buffer); - - try decoder.decode(buffer, data); - data = buffer; - } - - return data; -} - -const testing = @import("../testing.zig"); -test "DataURI: parse valid" { - try test_valid("data:text/javascript; charset=utf-8;base64,Zm9v", "foo"); - try test_valid("data:text/javascript; charset=utf-8;,foo", "foo"); - try test_valid("data:,foo", "foo"); -} - -test "DataURI: parse invalid" { - try test_cannot_parse("atad:,foo"); - try test_cannot_parse("data:foo"); - try test_cannot_parse("data:"); -} - -fn test_valid(uri: []const u8, expected: []const u8) !void { - defer testing.reset(); - const data_uri = try parse(testing.arena_allocator, uri) orelse return error.TestFailed; - try testing.expectEqual(expected, data_uri); -} - -fn test_cannot_parse(uri: []const u8) !void { - try testing.expectEqual(null, parse(undefined, uri)); -} diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig new file mode 100644 index 000000000..89cba8019 --- /dev/null +++ b/src/browser/EventManager.zig @@ -0,0 +1,297 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const log = @import("../log.zig"); +const String = @import("../string.zig").String; + +const js = @import("js/js.zig"); +const Page = @import("Page.zig"); + +const Node = @import("webapi/Node.zig"); +const Event = @import("webapi/Event.zig"); +const EventTarget = @import("webapi/EventTarget.zig"); + +const Allocator = std.mem.Allocator; + +const IS_DEBUG = builtin.mode == .Debug; + +pub const EventManager = @This(); + +page: *Page, +arena: Allocator, +listener_pool: std.heap.MemoryPool(Listener), +lookup: std.AutoHashMapUnmanaged(usize, std.DoublyLinkedList), + +pub fn init(page: *Page) EventManager { + return .{ + .page = page, + .lookup = .{}, + .arena = page.arena, + .listener_pool = std.heap.MemoryPool(Listener).init(page.arena), + }; +} + +pub const RegisterOptions = struct { + once: bool = false, + capture: bool = false, + passive: bool = false, + signal: ?*@import("webapi/AbortSignal.zig") = null, +}; +pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, opts: RegisterOptions) !void { + if (comptime IS_DEBUG) { + log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once }); + } + + // If a signal is provided and already aborted, don't register the listener + if (opts.signal) |signal| { + if (signal.getAborted()) { + return; + } + } + + const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target)); + if (gop.found_existing) { + // check for duplicate functions already registered + var node = gop.value_ptr.first; + while (node) |n| { + const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); + if (listener.function.eql(function) and listener.capture == opts.capture) { + return; + } + node = n.next; + } + } else { + gop.value_ptr.* = .{}; + } + + const listener = try self.listener_pool.create(); + listener.* = .{ + .node = .{}, + .once = opts.once, + .capture = opts.capture, + .passive = opts.passive, + .function = .{ .value = function }, + .signal = opts.signal, + .typ = try String.init(self.arena, typ, .{}), + }; + // append the listener to the list of listeners for this target + gop.value_ptr.append(&listener.node); +} + +pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, use_capture: bool) void { + const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; + if (findListener(list, typ, function, use_capture)) |listener| { + self.removeListener(list, listener); + } +} + +pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void { + if (comptime IS_DEBUG) { + log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); + } + event._target = target; + switch (target._type) { + .node => |node| try self.dispatchNode(node, event), + .xhr, .window, .abort_signal => { + const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; + try self.dispatchAll(list, target, event); + }, + } +} + +// There are a lot of events that can be attached via addEventListener or as +// a property, like the XHR events, or window.onload. You might think that the +// property is just a shortcut for calling addEventListener, but they are distinct. +// An event set via property cannot be removed by removeEventListener. If you +// set both the property and add a listener, they both execute. +const DispatchWithFunctionOptions = struct { + context: []const u8, + inject_target: bool = true, +}; +pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void { + if (comptime IS_DEBUG) { + log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null }); + } + + if (comptime opts.inject_target) { + event._target = target; + } + + if (function_) |func| { + event._current_target = target; + func.call(void, .{event}) catch |err| { + // a non-JS error + log.warn(.event, opts.context, .{ .err = err }); + }; + } + + const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; + try self.dispatchAll(list, target, event); +} + +fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { + if (event._bubbles == false) { + event._event_phase = .at_target; + const target_et = target.asEventTarget(); + if (self.lookup.getPtr(@intFromPtr(target_et))) |list| { + try self.dispatchPhase(list, target_et, event, null); + } + event._event_phase = .none; + return; + } + + var path_len: usize = 0; + var path_buffer: [128]*EventTarget = undefined; + + var node: ?*Node = target; + while (node) |n| : (node = n._parent) { + if (path_len >= path_buffer.len) break; + path_buffer[path_len] = n.asEventTarget(); + path_len += 1; + } + + // Even though the window isn't part of the DOM, events bubble to it + if (path_len < path_buffer.len) { + path_buffer[path_len] = self.page.window.asEventTarget(); + path_len += 1; + } + + const path = path_buffer[0..path_len]; + + // Phase 1: Capturing phase (root → target, excluding target) + event._event_phase = .capturing_phase; + var i: usize = path_len; + while (i > 1) { + i -= 1; + const current_target = path[i]; + if (self.lookup.getPtr(@intFromPtr(current_target))) |list| { + try self.dispatchPhase(list, current_target, event, true); + if (event._stop_propagation) { + event._event_phase = .none; + return; + } + } + } + + event._event_phase = .at_target; + const target_et = target.asEventTarget(); + if (self.lookup.getPtr(@intFromPtr(target_et))) |list| { + try self.dispatchPhase(list, target_et, event, null); + if (event._stop_propagation) { + event._event_phase = .none; + return; + } + } + + event._event_phase = .bubbling_phase; + for (path[1..]) |current_target| { + if (self.lookup.getPtr(@intFromPtr(current_target))) |list| { + try self.dispatchPhase(list, current_target, event, false); + if (event._stop_propagation) { + break; + } + } + } + + event._event_phase = .none; +} + +fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, comptime capture_only: ?bool) !void { + const page = self.page; + const typ = event._type_string; + + var node = list.first; + while (node) |n| { + // do this now, in case we need to remove n (once: true or aborted signal) + node = n.next; + + const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); + if (!listener.typ.eql(typ)) { + continue; + } + + // Can be null when dispatching to the target itself + if (comptime capture_only) |capture| { + if (listener.capture != capture) { + continue; + } + } + + // If the listener has an aborted signal, remove it and skip + if (listener.signal) |signal| { + if (signal.getAborted()) { + self.removeListener(list, listener); + continue; + } + } + + event._current_target = current_target; + + switch (listener.function) { + .value => |value| try value.call(void, .{event}), + .string => |string| { + const str = try page.call_arena.dupeZ(u8, string.str()); + try self.page.js.eval(str, null); + }, + } + + if (listener.once) { + self.removeListener(list, listener); + } + + if (event._stop_immediate_propagation) { + return; + } + } +} + +// Non-Node dispatching (XHR, Window without propagation) +fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event) !void { + return self.dispatchPhase(list, current_target, event, null); +} + +fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { + list.remove(&listener.node); + self.listener_pool.destroy(listener); +} + +fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, function: js.Function, capture: bool) ?*Listener { + var node = list.first; + while (node) |n| { + node = n.next; + const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); + if (!listener.function.eql(function)) { + continue; + } + if (listener.capture != capture) { + continue; + } + if (!listener.typ.eqlSlice(typ)) { + continue; + } + return listener; + } + return null; +} + +const Listener = struct { + typ: String, + once: bool, + capture: bool, + passive: bool, + function: Function, + signal: ?*@import("webapi/AbortSignal.zig") = null, + node: std.DoublyLinkedList.Node, +}; + +const Function = union(enum) { + value: js.Function, + string: String, + + fn eql(self: Function, func: js.Function) bool { + return switch (self) { + .string => false, + .value => |v| return v.id == func.id, + }; + } +}; diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig new file mode 100644 index 000000000..bd04da757 --- /dev/null +++ b/src/browser/Factory.zig @@ -0,0 +1,367 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const reflect = @import("reflect.zig"); +const IS_DEBUG = builtin.mode == .Debug; + +const log = @import("../log.zig"); +const String = @import("../string.zig").String; + +const Page = @import("Page.zig"); +const Node = @import("webapi/Node.zig"); +const Event = @import("webapi/Event.zig"); +const Element = @import("webapi/Element.zig"); +const EventTarget = @import("webapi/EventTarget.zig"); +const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); + +const MemoryPoolAligned = std.heap.MemoryPoolAligned; + +// 1. Generally, wrapping an ArenaAllocator within an ArenaAllocator doesn't make +// much sense. But wrapping a MemoryPool within an Arena does. Specifically, by +// doing so, we solve a major issue with Arena: freed memory can be re-used [for +// more of the same size]. +// 2. Normally, you have a MemoryPool(T) where T is a `User` or something. Then +// the MemoryPool can be used for creating users. But in reality, that memory +// created by that pool could be re-used for anything with the same size (or less) +// than a User (and a compatible alignment). So that's what we do - we have size +// (and alignment) based pools. +const Factory = @This(); +_page: *Page, +_size_1_8: MemoryPoolAligned([1]u8, .@"8"), +_size_8_8: MemoryPoolAligned([8]u8, .@"8"), +_size_16_8: MemoryPoolAligned([16]u8, .@"8"), +_size_24_8: MemoryPoolAligned([24]u8, .@"8"), +_size_32_8: MemoryPoolAligned([32]u8, .@"8"), +_size_32_16: MemoryPoolAligned([32]u8, .@"16"), +_size_40_8: MemoryPoolAligned([40]u8, .@"8"), +_size_48_16: MemoryPoolAligned([48]u8, .@"16"), +_size_56_8: MemoryPoolAligned([56]u8, .@"8"), +_size_64_16: MemoryPoolAligned([64]u8, .@"16"), +_size_72_8: MemoryPoolAligned([72]u8, .@"8"), +_size_80_16: MemoryPoolAligned([80]u8, .@"16"), +_size_88_8: MemoryPoolAligned([88]u8, .@"8"), +_size_96_16: MemoryPoolAligned([96]u8, .@"16"), +_size_104_8: MemoryPoolAligned([104]u8, .@"8"), +_size_112_8: MemoryPoolAligned([112]u8, .@"8"), +_size_120_8: MemoryPoolAligned([120]u8, .@"8"), +_size_128_8: MemoryPoolAligned([128]u8, .@"8"), +_size_144_8: MemoryPoolAligned([144]u8, .@"8"), +_size_456_8: MemoryPoolAligned([456]u8, .@"8"), +_size_520_8: MemoryPoolAligned([520]u8, .@"8"), +_size_648_8: MemoryPoolAligned([648]u8, .@"8"), + +pub fn init(page: *Page) Factory { + return .{ + ._page = page, + ._size_1_8 = MemoryPoolAligned([1]u8, .@"8").init(page.arena), + ._size_8_8 = MemoryPoolAligned([8]u8, .@"8").init(page.arena), + ._size_16_8 = MemoryPoolAligned([16]u8, .@"8").init(page.arena), + ._size_24_8 = MemoryPoolAligned([24]u8, .@"8").init(page.arena), + ._size_32_8 = MemoryPoolAligned([32]u8, .@"8").init(page.arena), + ._size_32_16 = MemoryPoolAligned([32]u8, .@"16").init(page.arena), + ._size_40_8 = MemoryPoolAligned([40]u8, .@"8").init(page.arena), + ._size_48_16 = MemoryPoolAligned([48]u8, .@"16").init(page.arena), + ._size_56_8 = MemoryPoolAligned([56]u8, .@"8").init(page.arena), + ._size_64_16 = MemoryPoolAligned([64]u8, .@"16").init(page.arena), + ._size_72_8 = MemoryPoolAligned([72]u8, .@"8").init(page.arena), + ._size_80_16 = MemoryPoolAligned([80]u8, .@"16").init(page.arena), + ._size_88_8 = MemoryPoolAligned([88]u8, .@"8").init(page.arena), + ._size_96_16 = MemoryPoolAligned([96]u8, .@"16").init(page.arena), + ._size_104_8 = MemoryPoolAligned([104]u8, .@"8").init(page.arena), + ._size_112_8 = MemoryPoolAligned([112]u8, .@"8").init(page.arena), + ._size_120_8 = MemoryPoolAligned([120]u8, .@"8").init(page.arena), + ._size_128_8 = MemoryPoolAligned([128]u8, .@"8").init(page.arena), + ._size_144_8 = MemoryPoolAligned([144]u8, .@"8").init(page.arena), + ._size_456_8 = MemoryPoolAligned([456]u8, .@"8").init(page.arena), + ._size_520_8 = MemoryPoolAligned([520]u8, .@"8").init(page.arena), + ._size_648_8 = MemoryPoolAligned([648]u8, .@"8").init(page.arena), + }; +} + +// this is a root object +pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + + const et = try self.createT(EventTarget); + child_ptr._proto = et; + et.* = .{ ._type = unionInit(EventTarget.Type, child_ptr) }; + return child_ptr; +} + +pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.eventTarget(Node{ + ._proto = undefined, + ._type = unionInit(Node.Type, child_ptr), + }); + return child_ptr; +} + +pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.node(Element{ + ._proto = undefined, + ._type = unionInit(Element.Type, child_ptr), + }); + return child_ptr; +} + +pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) { + if (comptime fieldIsPointer(Element.Html.Type, @TypeOf(child))) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.element(Element.Html{ + ._proto = undefined, + ._type = unionInit(Element.Html.Type, child_ptr), + }); + return child_ptr; + } + + // Our union type fields are usually pointers. But, at the leaf, they + // can be struct (if all they contain is the `_proto` field, then we might + // as well store it directly in the struct). + + const html = try self.element(Element.Html{ + ._proto = undefined, + ._type = unionInit(Element.Html.Type, child), + }); + const field_name = comptime unionFieldName(Element.Html.Type, @TypeOf(child)); + var child_ptr = &@field(html._type, field_name); + child_ptr._proto = html; + return child_ptr; +} + +pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { + if (@TypeOf(child) == Element.Svg) { + return self.element(child); + } + + // will never allocate, can't fail + const tag_name_str = String.init(undefined, tag_name, .{}) catch unreachable; + + if (comptime fieldIsPointer(Element.Svg.Type, @TypeOf(child))) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.element(Element.Svg{ + ._proto = undefined, + ._tag_name = tag_name_str, + ._type = unionInit(Element.Svg.Type, child_ptr), + }); + return child_ptr; + } + + // Our union type fields are usually pointers. But, at the leaf, they + // can be struct (if all they contain is the `_proto` field, then we might + // as well store it directly in the struct). + const svg = try self.element(Element.Svg{ + ._proto = undefined, + ._tag_name = tag_name_str, + ._type = unionInit(Element.Svg.Type, child), + }); + const field_name = comptime unionFieldName(Element.Svg.Type, @TypeOf(child)); + var child_ptr = &@field(svg._type, field_name); + child_ptr._proto = svg; + return child_ptr; +} + +// this is a root object +pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + + const e = try self.createT(Event); + child_ptr._proto = e; + e.* = .{ + ._type = unionInit(Event.Type, child_ptr), + ._type_string = try String.init(self._page.arena, typ, .{}), + }; + return child_ptr; +} + +pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { + const et = try self.eventTarget(XMLHttpRequestEventTarget{ + ._proto = undefined, + ._type = unionInit(XMLHttpRequestEventTarget.Type, child), + }); + const field_name = comptime unionFieldName(XMLHttpRequestEventTarget.Type, @TypeOf(child)); + var child_ptr = &@field(et._type, field_name); + child_ptr._proto = et; + return child_ptr; +} + +pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) { + const ptr = try self.createT(@TypeOf(value)); + ptr.* = value; + return ptr; +} + +pub fn createT(self: *Factory, comptime T: type) !*T { + const SO = @sizeOf(T); + if (comptime SO == 1) return @ptrCast(try self._size_1_8.create()); + if (comptime SO == 8) return @ptrCast(try self._size_8_8.create()); + if (comptime SO == 16) return @ptrCast(try self._size_16_8.create()); + if (comptime SO == 24) return @ptrCast(try self._size_24_8.create()); + if (comptime SO == 32) { + if (comptime @alignOf(T) == 8) return @ptrCast(try self._size_32_8.create()); + if (comptime @alignOf(T) == 16) return @ptrCast(try self._size_32_16.create()); + } + if (comptime SO == 40) return @ptrCast(try self._size_40_8.create()); + if (comptime SO == 48) return @ptrCast(try self._size_48_16.create()); + if (comptime SO == 56) return @ptrCast(try self._size_56_8.create()); + if (comptime SO == 64) return @ptrCast(try self._size_64_16.create()); + if (comptime SO == 72) return @ptrCast(try self._size_72_8.create()); + if (comptime SO == 80) return @ptrCast(try self._size_80_16.create()); + if (comptime SO == 88) return @ptrCast(try self._size_88_8.create()); + if (comptime SO == 96) return @ptrCast(try self._size_96_16.create()); + if (comptime SO == 104) return @ptrCast(try self._size_104_8.create()); + if (comptime SO == 112) return @ptrCast(try self._size_112_8.create()); + if (comptime SO == 120) return @ptrCast(try self._size_120_8.create()); + if (comptime SO == 128) return @ptrCast(try self._size_128_8.create()); + if (comptime SO == 144) return @ptrCast(try self._size_144_8.create()); + if (comptime SO == 456) return @ptrCast(try self._size_456_8.create()); + if (comptime SO == 520) return @ptrCast(try self._size_520_8.create()); + if (comptime SO == 648) return @ptrCast(try self._size_648_8.create()); + @compileError(std.fmt.comptimePrint("No pool configured for @sizeOf({d}), @alignOf({d}): ({s})", .{ SO, @alignOf(T), @typeName(T) })); +} + +pub fn destroy(self: *Factory, value: anytype) void { + const S = reflect.Struct(@TypeOf(value)); + if (comptime IS_DEBUG) { + // We should always destroy from the leaf down. + if (@hasField(S, "_type") and @typeInfo(@TypeOf(value._type)) == .@"union") { + // A Event{._type == .generic} (or any other similar types) + // _should_ be destoyed directly. The _type = .generic is a pseudo + // child + if (S != Event or value._type != .generic) { + log.fatal(.bug, "factory.destroy.event", .{ .type = @typeName(S) }); + unreachable; + } + } + } + + self.destroyChain(value, true); +} + +fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { + const S = reflect.Struct(@TypeOf(value)); + + // This is initially called from a deinit. We don't want to call that + // same deinit. So when this is the first time destroyChain is called + // we don't call deinit (because we're in that deinit) + if (!comptime first) { + // But if it isn't the first time + if (@hasDecl(S, "deinit")) { + // And it has a deinit, we'll call it + switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) { + 1 => value.deinit(), + 2 => value.deinit(self._page), + else => @compileLog(@typeName(S) ++ " has an invalid deinit function"), + } + } + } + + if (@hasField(S, "_proto")) { + self.destroyChain(value._proto, false); + } else if (@hasDecl(S, "JsApi")) { + // Doesn't have a _proto, but has a JsApi. + if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| { + self._size_24_8.destroy(@ptrCast(tagged)); + } + } + + // Leaf types are allowed by be placed directly within their _proto + // (which makes sense when the @sizeOf(Leaf) == 8). These don't need to + // be (cannot be) freed. But we'll still free the chain. + if (comptime wasAllocated(S)) { + switch (@sizeOf(S)) { + 1 => self._size_1_8.destroy(@ptrCast(@alignCast(value))), + 8 => self._size_8_8.destroy(@ptrCast(@alignCast(value))), + 16 => self._size_16_8.destroy(@ptrCast(value)), + 24 => self._size_24_8.destroy(@ptrCast(value)), + 32 => { + if (comptime @alignOf(S) == 8) { + self._size_32_8.destroy(@ptrCast(value)); + } else if (comptime @alignOf(S) == 16) { + self._size_32_16.destroy(@ptrCast(value)); + } + }, + 40 => self._size_40_8.destroy(@ptrCast(value)), + 48 => self._size_48_16.destroy(@ptrCast(@alignCast(value))), + 56 => self._size_56_8.destroy(@ptrCast(value)), + 64 => self._size_64_16.destroy(@ptrCast(@alignCast(value))), + 72 => self._size_72_8.destroy(@ptrCast(@alignCast(value))), + 80 => self._size_80_16.destroy(@ptrCast(@alignCast(value))), + 88 => self._size_88_8.destroy(@ptrCast(@alignCast(value))), + 96 => self._size_96_16.destroy(@ptrCast(@alignCast(value))), + 104 => self._size_104_8.destroy(@ptrCast(value)), + 112 => self._size_112_8.destroy(@ptrCast(value)), + 120 => self._size_120_8.destroy(@ptrCast(value)), + 128 => self._size_128_8.destroy(@ptrCast(value)), + 144 => self._size_144_8.destroy(@ptrCast(value)), + 456 => self._size_456_8.destroy(@ptrCast(value)), + 520 => self._size_520_8.destroy(@ptrCast(value)), + 648 => self._size_648_8.destroy(@ptrCast(value)), + else => |SO| @compileError(std.fmt.comptimePrint("Don't know what I'm being asked to destroy @sizeOf({d}), @alignOf({d}): ({s})", .{ SO, @alignOf(S), @typeName(S) })), + } + } +} + +fn wasAllocated(comptime S: type) bool { + // Whether it's heap allocate or not, we should have a pointer. + // (If it isn't heap allocated, it'll be a pointer from the proto's type + // e.g. &html._type.title) + if (!@hasField(S, "_proto")) { + // a root is always on the heap. + return true; + } + + // the _proto type + const P = reflect.Struct(std.meta.fieldInfo(S, ._proto).type); + + // the _proto._type type (the parent's _type union) + const U = std.meta.fieldInfo(P, ._type).type; + inline for (@typeInfo(U).@"union".fields) |field| { + if (field.type == S) { + // One of the types in the proto's _type union is this non-pointer + // structure, so it isn't heap allocted. + return false; + } + } + return true; +} + +fn unionInit(comptime T: type, value: anytype) T { + const V = @TypeOf(value); + const field_name = comptime unionFieldName(T, V); + return @unionInit(T, field_name, value); +} + +// There can be friction between comptime and runtime. Comptime has to +// account for all possible types, even if some runtime flow makes certain +// cases impossible. At runtime, we always call `unionFieldName` with the +// correct struct or pointer type. But at comptime time, `unionFieldName` +// is called with both variants (S and *S). So we use reflect.Struct(). +// This only works because we never have a union with a field S and another +// field *S. +fn unionFieldName(comptime T: type, comptime V: type) []const u8 { + inline for (@typeInfo(T).@"union".fields) |field| { + if (reflect.Struct(field.type) == reflect.Struct(V)) { + return field.name; + } + } + @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type"); +} + +fn fieldIsPointer(comptime T: type, comptime V: type) bool { + inline for (@typeInfo(T).@"union".fields) |field| { + if (field.type == V) { + return false; + } + if (field.type == *V) { + return true; + } + } + @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type"); +} diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig new file mode 100644 index 000000000..27fe35a85 --- /dev/null +++ b/src/browser/Mime.zig @@ -0,0 +1,518 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const Mime = @This(); +content_type: ContentType, +params: []const u8 = "", +// IANA defines max. charset value length as 40. +// We keep 41 for null-termination since HTML parser expects in this format. +charset: [41]u8 = default_charset, + +/// String "UTF-8" continued by null characters. +pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; + +/// Mime with unknown Content-Type, empty params and empty charset. +pub const unknown = Mime{ .content_type = .{ .unknown = {} } }; + +pub const ContentTypeEnum = enum { + text_xml, + text_html, + text_javascript, + text_plain, + text_css, + application_json, + unknown, + other, +}; + +pub const ContentType = union(ContentTypeEnum) { + text_xml: void, + text_html: void, + text_javascript: void, + text_plain: void, + text_css: void, + application_json: void, + unknown: void, + other: struct { type: []const u8, sub_type: []const u8 }, +}; + +/// Returns the null-terminated charset value. +pub fn charsetString(mime: *const Mime) [:0]const u8 { + return @ptrCast(&mime.charset); +} + +/// Removes quotes of value if quotes are given. +/// +/// Currently we don't validate the charset. +/// See section 2.3 Naming Requirements: +/// https://datatracker.ietf.org/doc/rfc2978/ +fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 { + // Cannot be larger than 40. + // https://datatracker.ietf.org/doc/rfc2978/ + if (value.len > 40) return error.CharsetTooBig; + + // If the first char is a quote, look for a pair. + if (value[0] == '"') { + if (value.len < 3 or value[value.len - 1] != '"') { + return error.Invalid; + } + + return value[1 .. value.len - 1]; + } + + // No quotes. + return value; +} + +pub fn parse(input: []u8) !Mime { + if (input.len > 255) { + return error.TooBig; + } + + // Zig's trim API is broken. The return type is always `[]const u8`, + // even if the input type is `[]u8`. @constCast is safe here. + var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace)); + _ = std.ascii.lowerString(normalized, normalized); + + const content_type, const type_len = try parseContentType(normalized); + if (type_len >= normalized.len) { + return .{ .content_type = content_type }; + } + + const params = trimLeft(normalized[type_len..]); + + var charset: [41]u8 = undefined; + + var it = std.mem.splitScalar(u8, params, ';'); + while (it.next()) |attr| { + const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid; + const name = trimLeft(attr[0..i]); + + const value = trimRight(attr[i + 1 ..]); + if (value.len == 0) { + return error.Invalid; + } + + const attribute_name = std.meta.stringToEnum(enum { + charset, + }, name) orelse continue; + + switch (attribute_name) { + .charset => { + if (value.len == 0) { + break; + } + + const attribute_value = try parseCharset(value); + @memcpy(charset[0..attribute_value.len], attribute_value); + // Null-terminate right after attribute value. + charset[attribute_value.len] = 0; + }, + } + } + + return .{ + .params = params, + .charset = charset, + .content_type = content_type, + }; +} + +pub fn sniff(body: []const u8) ?Mime { + // 0x0C is form feed + const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C }); + if (content.len == 0) { + return null; + } + + if (content[0] != '<') { + if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) { + // UTF-8 BOM + return .{ .content_type = .{ .text_plain = {} } }; + } + if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) { + // UTF-16 big-endian BOM + return .{ .content_type = .{ .text_plain = {} } }; + } + if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) { + // UTF-16 little-endian BOM + return .{ .content_type = .{ .text_plain = {} } }; + } + return null; + } + + // The longest prefix we have is " known_prefix.len) { + const next = prefix[known_prefix.len]; + // a "tag-terminating-byte" + if (next == ' ' or next == '>') { + return .{ .content_type = kp.@"1" }; + } + } + } + + return null; +} + +pub fn isHTML(self: *const Mime) bool { + return self.content_type == .text_html; +} + +// we expect value to be lowercase +fn parseContentType(value: []const u8) !struct { ContentType, usize } { + const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; + const type_name = trimRight(value[0..end]); + const attribute_start = end + 1; + + if (std.meta.stringToEnum(enum { + @"text/xml", + @"text/html", + @"text/css", + @"text/plain", + + @"text/javascript", + @"application/javascript", + @"application/x-javascript", + + @"application/json", + }, type_name)) |known_type| { + const ct: ContentType = switch (known_type) { + .@"text/xml" => .{ .text_xml = {} }, + .@"text/html" => .{ .text_html = {} }, + .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, + .@"text/plain" => .{ .text_plain = {} }, + .@"text/css" => .{ .text_css = {} }, + .@"application/json" => .{ .application_json = {} }, + }; + return .{ ct, attribute_start }; + } + + const separator = std.mem.indexOfScalarPos(u8, type_name, 0, '/') orelse return error.Invalid; + + const main_type = value[0..separator]; + const sub_type = trimRight(value[separator + 1 .. end]); + + if (main_type.len == 0 or validType(main_type) == false) { + return error.Invalid; + } + if (sub_type.len == 0 or validType(sub_type) == false) { + return error.Invalid; + } + + return .{ .{ .other = .{ + .type = main_type, + .sub_type = sub_type, + } }, attribute_start }; +} + +const T_SPECIAL = blk: { + var v = [_]bool{false} ** 256; + for ("()<>@,;:\\\"/[]?=") |b| { + v[b] = true; + } + break :blk v; +}; + +const VALID_CODEPOINTS = blk: { + var v: [256]bool = undefined; + for (0..256) |i| { + v[i] = std.ascii.isAlphanumeric(i); + } + for ("!#$%&\\*+-.^'_`|~") |b| { + v[b] = true; + } + break :blk v; +}; + +fn validType(value: []const u8) bool { + for (value) |b| { + if (VALID_CODEPOINTS[b] == false) { + return false; + } + } + return true; +} + +fn trimLeft(s: []const u8) []const u8 { + return std.mem.trimLeft(u8, s, &std.ascii.whitespace); +} + +fn trimRight(s: []const u8) []const u8 { + return std.mem.trimRight(u8, s, &std.ascii.whitespace); +} + +const testing = @import("../testing.zig"); +test "Mime: invalid" { + defer testing.reset(); + + const invalids = [_][]const u8{ + "", + "text", + "text /html", + "text/ html", + "text / html", + "text/html other", + "text/html; x", + "text/html; x=", + "text/html; x= ", + "text/html; = ", + "text/html;=", + "text/html; charset=\"\"", + "text/html; charset=\"", + "text/html; charset=\"\\", + }; + + for (invalids) |invalid| { + const mutable_input = try testing.arena_allocator.dupe(u8, invalid); + try testing.expectError(error.Invalid, Mime.parse(mutable_input)); + } +} + +test "Mime: parse common" { + defer testing.reset(); + + try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml"); + try expect(.{ .content_type = .{ .text_html = {} } }, "text/html"); + try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain"); + + try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml;"); + try expect(.{ .content_type = .{ .text_html = {} } }, "text/html;"); + try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain;"); + + try expect(.{ .content_type = .{ .text_xml = {} } }, " \ttext/xml"); + try expect(.{ .content_type = .{ .text_html = {} } }, "text/html "); + try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain \t\t"); + + try expect(.{ .content_type = .{ .text_xml = {} } }, "TEXT/xml"); + try expect(.{ .content_type = .{ .text_html = {} } }, "text/Html"); + try expect(.{ .content_type = .{ .text_plain = {} } }, "TEXT/PLAIN"); + + try expect(.{ .content_type = .{ .text_xml = {} } }, " TeXT/xml"); + try expect(.{ .content_type = .{ .text_html = {} } }, "teXt/HtML ;"); + try expect(.{ .content_type = .{ .text_plain = {} } }, "tExT/PlAiN;"); + + try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript"); + try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript"); + try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript"); + + try expect(.{ .content_type = .{ .application_json = {} } }, "application/json"); + try expect(.{ .content_type = .{ .text_css = {} } }, "text/css"); +} + +test "Mime: parse uncommon" { + defer testing.reset(); + + const text_csv = Expectation{ + .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } }, + }; + try expect(text_csv, "text/csv"); + try expect(text_csv, "text/csv;"); + try expect(text_csv, " text/csv\t "); + try expect(text_csv, " text/csv\t ;"); + + try expect( + .{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } }, + "Text/CSV", + ); +} + +test "Mime: parse charset" { + defer testing.reset(); + + try expect(.{ + .content_type = .{ .text_xml = {} }, + .charset = "utf-8", + .params = "charset=utf-8", + }, "text/xml; charset=utf-8"); + + try expect(.{ + .content_type = .{ .text_xml = {} }, + .charset = "utf-8", + .params = "charset=\"utf-8\"", + }, "text/xml;charset=\"UTF-8\""); + + try expect(.{ + .content_type = .{ .text_html = {} }, + .charset = "iso-8859-1", + .params = "charset=\"iso-8859-1\"", + }, "text/html; charset=\"iso-8859-1\""); + + try expect(.{ + .content_type = .{ .text_html = {} }, + .charset = "iso-8859-1", + .params = "charset=\"iso-8859-1\"", + }, "text/html; charset=\"ISO-8859-1\""); + + try expect(.{ + .content_type = .{ .text_xml = {} }, + .charset = "custom-non-standard-charset-value", + .params = "charset=\"custom-non-standard-charset-value\"", + }, "text/xml;charset=\"custom-non-standard-charset-value\""); +} + +test "Mime: isHTML" { + defer testing.reset(); + + const assert = struct { + fn assert(expected: bool, input: []const u8) !void { + const mutable_input = try testing.arena_allocator.dupe(u8, input); + var mime = try Mime.parse(mutable_input); + try testing.expectEqual(expected, mime.isHTML()); + } + }.assert; + try assert(true, "text/html"); + try assert(true, "text/html;"); + try assert(true, "text/html; charset=utf-8"); + try assert(false, "text/htm"); // htm not html + try assert(false, "text/plain"); + try assert(false, "over/9000"); +} + +test "Mime: sniff" { + try testing.expectEqual(null, Mime.sniff("")); + try testing.expectEqual(null, Mime.sniff("")); + try testing.expectEqual(null, Mime.sniff("\n ")); + try testing.expectEqual(null, Mime.sniff("\n \t ")); + + const expectHTML = struct { + fn expect(input: []const u8) !void { + try testing.expectEqual(.text_html, std.meta.activeTag(Mime.sniff(input).?.content_type)); + } + }.expect; + + try expectHTML(" even more stufff"); + + try expectHTML(""); + + try expectHTML(" - - - - diff --git a/src/tests/window/window.html b/src/tests/window/window.html deleted file mode 100644 index cbe67f5f4..000000000 --- a/src/tests/window/window.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/tests/xhr/file.html b/src/tests/xhr/file.html deleted file mode 100644 index 622846028..000000000 --- a/src/tests/xhr/file.html +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/src/tests/xhr/form_data.html b/src/tests/xhr/form_data.html deleted file mode 100644 index 94bf8a272..000000000 --- a/src/tests/xhr/form_data.html +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- diff --git a/src/tests/xhr/progress_event.html b/src/tests/xhr/progress_event.html deleted file mode 100644 index 4b7f5df4a..000000000 --- a/src/tests/xhr/progress_event.html +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/tests/xhr/xhr.html b/src/tests/xhr/xhr.html deleted file mode 100644 index 13ab6216e..000000000 --- a/src/tests/xhr/xhr.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - diff --git a/src/tests/xmlserializer.html b/src/tests/xmlserializer.html deleted file mode 100644 index 0d3d46284..000000000 --- a/src/tests/xmlserializer.html +++ /dev/null @@ -1,8 +0,0 @@ - - -

And

- diff --git a/src/url.zig b/src/url.zig deleted file mode 100644 index acfac2560..000000000 --- a/src/url.zig +++ /dev/null @@ -1,555 +0,0 @@ -const std = @import("std"); - -const Uri = std.Uri; -const Allocator = std.mem.Allocator; -const WebApiURL = @import("browser/url/url.zig").URL; - -pub const stitch = URL.stitch; - -pub const URL = struct { - uri: Uri, - raw: []const u8, - - pub const empty = URL{ .uri = .{ .scheme = "" }, .raw = "" }; - pub const about_blank = URL{ .uri = .{ .scheme = "" }, .raw = "about:blank" }; - - // We assume str will last as long as the URL - // In some cases, this is safe to do, because we know the URL is short lived. - // In most cases though, we assume the caller will just dupe the string URL - // into an arena - pub fn parse(str: []const u8, default_scheme: ?[]const u8) !URL { - var uri = Uri.parse(str) catch try Uri.parseAfterScheme(default_scheme orelse "https", str); - - // special case, url scheme is about, like about:blank. - // Use an empty string as host. - if (std.mem.eql(u8, uri.scheme, "about")) { - uri.host = .{ .percent_encoded = "" }; - } - - if (uri.host == null) { - return error.MissingHost; - } - - std.debug.assert(uri.host.? == .percent_encoded); - - return .{ - .uri = uri, - .raw = str, - }; - } - - pub fn fromURI(arena: Allocator, uri: *const Uri) !URL { - // This is embarrassing. - var buf: std.ArrayListUnmanaged(u8) = .{}; - try uri.writeToStream(.{ - .scheme = true, - .authentication = true, - .authority = true, - .path = true, - .query = true, - .fragment = true, - }, buf.writer(arena)); - - return parse(buf.items, null); - } - - // Above, in `parse`, we error if a host doesn't exist - // In other words, we can't have a URL with a null host. - pub fn host(self: *const URL) []const u8 { - return self.uri.host.?.percent_encoded; - } - - pub fn port(self: *const URL) ?u16 { - return self.uri.port; - } - - pub fn scheme(self: *const URL) []const u8 { - return self.uri.scheme; - } - - pub fn origin(self: *const URL, writer: *std.Io.Writer) !void { - return self.uri.writeToStream(writer, .{ .scheme = true, .authority = true }); - } - - pub fn format(self: *const URL, writer: *std.Io.Writer) !void { - return writer.writeAll(self.raw); - } - - pub fn toWebApi(self: *const URL, allocator: Allocator) !WebApiURL { - return WebApiURL.init(allocator, self.uri); - } - - /// Properly stitches two URL fragments together. - /// - /// For URLs with a path, it will replace the last entry with the src. - /// For URLs without a path, it will add src as the path. - pub fn stitch( - allocator: Allocator, - path: []const u8, - base: []const u8, - comptime opts: StitchOpts, - ) !StitchReturn(opts) { - if (base.len == 0 or isCompleteHTTPUrl(path)) { - return simpleStitch(allocator, path, opts); - } - - if (path.len == 0) { - return simpleStitch(allocator, base, opts); - } - - if (std.mem.startsWith(u8, path, "//")) { - // network-path reference - const index = std.mem.indexOfScalar(u8, base, ':') orelse { - return simpleStitch(allocator, path, opts); - }; - - const protocol = base[0..index]; - if (comptime opts.null_terminated) { - return std.fmt.allocPrintSentinel(allocator, "{s}:{s}", .{ protocol, path }, 0); - } - return std.fmt.allocPrint(allocator, "{s}:{s}", .{ protocol, path }); - } - - // Quick hack because domains have to be at least 3 characters. - // Given https://a.b this will point to 'a' - // Given http://a.b this will point '.' - // Either way, we just care about this value to find the start of the path - const protocol_end: usize = if (isCompleteHTTPUrl(base)) 8 else 0; - - var root = base; - if (std.mem.indexOfScalar(u8, base[protocol_end..], '/')) |pos| { - root = base[0 .. pos + protocol_end]; - } - - if (path[0] == '/') { - if (comptime opts.null_terminated) { - return std.fmt.allocPrintSentinel(allocator, "{s}{s}", .{ root, path }, 0); - } - return std.fmt.allocPrint(allocator, "{s}{s}", .{ root, path }); - } - - var old_path = std.mem.trimStart(u8, base[root.len..], "/"); - if (std.mem.lastIndexOfScalar(u8, old_path, '/')) |pos| { - old_path = old_path[0..pos]; - } else { - old_path = ""; - } - - // We preallocate all of the space possibly needed. - // This is the root, old_path, new path, 3 slashes and perhaps a null terminated slot. - var out = try allocator.alloc(u8, root.len + old_path.len + path.len + 3 + if (comptime opts.null_terminated) 1 else 0); - var end: usize = 0; - @memmove(out[0..root.len], root); - end += root.len; - out[root.len] = '/'; - end += 1; - // If we don't have an old path, do nothing here. - if (old_path.len > 0) { - @memmove(out[end .. end + old_path.len], old_path); - end += old_path.len; - out[end] = '/'; - end += 1; - } - @memmove(out[end .. end + path.len], path); - end += path.len; - - var read: usize = root.len; - var write: usize = root.len; - - // Strip out ./ and ../. This is done in-place, because doing so can - // only ever make `out` smaller. After this, `out` cannot be freed by - // an allocator, which is ok, because we expect allocator to be an arena. - while (read < end) { - if (std.mem.startsWith(u8, out[read..], "./")) { - read += 2; - continue; - } - - if (std.mem.startsWith(u8, out[read..], "../")) { - if (write > root.len + 1) { - const search_range = out[root.len .. write - 1]; - if (std.mem.lastIndexOfScalar(u8, search_range, '/')) |pos| { - write = root.len + pos + 1; - } else { - write = root.len + 1; - } - } - - read += 3; - continue; - } - - out[write] = out[read]; - write += 1; - read += 1; - } - - if (comptime opts.null_terminated) { - // we always have an extra space - out[write] = 0; - return out[0..write :0]; - } - - return out[0..write]; - } - - pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![]const u8 { - std.debug.assert(url.len != 0); - - if (query_string.len == 0) { - return url; - } - - var buf: std.ArrayListUnmanaged(u8) = .empty; - - // the most space well need is the url + ('?' or '&') + the query_string - try buf.ensureTotalCapacity(arena, url.len + 1 + query_string.len); - buf.appendSliceAssumeCapacity(url); - - if (std.mem.indexOfScalar(u8, url, '?')) |index| { - const last_index = url.len - 1; - if (index != last_index and url[last_index] != '&') { - buf.appendAssumeCapacity('&'); - } - } else { - buf.appendAssumeCapacity('?'); - } - buf.appendSliceAssumeCapacity(query_string); - return buf.items; - } -}; - -const StitchOpts = struct { - alloc: AllocWhen = .always, - null_terminated: bool = false, - - const AllocWhen = enum { - always, - if_needed, - }; -}; - -fn StitchReturn(comptime opts: StitchOpts) type { - return if (opts.null_terminated) [:0]const u8 else []const u8; -} - -fn simpleStitch(allocator: Allocator, url: []const u8, comptime opts: StitchOpts) !StitchReturn(opts) { - if (comptime opts.null_terminated) { - return allocator.dupeZ(u8, url); - } - - if (comptime opts.alloc == .always) { - return allocator.dupe(u8, url); - } - - return url; -} - -fn isCompleteHTTPUrl(url: []const u8) bool { - if (url.len < 8) { - return false; - } - - if (!std.ascii.startsWithIgnoreCase(url, "http")) { - return false; - } - - var pos: usize = 4; - if (url[4] == 's' or url[4] == 'S') { - pos = 5; - } - return std.mem.startsWith(u8, url[pos..], "://"); -} - -const testing = @import("testing.zig"); -test "URL: isCompleteHTTPUrl" { - try testing.expectEqual(true, isCompleteHTTPUrl("http://lightpanda.io/about")); - try testing.expectEqual(true, isCompleteHTTPUrl("HttP://lightpanda.io/about")); - try testing.expectEqual(true, isCompleteHTTPUrl("httpS://lightpanda.io/about")); - try testing.expectEqual(true, isCompleteHTTPUrl("HTTPs://lightpanda.io/about")); - - try testing.expectEqual(false, isCompleteHTTPUrl("/lightpanda.io")); - try testing.expectEqual(false, isCompleteHTTPUrl("../../about")); - try testing.expectEqual(false, isCompleteHTTPUrl("about")); - try testing.expectEqual(false, isCompleteHTTPUrl("//lightpanda.io")); - try testing.expectEqual(false, isCompleteHTTPUrl("//lightpanda.io/about")); -} - -test "URL: stitch" { - defer testing.reset(); - - const Case = struct { - base: []const u8, - path: []const u8, - expected: []const u8, - }; - - const cases = [_]Case{ - .{ - .base = "https://lightpanda.io/xyz/abc/123", - .path = "something1.js", - .expected = "https://lightpanda.io/xyz/abc/something1.js", - }, - .{ - .base = "https://lightpanda.io/xyz/abc/123", - .path = "/something2.js", - .expected = "https://lightpanda.io/something2.js", - }, - .{ - .base = "https://lightpanda.io/", - .path = "something3.js", - .expected = "https://lightpanda.io/something3.js", - }, - .{ - .base = "https://lightpanda.io/", - .path = "/something4.js", - .expected = "https://lightpanda.io/something4.js", - }, - .{ - .base = "https://lightpanda.io", - .path = "something5.js", - .expected = "https://lightpanda.io/something5.js", - }, - .{ - .base = "https://lightpanda.io", - .path = "abc/something6.js", - .expected = "https://lightpanda.io/abc/something6.js", - }, - .{ - .base = "https://lightpanda.io/nested", - .path = "abc/something7.js", - .expected = "https://lightpanda.io/abc/something7.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "abc/something8.js", - .expected = "https://lightpanda.io/nested/abc/something8.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "/abc/something9.js", - .expected = "https://lightpanda.io/abc/something9.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "http://www.github.com/lightpanda-io/", - .expected = "http://www.github.com/lightpanda-io/", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "", - .expected = "https://lightpanda.io/nested/", - }, - .{ - .base = "https://lightpanda.io/abc/aaa", - .path = "./hello/./world", - .expected = "https://lightpanda.io/abc/hello/world", - }, - .{ - .base = "https://lightpanda.io/abc/aaa/", - .path = "../hello", - .expected = "https://lightpanda.io/abc/hello", - }, - .{ - .base = "https://lightpanda.io/abc/aaa", - .path = "../hello", - .expected = "https://lightpanda.io/hello", - }, - .{ - .base = "https://lightpanda.io/abc/aaa/", - .path = "./.././.././hello", - .expected = "https://lightpanda.io/hello", - }, - .{ - .base = "some/page", - .path = "hello", - .expected = "some/hello", - }, - .{ - .base = "some/page/", - .path = "hello", - .expected = "some/page/hello", - }, - .{ - .base = "some/page/other", - .path = ".././hello", - .expected = "some/hello", - }, - .{ - .path = "//static.lightpanda.io/hello.js", - .base = "https://lightpanda.io/about/", - .expected = "https://static.lightpanda.io/hello.js", - }, - }; - - for (cases) |case| { - const result = try stitch(testing.arena_allocator, case.path, case.base, .{}); - try testing.expectString(case.expected, result); - } -} - -test "URL: stitch regression (#1093)" { - defer testing.reset(); - - const Case = struct { - base: []const u8, - path: []const u8, - expected: []const u8, - }; - - const cases = [_]Case{ - .{ - .base = "https://alas.aws.amazon.com/alas2.html", - .path = "../static/bootstrap.min.css", - .expected = "https://alas.aws.amazon.com/static/bootstrap.min.css", - }, - }; - - for (cases) |case| { - const result = try stitch(testing.arena_allocator, case.path, case.base, .{}); - try testing.expectString(case.expected, result); - } -} - -test "URL: stitch null terminated" { - defer testing.reset(); - - const Case = struct { - base: []const u8, - path: []const u8, - expected: []const u8, - }; - - const cases = [_]Case{ - .{ - .base = "https://lightpanda.io/xyz/abc/123", - .path = "something1.js", - .expected = "https://lightpanda.io/xyz/abc/something1.js", - }, - .{ - .base = "https://lightpanda.io/xyz/abc/123", - .path = "/something2.js", - .expected = "https://lightpanda.io/something2.js", - }, - .{ - .base = "https://lightpanda.io/", - .path = "something3.js", - .expected = "https://lightpanda.io/something3.js", - }, - .{ - .base = "https://lightpanda.io/", - .path = "/something4.js", - .expected = "https://lightpanda.io/something4.js", - }, - .{ - .base = "https://lightpanda.io", - .path = "something5.js", - .expected = "https://lightpanda.io/something5.js", - }, - .{ - .base = "https://lightpanda.io", - .path = "abc/something6.js", - .expected = "https://lightpanda.io/abc/something6.js", - }, - .{ - .base = "https://lightpanda.io/nested", - .path = "abc/something7.js", - .expected = "https://lightpanda.io/abc/something7.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "abc/something8.js", - .expected = "https://lightpanda.io/nested/abc/something8.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "/abc/something9.js", - .expected = "https://lightpanda.io/abc/something9.js", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "http://www.github.com/lightpanda-io/", - .expected = "http://www.github.com/lightpanda-io/", - }, - .{ - .base = "https://lightpanda.io/nested/", - .path = "", - .expected = "https://lightpanda.io/nested/", - }, - .{ - .base = "https://lightpanda.io/abc/aaa", - .path = "./hello/./world", - .expected = "https://lightpanda.io/abc/hello/world", - }, - .{ - .base = "https://lightpanda.io/abc/aaa/", - .path = "../hello", - .expected = "https://lightpanda.io/abc/hello", - }, - .{ - .base = "https://lightpanda.io/abc/aaa", - .path = "../hello", - .expected = "https://lightpanda.io/hello", - }, - .{ - .base = "https://lightpanda.io/abc/aaa/", - .path = "./.././.././hello", - .expected = "https://lightpanda.io/hello", - }, - .{ - .base = "some/page", - .path = "hello", - .expected = "some/hello", - }, - .{ - .base = "some/page/", - .path = "hello", - .expected = "some/page/hello", - }, - .{ - .base = "some/page/other", - .path = ".././hello", - .expected = "some/hello", - }, - .{ - .path = "//static.lightpanda.io/hello.js", - .base = "https://lightpanda.io/about/", - .expected = "https://static.lightpanda.io/hello.js", - }, - }; - - for (cases) |case| { - const result = try stitch(testing.arena_allocator, case.path, case.base, .{ .null_terminated = true }); - try testing.expectString(case.expected, result); - } -} - -test "URL: concatQueryString" { - defer testing.reset(); - const arena = testing.arena_allocator; - - { - const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/", ""); - try testing.expectEqual("https://www.lightpanda.io/", url); - } - - { - const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", ""); - try testing.expectEqual("https://www.lightpanda.io/index?", url); - } - - { - const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", "a=b"); - try testing.expectEqual("https://www.lightpanda.io/index?a=b", url); - } - - { - const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2", "a=b"); - try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url); - } - - { - const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2&", "a=b"); - try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url); - } -} diff --git a/vendor/mimalloc b/vendor/mimalloc deleted file mode 160000 index 8f7d1e9a4..000000000 --- a/vendor/mimalloc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8f7d1e9a41bb0182166aac6a8d4d8b00f60ed032 diff --git a/vendor/netsurf/libdom b/vendor/netsurf/libdom deleted file mode 160000 index c7f2d3cd2..000000000 --- a/vendor/netsurf/libdom +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c7f2d3cd27d6dc853d8f4cc29ac51ef47944c233 diff --git a/vendor/netsurf/libhubbub b/vendor/netsurf/libhubbub deleted file mode 160000 index 1624ba625..000000000 --- a/vendor/netsurf/libhubbub +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1624ba625047eebdaaefd0c5aa161a91e6e2e641 diff --git a/vendor/netsurf/libparserutils b/vendor/netsurf/libparserutils deleted file mode 160000 index 094dc22e2..000000000 --- a/vendor/netsurf/libparserutils +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 094dc22e2b3c21e8d12f2275fd7bf09bc4da3f3e diff --git a/vendor/netsurf/libwapcaplet b/vendor/netsurf/libwapcaplet deleted file mode 160000 index 74f1e0117..000000000 --- a/vendor/netsurf/libwapcaplet +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 74f1e0117310b5392da484a71346cf09f78e8216 diff --git a/vendor/netsurf/share/netsurf-buildsystem b/vendor/netsurf/share/netsurf-buildsystem deleted file mode 160000 index b4ba781fe..000000000 --- a/vendor/netsurf/share/netsurf-buildsystem +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b4ba781fe22f356d7c53b1674dff91323af61458 From cdd31353c52bd2da4fb72bebfad6f51dc6bb5154 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 28 Oct 2025 11:24:29 +0800 Subject: [PATCH 002/257] get fetch campire working --- src/browser/dump.zig | 8 ++- src/browser/js/Context.zig | 2 - src/browser/js/Env.zig | 1 - src/browser/js/bridge.zig | 1 - src/browser/js/js.zig | 4 +- src/browser/page.zig | 12 +++- .../tests/document/query_selector.html | 2 +- src/browser/webapi/Document.zig | 2 +- src/browser/webapi/Element.zig | 13 ++-- src/browser/webapi/element/Attribute.zig | 39 ++++++++++- src/browser/webapi/net/Fetch.zig | 66 +++++++++++++++++-- src/browser/webapi/net/Response.zig | 2 +- src/lightpanda.zig | 13 ++-- src/log.zig | 2 +- src/main.zig | 21 +++--- 15 files changed, 142 insertions(+), 46 deletions(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 494c4d772..22460ee56 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -2,10 +2,14 @@ const std = @import("std"); const Node = @import("webapi/Node.zig"); pub const Opts = struct { + // @ZIGDOM (none of these do anything) + with_base: bool = false, strip_mode: StripMode = .{}, - const StripMode = struct { - // @ZIGDOM + pub const StripMode = struct { + js: bool = false, + ui: bool = false, + css: bool = false, }; }; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 0b7f258cb..c325df9d3 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -387,7 +387,6 @@ pub fn throw(self: *Context, err: []const u8) js.Exception { pub fn zigValueToJs(self: *Context, value: anytype, comptime opts: Caller.CallOpts) !v8.Value { const isolate = self.isolate; - // Check if it's a "simple" type. This is extracted so that it can be // reused by other parts of the code. "simple" types only require an // isolate to create (specifically, they don't our templates array) @@ -595,7 +594,6 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) ! }; const JsApi = bridge.Struct(ptr.child).JsApi; - // The TAO contains the pointer to our Zig instance as // well as any meta data we'll need to use it later. // See the TaggedAnyOpaque struct for more details. diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 046d5b401..386775e0f 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -311,7 +311,6 @@ fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTem return template; } - // ZIGDOM (HTMLAllCollection I think) // fn generateUndetectable(comptime Struct: type, template: v8.ObjectTemplate) void { // const has_js_call_as_function = @hasDecl(Struct, "jsCallAsFunction"); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index b0732de1f..0e2d5c80a 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -401,7 +401,6 @@ pub const SubType = enum { webassemblymemory, }; - pub const JsApis = flattenTypes(&.{ @import("../webapi/AbortController.zig"), @import("../webapi/AbortSignal.zig"), diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 444c0c571..f0d45e97e 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -106,7 +106,7 @@ pub const PersistentPromiseResolver = struct { pub fn resolve(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; - const js_value = try context.zigValueToJs(value); + const js_value = try context.zigValueToJs(value, .{}); // resolver.resolve will return null if the promise isn't pending const ok = self.resolver.castToPromiseResolver().resolve(context.v8_context, js_value) orelse return; @@ -117,7 +117,7 @@ pub const PersistentPromiseResolver = struct { pub fn reject(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; - const js_value = try context.zigValueToJs(value); + const js_value = try context.zigValueToJs(value, .{}); // resolver.reject will return null if the promise isn't pending const ok = self.resolver.castToPromiseResolver().reject(context.v8_context, js_value) orelse return; diff --git a/src/browser/page.zig b/src/browser/page.zig index 634f6eb77..1851bdc2f 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -58,6 +58,10 @@ _parse_mode: enum { document, fragment }, // even thoug we'll create very few (if any) actual *Attributes. _attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute), +// Same as _atlribute_lookup, but instead of individual attributes, this is for +// the return of elements.attributes. +_attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap), + _script_manager: ScriptManager, _polyfill_loader: polyfill.Loader = .{}, @@ -119,6 +123,7 @@ pub fn deinit(self: *Page) void { log.debug(.page, "page.deinit", .{ .url = self.url }); } self.js.deinit(); + self._script_manager.deinit(); } fn reset(self: *Page, comptime initializing: bool) !void { @@ -144,6 +149,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._parse_state = .pre; self._load_state = .parsing; self._attribute_lookup = .empty; + self._attribute_named_node_map_lookup = .empty; self._event_manager = EventManager.init(self); self._script_manager = ScriptManager.init(self); @@ -165,7 +171,7 @@ fn registerBackgroundTasks(self: *Page) !void { const Browser = @import("Browser.zig"); try self.scheduler.add(self._session.browser, struct { - fn runMicrotasks(ctx: *anyopaque) ?u32 { + fn runMicrotasks(ctx: *anyopaque) !?u32 { const b: *Browser = @ptrCast(@alignCast(ctx)); b.runMicrotasks(); return 5; @@ -173,7 +179,7 @@ fn registerBackgroundTasks(self: *Page) !void { }.runMicrotasks, 5, .{ .name = "page.microtasks" }); try self.scheduler.add(self._session.browser, struct { - fn runMessageLoop(ctx: *anyopaque) ?u32 { + fn runMessageLoop(ctx: *anyopaque) !?u32 { const b: *Browser = @ptrCast(@alignCast(ctx)); b.runMessageLoop(); return 100; @@ -992,7 +998,7 @@ fn populateElementAttributes(self: *Page, element: *Element, list: anytype) !voi if (@TypeOf(list) == ?*Element.Attribute.List) { // from cloneNode - var existing = list orelse return ; + var existing = list orelse return; var attributes = try self.arena.create(Element.Attribute.List); attributes.* = .{}; diff --git a/src/browser/tests/document/query_selector.html b/src/browser/tests/document/query_selector.html index 2399b3ea5..265273079 100644 --- a/src/browser/tests/document/query_selector.html +++ b/src/browser/tests/document/query_selector.html @@ -57,7 +57,7 @@
Heading 6
const firstScript = document.querySelector('script'); testing.expectEqual('SCRIPT', firstScript.tagName); - testing.expectEqual(null, document.querySelector('select')); + testing.expectEqual(null, document.querySelector('article')); testing.expectEqual(null, document.querySelector('another')); } diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 753ecf669..e7dd31ec1 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -170,7 +170,7 @@ pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?u32, fil return DOMTreeWalker.init(root, show, filter, page); } - // @ZIGDOM what_to_show tristate (null vs undefined vs value) +// @ZIGDOM what_to_show tristate (null vs undefined vs value) pub fn createNodeIterator(_: *const Document, root: *Node, what_to_show: ?u32, filter: ?DOMNodeIterator.FilterOpts, page: *Page) !*DOMNodeIterator { const show = what_to_show orelse NodeFilter.SHOW_ALL; return DOMNodeIterator.init(root, show, filter, page); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 2e2e36b67..68dcb6c89 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -118,7 +118,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .script => "script", .select => "select", .style => "style", - .text_area => "textara", + .text_area => "textarea", .title => "title", .ul => "ul", .unknown => |e| e._tag_name.str(), @@ -311,9 +311,14 @@ pub fn getAttributeNames(self: *const Element, page: *Page) ![][]const u8 { return attributes.getNames(page); } -pub fn getAttributeNamedNodeMap(self: *Element) Attribute.NamedNodeMap { - const attributes = self._attributes orelse return .{}; - return .{ ._list = attributes.*, ._element = self }; +pub fn getAttributeNamedNodeMap(self: *Element, page: *Page) !*Attribute.NamedNodeMap { + const gop = try page._attribute_named_node_map_lookup.getOrPut(page.arena, @intFromPtr(self)); + if (!gop.found_existing) { + const attributes = try self.getOrCreateAttributeList(page); + const named_node_map = try page._factory.create(Attribute.NamedNodeMap{ ._list = attributes, ._element = self }); + gop.value_ptr.* = named_node_map; + } + return gop.value_ptr.*; } pub fn getStyle(self: *Element, page: *Page) !*CSSStyleProperties { diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 0e619ca8e..f3fcbe04d 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -326,7 +326,7 @@ fn needsLowerCasing(name: []const u8) bool { } pub const NamedNodeMap = struct { - _list: List = .{}, + _list: *List, // Whenever the NamedNodeMap creates an Attribute, it needs to provide the // "ownerElement". @@ -418,6 +418,12 @@ pub const InnerIterator = struct { fn formatAttribute(name: []const u8, value: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll(name); + + // Boolean attributes with empty values are serialized without a value + if (value.len == 0 and boolean_attributes_lookup.has(name)) { + return; + } + try writer.writeByte('='); if (value.len == 0) { return writer.writeAll("\"\""); @@ -433,6 +439,37 @@ fn formatAttribute(name: []const u8, value: []const u8, writer: *std.Io.Writer) return writer.writeByte('"'); } +const boolean_attributes = [_][]const u8{ + "checked", + "disabled", + "required", + "readonly", + "multiple", + "selected", + "autofocus", + "autoplay", + "controls", + "loop", + "muted", + "hidden", + "async", + "defer", + "novalidate", + "formnovalidate", + "ismap", + "reversed", + "default", + "open", +}; + +const boolean_attributes_lookup = std.StaticStringMap(void).initComptime(blk: { + var entries: [boolean_attributes.len]struct { []const u8, void } = undefined; + for (boolean_attributes, 0..) |attr, i| { + entries[i] = .{ attr, {} }; + } + break :blk entries; +}); + fn writeEscapedAttributeValue(value: []const u8, first_offset: usize, writer: *std.Io.Writer) !void { // Write everything before the first special character try writer.writeAll(value[0..first_offset]); diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index f838ad34c..0d4853f98 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -1,5 +1,8 @@ const std = @import("std"); +const log = @import("../../../log.zig"); +const Http = @import("../../../http/Http.zig"); + const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); @@ -8,15 +11,64 @@ const Response = @import("Response.zig"); const Allocator = std.mem.Allocator; -_arena: Allocator, -_promise: js.Promise, -_has_response: bool, +const Fetch = @This(); + +_page: *Page, +_response: std.ArrayList(u8), +_resolver: js.PersistentPromiseResolver, pub const Input = Request.Input; +// @ZIGDOM just enough to get campire demo working pub fn init(input: Input, page: *Page) !js.Promise { - // @ZIGDOM - _ = input; - _ = page; - return undefined; + const request = try Request.init(input, page); + + const fetch = try page.arena.create(Fetch); + fetch.* = .{ + ._page = page, + ._response = .empty, + ._resolver = try page.js.createPromiseResolver(.page), + }; + + const http_client = page._session.browser.http_client; + const headers = try http_client.newHeaders(); + + try http_client.request(.{ + .ctx = fetch, + .url = request._url, + .method = .GET, + .headers = headers, + .cookie_jar = &page._session.cookie_jar, + .resource_type = .fetch, + .header_callback = httpHeaderDoneCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + }); + return fetch._resolver.promise(); +} + +fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { + const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); + _ = self; +} + +fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { + const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); + try self._response.appendSlice(self._page.arena, data); +} + +fn httpDoneCallback(ctx: *anyopaque) !void { + const self: *Fetch = @ptrCast(@alignCast(ctx)); + + const page = self._page; + const res = try Response.initFromFetch(page.arena, self._response.items, page); + return self._resolver.resolve(res); +} + +fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *Fetch = @ptrCast(@alignCast(ctx)); + self._resolver.reject(@errorName(err)) catch |inner| { + log.err(.bug, "failed to reject", .{ .source = "fetch", .err = inner, .reject = err }); + }; } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index a2fe44f2e..e7f3168db 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -35,7 +35,7 @@ pub fn getJson(self: *Response, page: *Page) !js.Promise { ) catch |err| { return page.js.rejectPromise(.{@errorName(err)}); }; - return page.js.resolvePromise(.{value}); + return page.js.resolvePromise(value); } pub const JsApi = struct { diff --git a/src/lightpanda.zig b/src/lightpanda.zig index a2ad306fc..54e425735 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -8,8 +8,8 @@ const Allocator = std.mem.Allocator; pub const FetchOpts = struct { wait_ms: u32 = 5000, - dump_opts: dump.Opts, - dump_file: ?std.fs.File = null, + dump: dump.Opts, + writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { const Browser = @import("browser/Browser.zig"); @@ -40,12 +40,9 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = try page.navigate(url, .{}); _ = session.fetchWait(opts.wait_ms); - const file = opts.dump_file orelse return; - - var buf: [4096]u8 = undefined; - var writer = file.writer(&buf); - try dump.deep(page.document.asNode(), opts.dump_opts, &writer.interface); - try writer.interface.flush(); + const writer = opts.writer orelse return; + try dump.deep(page.document.asNode(), opts.dump, writer); + try writer.flush(); } test { diff --git a/src/log.zig b/src/log.zig index 03547ba45..d0f02bf9d 100644 --- a/src/log.zig +++ b/src/log.zig @@ -352,7 +352,7 @@ fn elapsed() struct { time: f64, unit: []const u8 } { } const datetime = @import("datetime.zig"); -fn timestamp(mode: datetime.TimestampMode) u64 { +fn timestamp(comptime mode: datetime.TimestampMode) u64 { if (comptime @import("builtin").is_test) { return 1739795092929; } diff --git a/src/main.zig b/src/main.zig index b1a6cb5e4..6c90196d2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,19 +38,17 @@ pub fn main() !void { if (gpa.detectLeaks()) std.posix.exit(1); }; - var global_allocator = lp.GlobalAllocator.init(allocator); - // arena for main-specific allocations - var main_arena = std.heap.ArenaAllocator.init(global_allocator.allocator()); + var main_arena = std.heap.ArenaAllocator.init(allocator); defer main_arena.deinit(); - run(&global_allocator, main_arena.allocator()) catch |err| { + run(allocator, main_arena.allocator()) catch |err| { log.fatal(.app, "exit", .{ .err = err }); std.posix.exit(1); }; } -fn run(allocator: *lp.GlobalAllocator, main_arena: Allocator) !void { +fn run(allocator: Allocator, main_arena: Allocator) !void { const args = try parseArgs(main_arena); switch (args.mode) { @@ -102,7 +100,6 @@ fn run(allocator: *lp.GlobalAllocator, main_arena: Allocator) !void { switch (args.mode) { .serve => { - log.fatal(.app, "serve not not supported in the zigdom branch yet\n", .{}); return; // @ZIGDOM-CDP // .serve => |opts| { @@ -131,13 +128,15 @@ fn run(allocator: *lp.GlobalAllocator, main_arena: Allocator) !void { var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, .dump = .{ - .with_base = opts.with_base, + .with_base = opts.withbase, .strip_mode = opts.strip_mode, }, }; + var stdout = std.fs.File.stdout(); + var writer = stdout.writer(&.{}); if (opts.dump) { - fetch_opts.dump_file = std.fs.File.stdout(); + fetch_opts.writer = &writer.interface; } lp.fetch(app, url, fetch_opts) catch |err| { @@ -245,7 +244,7 @@ const Command = struct { }; const Fetch = struct { - url: []const u8, + url: [:0]const u8, dump: bool = false, common: Common, withbase: bool = false, @@ -513,7 +512,7 @@ fn parseFetchArgs( ) !Command.Fetch { var dump: bool = false; var withbase: bool = false; - var url: ?[]const u8 = null; + var url: ?[:0]const u8 = null; var common: Command.Common = .{}; var strip_mode: lp.dump.Opts.StripMode = .{}; @@ -576,7 +575,7 @@ fn parseFetchArgs( log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" }); return error.TooManyURLs; } - url = try allocator.dupe(u8, opt); + url = try allocator.dupeZ(u8, opt); } if (url == null) { From d3973172e8dbb3a7fffeaaa8c5c63ef5e4f3712c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 28 Oct 2025 18:56:03 +0800 Subject: [PATCH 003/257] re-enable minimum viable CDP server --- src/browser/URL.zig | 129 +++ src/browser/js/bridge.zig | 1 + src/browser/session.zig | 2 +- src/browser/webapi/MutationObserver.zig | 23 + src/browser/webapi/Node.zig | 3 + src/browser/webapi/TreeWalker.zig | 20 +- src/browser/webapi/URL.zig | 83 +- src/browser/webapi/storage/storage.zig | 1 + src/cdp/Node.zig | 1137 ++++++++++---------- src/cdp/cdp.zig | 72 +- src/cdp/domains/dom.zig | 1283 ++++++++++++----------- src/cdp/domains/fetch.zig | 2 +- src/cdp/domains/input.zig | 2 +- src/cdp/domains/log.zig | 2 +- src/cdp/domains/network.zig | 65 +- src/cdp/domains/page.zig | 13 +- src/cdp/domains/storage.zig | 6 +- src/cdp/domains/target.zig | 5 +- src/cdp/testing.zig | 1 - src/http/Client.zig | 4 +- src/http/Http.zig | 2 +- src/lightpanda.zig | 2 + src/main.zig | 39 +- src/server.zig | 20 +- src/telemetry/lightpanda.zig | 2 +- 25 files changed, 1516 insertions(+), 1403 deletions(-) create mode 100644 src/browser/webapi/MutationObserver.zig diff --git a/src/browser/URL.zig b/src/browser/URL.zig index a2062d507..da0319497 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -122,6 +122,135 @@ pub fn isCompleteHTTPUrl(url: []const u8) bool { std.ascii.startsWithIgnoreCase(url, "ftp://"); } +pub fn getUsername(raw: [:0]const u8) []const u8 { + const user_info = getUserInfo(raw) orelse return ""; + const pos = std.mem.indexOfScalarPos(u8, user_info, 0, ':') orelse return user_info; + return user_info[0..pos]; +} + +pub fn getPassword(raw: [:0]const u8) []const u8 { + const user_info = getUserInfo(raw) orelse return ""; + const pos = std.mem.indexOfScalarPos(u8, user_info, 0, ':') orelse return ""; + return user_info[pos + 1 ..]; +} + +pub fn getPathname(raw: [:0]const u8) []const u8 { + const protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0; + const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len; + + const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len; + + if (path_start >= query_or_hash_start) { + if (std.mem.indexOf(u8, raw, "://") != null) return "/"; + return ""; + } + + return raw[path_start..query_or_hash_start]; +} + +pub fn getProtocol(raw: [:0]const u8) []const u8 { + const pos = std.mem.indexOfScalarPos(u8, raw, 0, ':') orelse return ""; + return raw[0 .. pos + 1]; +} + +pub fn getHostname(raw: [:0]const u8) []const u8 { + const host = getHost(raw); + const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host; + return host[0..pos]; +} + +pub fn getPort(raw: [:0]const u8) []const u8 { + const host = getHost(raw); + const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return ""; + + if (pos + 1 >= host.len) { + return ""; + } + + for (host[pos + 1 ..]) |c| { + if (c < '0' or c > '9') { + return ""; + } + } + + return host[pos + 1 ..]; +} + +pub fn getSearch(raw: [:0]const u8) []const u8 { + const pos = std.mem.indexOfScalarPos(u8, raw, 0, '?') orelse return ""; + const query_part = raw[pos..]; + + if (std.mem.indexOfScalarPos(u8, query_part, 0, '#')) |fragment_start| { + return query_part[0..fragment_start]; + } + + return query_part; +} + +pub fn getHash(raw: [:0]const u8) []const u8 { + const start = std.mem.indexOfScalarPos(u8, raw, 0, '#') orelse return ""; + return raw[start..]; +} + +pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 { + const port = getPort(raw); + const protocol = getProtocol(raw); + const hostname = getHostname(raw); + + const p = std.meta.stringToEnum(KnownProtocol, getProtocol(raw)) orelse return null; + + const include_port = blk: { + if (port.len == 0) { + break :blk false; + } + if (p == .@"https:" and std.mem.eql(u8, port, "443")) { + break :blk false; + } + if (p == .@"http:" and std.mem.eql(u8, port, "80")) { + break :blk false; + } + break :blk true; + }; + + if (include_port) { + return try std.fmt.allocPrint(allocator, "{s}//{s}:{s}", .{ protocol, hostname, port }); + } + return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ protocol, hostname }); +} + +fn getUserInfo(raw: [:0]const u8) ?[]const u8 { + const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; + const authority_start = scheme_end + 3; + + const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null; + const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len; + + const full_pos = authority_start + pos; + if (full_pos < path_start) { + return raw[authority_start..full_pos]; + } + + return null; +} + +fn getHost(raw: [:0]const u8) []const u8 { + const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return ""; + + var authority_start = scheme_end + 3; + if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| { + authority_start += pos + 1; + } + + const authority = raw[authority_start..]; + const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority; + return authority[0..path_start]; +} + +const KnownProtocol = enum { + @"http:", + @"https:", +}; + const testing = @import("../testing.zig"); test "URL: isCompleteHTTPUrl" { try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about")); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 0e2d5c80a..ae2790eab 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -467,4 +467,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/storage/storage.zig"), @import("../webapi/URL.zig"), @import("../webapi/Window.zig"), + @import("../webapi/MutationObserver.zig"), }); diff --git a/src/browser/session.zig b/src/browser/session.zig index 41fd795a6..0f90a82a3 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -141,7 +141,7 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult { return .done; }; - if (self.page) |*page| { + if (self.page) |page| { return page.wait(wait_ms); } return .no_page; diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig new file mode 100644 index 000000000..73001ee44 --- /dev/null +++ b/src/browser/webapi/MutationObserver.zig @@ -0,0 +1,23 @@ +const js = @import("../js/js.zig"); + +// @ZIGDOM (haha, bet you wish you hadn't opened this file) +// puppeteer's startup script creates a MutationObserver, even if it doesn't use +// it in simple scripts. This not-even-a-skeleton is required for puppeteer/cdp.js +// to run +const MutationObserver = @This(); + +pub fn init() MutationObserver { + return .{}; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MutationObserver); + + pub const Meta = struct { + pub const name = "MutationObserver"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_index: u16 = 0; + }; + + pub const constructor = bridge.constructor(MutationObserver.init, .{}); +}; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index e6f03d980..88a827fab 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -340,6 +340,9 @@ pub fn setNodeValue(self: *const Node, value: ?[]const u8, page: *Page) !void { } pub fn format(self: *Node, writer: *std.Io.Writer) !void { + // // If you need extra debugging: + // return @import("../dump.zig").deep(self, .{}, writer); + return switch (self._type) { .cdata => |cd| cd.format(writer), .element => |el| writer.print("{f}", .{el}), diff --git a/src/browser/webapi/TreeWalker.zig b/src/browser/webapi/TreeWalker.zig index c2b1f39e0..cee99ff14 100644 --- a/src/browser/webapi/TreeWalker.zig +++ b/src/browser/webapi/TreeWalker.zig @@ -39,14 +39,22 @@ pub fn TreeWalker(comptime mode: Mode) type { self._next = children.first(); } else if (node._child_link.next) |n| { self._next = Node.linkToNode(n); - } else if (node._parent) |n| { - if (n == self._root) { - self._next = null; + } else { + // No children, no next sibling - walk up until we find a next sibling or hit root + var current = node._parent; + while (current) |parent| { + if (parent == self._root) { + self._next = null; + break; + } + if (parent._child_link.next) |next_sibling| { + self._next = Node.linkToNode(next_sibling); + break; + } + current = parent._parent; } else { - self._next = Node.linkToNodeOrNull(n._child_link.next); + self._next = null; } - } else { - self._next = null; } return node; } diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index b81c8cb2e..d7bf0d7db 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -1,6 +1,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const U = @import("../URL.zig"); const Page = @import("../Page.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); @@ -42,106 +43,42 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { } pub fn getUsername(self: *const URL) []const u8 { - const user_info = self.getUserInfo() orelse return ""; - const pos = std.mem.indexOfScalarPos(u8, user_info, 0, ':') orelse return user_info; - return user_info[0..pos]; + return U.getUsername(self._raw); } pub fn getPassword(self: *const URL) []const u8 { - const user_info = self.getUserInfo() orelse return ""; - const pos = std.mem.indexOfScalarPos(u8, user_info, 0, ':') orelse return ""; - return user_info[pos + 1 ..]; + return U.getPassword(self._raw); } pub fn getPathname(self: *const URL) []const u8 { - const raw = self._raw; - const protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0; - const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len; - - const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len; - - if (path_start >= query_or_hash_start) { - if (std.mem.indexOf(u8, raw, "://") != null) return "/"; - return ""; - } - - return raw[path_start..query_or_hash_start]; + return U.getPathname(self._raw); } pub fn getProtocol(self: *const URL) []const u8 { - const raw = self._raw; - const pos = std.mem.indexOfScalarPos(u8, raw, 0, ':') orelse return ""; - return raw[0 .. pos + 1]; + return U.getProtocol(self._raw); } pub fn getHostname(self: *const URL) []const u8 { - const host = self.getHost(); - const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host; - return host[0..pos]; + return U.getHostname(self._raw); } pub fn getPort(self: *const URL) []const u8 { - const host = self.getHost(); - const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return ""; - - if (pos + 1 >= host.len) { - return ""; - } - - for (host[pos + 1 ..]) |c| { - if (c < '0' or c > '9') { - return ""; - } - } - - return host[pos + 1 ..]; + return U.getPort(self._raw); } pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 { - const port = self.getPort(); - const protocol = self.getProtocol(); - const hostname = self.getHostname(); - - const p = std.meta.stringToEnum(KnownProtocol, self.getProtocol()) orelse { + return (try U.getOrigin(page.call_arena, self._raw)) orelse { // yes, a null string, that's what the spec wants return "null"; }; - - const include_port = blk: { - if (port.len == 0) { - break :blk false; - } - if (p == .@"https:" and std.mem.eql(u8, port, "443")) { - break :blk false; - } - if (p == .@"http:" and std.mem.eql(u8, port, "80")) { - break :blk false; - } - break :blk true; - }; - - if (include_port) { - return std.fmt.allocPrint(page.call_arena, "{s}//{s}:{s}", .{ protocol, hostname, port }); - } - return std.fmt.allocPrint(page.call_arena, "{s}//{s}", .{ protocol, hostname }); } pub fn getSearch(self: *const URL) []const u8 { - const raw = self._raw; - const pos = std.mem.indexOfScalarPos(u8, raw, 0, '?') orelse return ""; - const query_part = raw[pos..]; - - if (std.mem.indexOfScalarPos(u8, query_part, 0, '#')) |fragment_start| { - return query_part[0..fragment_start]; - } - - return query_part; + return U.getSearch(self._raw); } pub fn getHash(self: *const URL) []const u8 { - const raw = self._raw; - const start = std.mem.indexOfScalarPos(u8, raw, 0, '#') orelse return ""; - return raw[start..]; + return U.getHash(self._raw); } pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 13bbc72f2..8813c0928 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -9,6 +9,7 @@ pub fn registerTypes() []const type { } pub const Jar = @import("cookie.zig").Jar; +pub const Cookie =@import("cookie.zig").Cookie; pub const Shed = struct { _origins: std.StringHashMapUnmanaged(*Bucket) = .empty, diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index be18206c4..c51093128 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -16,571 +16,572 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const log = @import("../log.zig"); -const parser = @import("../browser/netsurf.zig"); - -pub const Id = u32; - -const Node = @This(); - -id: Id, -_node: *parser.Node, -set_child_nodes_event: bool, - -// Whenever we send a node to the client, we register it here for future lookup. -// We maintain a node -> id and id -> node lookup. -pub const Registry = struct { - node_id: u32, - allocator: Allocator, - arena: std.heap.ArenaAllocator, - node_pool: std.heap.MemoryPool(Node), - lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), - lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage), - - pub fn init(allocator: Allocator) Registry { - return .{ - .node_id = 1, - .lookup_by_id = .{}, - .lookup_by_node = .{}, - .allocator = allocator, - .arena = std.heap.ArenaAllocator.init(allocator), - .node_pool = std.heap.MemoryPool(Node).init(allocator), - }; - } - - pub fn deinit(self: *Registry) void { - const allocator = self.allocator; - self.lookup_by_id.deinit(allocator); - self.lookup_by_node.deinit(allocator); - self.node_pool.deinit(); - self.arena.deinit(); - } - - pub fn reset(self: *Registry) void { - self.lookup_by_id.clearRetainingCapacity(); - self.lookup_by_node.clearRetainingCapacity(); - _ = self.arena.reset(.{ .retain_with_limit = 1024 }); - _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); - } - - pub fn register(self: *Registry, n: *parser.Node) !*Node { - const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, n); - if (node_lookup_gop.found_existing) { - return node_lookup_gop.value_ptr.*; - } - - // on error, we're probably going to abort the entire browser context - // but, just in case, let's try to keep things tidy. - errdefer _ = self.lookup_by_node.remove(n); - - const node = try self.node_pool.create(); - errdefer self.node_pool.destroy(node); - - const id = self.node_id; - self.node_id = id + 1; - - node.* = .{ - ._node = n, - .id = id, - .set_child_nodes_event = false, - }; - - node_lookup_gop.value_ptr.* = node; - try self.lookup_by_id.putNoClobber(self.allocator, id, node); - return node; - } -}; - -const NodeContext = struct { - pub fn hash(_: NodeContext, n: *parser.Node) u64 { - return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(n))); - } - - pub fn eql(_: NodeContext, a: *parser.Node, b: *parser.Node) bool { - return @intFromPtr(a) == @intFromPtr(b); - } -}; - -// Searches are a 3 step process: -// 1 - Dom.performSearch -// 2 - Dom.getSearchResults -// 3 - Dom.discardSearchResults -// -// For a given browser context, we can have multiple active searches. I.e. -// performSearch could be called multiple times without getSearchResults or -// discardSearchResults being called. We keep these active searches in the -// browser context's node_search_list, which is a SearchList. Since we don't -// expect many active searches (mostly just 1), a list is fine to scan through. -pub const Search = struct { - name: []const u8, - node_ids: []const Id, - - pub const List = struct { - registry: *Registry, - search_id: u16 = 0, - arena: std.heap.ArenaAllocator, - searches: std.ArrayListUnmanaged(Search) = .{}, - - pub fn init(allocator: Allocator, registry: *Registry) List { - return .{ - .registry = registry, - .arena = std.heap.ArenaAllocator.init(allocator), - }; - } - - pub fn deinit(self: *List) void { - self.arena.deinit(); - } - - pub fn reset(self: *List) void { - self.search_id = 0; - self.searches = .{}; - _ = self.arena.reset(.{ .retain_with_limit = 4096 }); - } - - pub fn create(self: *List, nodes: []const *parser.Node) !Search { - const id = self.search_id; - defer self.search_id = id +% 1; - - const arena = self.arena.allocator(); - - const name = switch (id) { - 0 => "0", - 1 => "1", - 2 => "2", - 3 => "3", - 4 => "4", - 5 => "5", - 6 => "6", - 7 => "7", - 8 => "8", - 9 => "9", - else => try std.fmt.allocPrint(arena, "{d}", .{id}), - }; - - var registry = self.registry; - const node_ids = try arena.alloc(Id, nodes.len); - for (nodes, node_ids) |node, *node_id| { - node_id.* = (try registry.register(node)).id; - } - - const search = Search{ - .name = name, - .node_ids = node_ids, - }; - try self.searches.append(arena, search); - return search; - } - - pub fn remove(self: *List, name: []const u8) void { - for (self.searches.items, 0..) |search, i| { - if (std.mem.eql(u8, name, search.name)) { - _ = self.searches.swapRemove(i); - return; - } - } - } - - pub fn get(self: *const List, name: []const u8) ?Search { - for (self.searches.items) |search| { - if (std.mem.eql(u8, name, search.name)) { - return search; - } - } - return null; - } - }; -}; - -// Need a custom writer, because we can't just serialize the node as-is. -// Sometimes we want to serializ the node without chidren, sometimes with just -// its direct children, and sometimes the entire tree. -// (For now, we only support direct children) - -pub const Writer = struct { - depth: i32, - exclude_root: bool, - root: *const Node, - registry: *Registry, - - pub const Opts = struct { - depth: i32 = 0, - exclude_root: bool = false, - }; - - pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { - if (self.exclude_root) { - _ = self.writeChildren(self.root, 1, w) catch |err| { - log.err(.cdp, "node writeChildren", .{ .err = err }); - return error.WriteFailed; - }; - } else { - self.toJSON(self.root, 0, w) catch |err| { - // The only error our jsonStringify method can return is - // @TypeOf(w).Error. In other words, our code can't return its own - // error, we can only return a writer error. Kinda sucks. - log.err(.cdp, "node toJSON stringify", .{ .err = err }); - return error.WriteFailed; - }; - } - } - - fn toJSON(self: *const Writer, node: *const Node, depth: usize, w: anytype) !void { - try w.beginObject(); - try self.writeCommon(node, false, w); - - try w.objectField("children"); - const child_count = try self.writeChildren(node, depth, w); - try w.objectField("childNodeCount"); - try w.write(child_count); - - try w.endObject(); - } - - fn writeChildren(self: *const Writer, node: *const Node, depth: usize, w: anytype) anyerror!usize { - var registry = self.registry; - const child_nodes = try parser.nodeGetChildNodes(node._node); - const child_count = parser.nodeListLength(child_nodes); - const full_child = self.depth < 0 or self.depth < depth; - - var i: usize = 0; - try w.beginArray(); - for (0..child_count) |_| { - const child = (parser.nodeListItem(child_nodes, @intCast(i))) orelse break; - const child_node = try registry.register(child); - if (full_child) { - try self.toJSON(child_node, depth + 1, w); - } else { - try w.beginObject(); - try self.writeCommon(child_node, true, w); - try w.endObject(); - } - - i += 1; - } - try w.endArray(); - - return i; - } - - fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void { - try w.objectField("nodeId"); - try w.write(node.id); - - try w.objectField("backendNodeId"); - try w.write(node.id); - - const n = node._node; - - if (parser.nodeParentNode(n)) |p| { - const parent_node = try self.registry.register(p); - try w.objectField("parentId"); - try w.write(parent_node.id); - } - - const _map = try parser.nodeGetAttributes(n); - if (_map) |map| { - const attr_count = try parser.namedNodeMapGetLength(map); - try w.objectField("attributes"); - try w.beginArray(); - for (0..attr_count) |i| { - const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue; - try w.write(try parser.attributeGetName(attr)); - try w.write(try parser.attributeGetValue(attr) orelse continue); - } - try w.endArray(); - } - - try w.objectField("nodeType"); - try w.write(@intFromEnum(parser.nodeType(n))); - - try w.objectField("nodeName"); - try w.write(try parser.nodeName(n)); - - try w.objectField("localName"); - try w.write(try parser.nodeLocalName(n)); - - try w.objectField("nodeValue"); - try w.write((parser.nodeValue(n)) orelse ""); - - if (include_child_count) { - try w.objectField("childNodeCount"); - const child_nodes = try parser.nodeGetChildNodes(n); - try w.write(parser.nodeListLength(child_nodes)); - } - - try w.objectField("documentURL"); - try w.write(null); - - try w.objectField("baseURL"); - try w.write(null); - - try w.objectField("xmlVersion"); - try w.write(""); - - try w.objectField("compatibilityMode"); - try w.write("NoQuirksMode"); - - try w.objectField("isScrollable"); - try w.write(false); - } -}; - -const testing = @import("testing.zig"); -test "cdp Node: Registry register" { - parser.init(); - defer parser.deinit(); - - var registry = Registry.init(testing.allocator); - defer registry.deinit(); - - try testing.expectEqual(0, registry.lookup_by_id.count()); - try testing.expectEqual(0, registry.lookup_by_node.count()); - - var doc = try testing.Document.init("link1

other

"); - defer doc.deinit(); - - { - const n = (try doc.querySelector("#a1")).?; - const node = try registry.register(n); - const n1b = registry.lookup_by_id.get(1).?; - const n1c = registry.lookup_by_node.get(node._node).?; - try testing.expectEqual(node, n1b); - try testing.expectEqual(node, n1c); - - try testing.expectEqual(1, node.id); - try testing.expectEqual(n, node._node); - } - - { - const n = (try doc.querySelector("p")).?; - const node = try registry.register(n); - const n1b = registry.lookup_by_id.get(2).?; - const n1c = registry.lookup_by_node.get(node._node).?; - try testing.expectEqual(node, n1b); - try testing.expectEqual(node, n1c); - - try testing.expectEqual(2, node.id); - try testing.expectEqual(n, node._node); - } -} - -test "cdp Node: search list" { - parser.init(); - defer parser.deinit(); - - var registry = Registry.init(testing.allocator); - defer registry.deinit(); - - var search_list = Search.List.init(testing.allocator, ®istry); - defer search_list.deinit(); - - { - // empty search list, noops - search_list.remove("0"); - try testing.expectEqual(null, search_list.get("0")); - } - - { - // empty nodes - const s1 = try search_list.create(&.{}); - try testing.expectEqual("0", s1.name); - try testing.expectEqual(0, s1.node_ids.len); - - const s2 = search_list.get("0").?; - try testing.expectEqual("0", s2.name); - try testing.expectEqual(0, s2.node_ids.len); - - search_list.remove("0"); - try testing.expectEqual(null, search_list.get("0")); - } - - { - var doc = try testing.Document.init(""); - defer doc.deinit(); - - const s1 = try search_list.create(try doc.querySelectorAll("a")); - try testing.expectEqual("1", s1.name); - try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); - - try testing.expectEqual(2, registry.lookup_by_id.count()); - try testing.expectEqual(2, registry.lookup_by_node.count()); - - const s2 = try search_list.create(try doc.querySelectorAll("#a1")); - try testing.expectEqual("2", s2.name); - try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); - - const s3 = try search_list.create(try doc.querySelectorAll("#a2")); - try testing.expectEqual("3", s3.name); - try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); - - try testing.expectEqual(2, registry.lookup_by_id.count()); - try testing.expectEqual(2, registry.lookup_by_node.count()); - } -} - -test "cdp Node: Writer" { - parser.init(); - defer parser.deinit(); - - var registry = Registry.init(testing.allocator); - defer registry.deinit(); - - var doc = try testing.Document.init("
"); - defer doc.deinit(); - - { - const node = try registry.register(doc.asNode()); - const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ - .root = node, - .depth = 0, - .exclude_root = false, - .registry = ®istry, - }, .{}); - defer testing.allocator.free(json); - - try testing.expectJson(.{ - .nodeId = 1, - .backendNodeId = 1, - .nodeType = 9, - .nodeName = "#document", - .localName = "", - .nodeValue = "", - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .isScrollable = false, - .compatibilityMode = "NoQuirksMode", - .childNodeCount = 1, - .children = &.{.{ - .nodeId = 2, - .backendNodeId = 2, - .nodeType = 1, - .nodeName = "HTML", - .localName = "html", - .nodeValue = "", - .childNodeCount = 2, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - }}, - }, json); - } - - { - const node = registry.lookup_by_id.get(2).?; - const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ - .root = node, - .depth = 1, - .exclude_root = false, - .registry = ®istry, - }, .{}); - defer testing.allocator.free(json); - - try testing.expectJson(.{ - .nodeId = 2, - .backendNodeId = 2, - .nodeType = 1, - .nodeName = "HTML", - .localName = "html", - .nodeValue = "", - .childNodeCount = 2, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - .children = &.{ .{ - .nodeId = 3, - .backendNodeId = 3, - .nodeType = 1, - .nodeName = "HEAD", - .localName = "head", - .nodeValue = "", - .childNodeCount = 0, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - .parentId = 2, - }, .{ - .nodeId = 4, - .backendNodeId = 4, - .nodeType = 1, - .nodeName = "BODY", - .localName = "body", - .nodeValue = "", - .childNodeCount = 2, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - .parentId = 2, - } }, - }, json); - } - - { - const node = registry.lookup_by_id.get(2).?; - const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ - .root = node, - .depth = -1, - .exclude_root = true, - .registry = ®istry, - }, .{}); - defer testing.allocator.free(json); - - try testing.expectJson(&.{ .{ - .nodeId = 3, - .backendNodeId = 3, - .nodeType = 1, - .nodeName = "HEAD", - .localName = "head", - .nodeValue = "", - .childNodeCount = 0, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - .parentId = 2, - }, .{ - .nodeId = 4, - .backendNodeId = 4, - .nodeType = 1, - .nodeName = "BODY", - .localName = "body", - .nodeValue = "", - .childNodeCount = 2, - .documentURL = null, - .baseURL = null, - .xmlVersion = "", - .compatibilityMode = "NoQuirksMode", - .isScrollable = false, - .children = &.{ .{ - .nodeId = 5, - .localName = "a", - .childNodeCount = 0, - .parentId = 4, - }, .{ - .nodeId = 6, - .localName = "div", - .childNodeCount = 1, - .parentId = 4, - .children = &.{.{ - .nodeId = 7, - .localName = "a", - .childNodeCount = 0, - .parentId = 6, - }}, - } }, - } }, json); - } -} +// @ZIGDOM +// const std = @import("std"); +// const Allocator = std.mem.Allocator; + +// const log = @import("../log.zig"); +// const parser = @import("../browser/netsurf.zig"); + +// pub const Id = u32; + +// const Node = @This(); + +// id: Id, +// _node: *parser.Node, +// set_child_nodes_event: bool, + +// // Whenever we send a node to the client, we register it here for future lookup. +// // We maintain a node -> id and id -> node lookup. +// pub const Registry = struct { +// node_id: u32, +// allocator: Allocator, +// arena: std.heap.ArenaAllocator, +// node_pool: std.heap.MemoryPool(Node), +// lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), +// lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage), + +// pub fn init(allocator: Allocator) Registry { +// return .{ +// .node_id = 1, +// .lookup_by_id = .{}, +// .lookup_by_node = .{}, +// .allocator = allocator, +// .arena = std.heap.ArenaAllocator.init(allocator), +// .node_pool = std.heap.MemoryPool(Node).init(allocator), +// }; +// } + +// pub fn deinit(self: *Registry) void { +// const allocator = self.allocator; +// self.lookup_by_id.deinit(allocator); +// self.lookup_by_node.deinit(allocator); +// self.node_pool.deinit(); +// self.arena.deinit(); +// } + +// pub fn reset(self: *Registry) void { +// self.lookup_by_id.clearRetainingCapacity(); +// self.lookup_by_node.clearRetainingCapacity(); +// _ = self.arena.reset(.{ .retain_with_limit = 1024 }); +// _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); +// } + +// pub fn register(self: *Registry, n: *parser.Node) !*Node { +// const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, n); +// if (node_lookup_gop.found_existing) { +// return node_lookup_gop.value_ptr.*; +// } + +// // on error, we're probably going to abort the entire browser context +// // but, just in case, let's try to keep things tidy. +// errdefer _ = self.lookup_by_node.remove(n); + +// const node = try self.node_pool.create(); +// errdefer self.node_pool.destroy(node); + +// const id = self.node_id; +// self.node_id = id + 1; + +// node.* = .{ +// ._node = n, +// .id = id, +// .set_child_nodes_event = false, +// }; + +// node_lookup_gop.value_ptr.* = node; +// try self.lookup_by_id.putNoClobber(self.allocator, id, node); +// return node; +// } +// }; + +// const NodeContext = struct { +// pub fn hash(_: NodeContext, n: *parser.Node) u64 { +// return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(n))); +// } + +// pub fn eql(_: NodeContext, a: *parser.Node, b: *parser.Node) bool { +// return @intFromPtr(a) == @intFromPtr(b); +// } +// }; + +// // Searches are a 3 step process: +// // 1 - Dom.performSearch +// // 2 - Dom.getSearchResults +// // 3 - Dom.discardSearchResults +// // +// // For a given browser context, we can have multiple active searches. I.e. +// // performSearch could be called multiple times without getSearchResults or +// // discardSearchResults being called. We keep these active searches in the +// // browser context's node_search_list, which is a SearchList. Since we don't +// // expect many active searches (mostly just 1), a list is fine to scan through. +// pub const Search = struct { +// name: []const u8, +// node_ids: []const Id, + +// pub const List = struct { +// registry: *Registry, +// search_id: u16 = 0, +// arena: std.heap.ArenaAllocator, +// searches: std.ArrayListUnmanaged(Search) = .{}, + +// pub fn init(allocator: Allocator, registry: *Registry) List { +// return .{ +// .registry = registry, +// .arena = std.heap.ArenaAllocator.init(allocator), +// }; +// } + +// pub fn deinit(self: *List) void { +// self.arena.deinit(); +// } + +// pub fn reset(self: *List) void { +// self.search_id = 0; +// self.searches = .{}; +// _ = self.arena.reset(.{ .retain_with_limit = 4096 }); +// } + +// pub fn create(self: *List, nodes: []const *parser.Node) !Search { +// const id = self.search_id; +// defer self.search_id = id +% 1; + +// const arena = self.arena.allocator(); + +// const name = switch (id) { +// 0 => "0", +// 1 => "1", +// 2 => "2", +// 3 => "3", +// 4 => "4", +// 5 => "5", +// 6 => "6", +// 7 => "7", +// 8 => "8", +// 9 => "9", +// else => try std.fmt.allocPrint(arena, "{d}", .{id}), +// }; + +// var registry = self.registry; +// const node_ids = try arena.alloc(Id, nodes.len); +// for (nodes, node_ids) |node, *node_id| { +// node_id.* = (try registry.register(node)).id; +// } + +// const search = Search{ +// .name = name, +// .node_ids = node_ids, +// }; +// try self.searches.append(arena, search); +// return search; +// } + +// pub fn remove(self: *List, name: []const u8) void { +// for (self.searches.items, 0..) |search, i| { +// if (std.mem.eql(u8, name, search.name)) { +// _ = self.searches.swapRemove(i); +// return; +// } +// } +// } + +// pub fn get(self: *const List, name: []const u8) ?Search { +// for (self.searches.items) |search| { +// if (std.mem.eql(u8, name, search.name)) { +// return search; +// } +// } +// return null; +// } +// }; +// }; + +// // Need a custom writer, because we can't just serialize the node as-is. +// // Sometimes we want to serializ the node without chidren, sometimes with just +// // its direct children, and sometimes the entire tree. +// // (For now, we only support direct children) + +// pub const Writer = struct { +// depth: i32, +// exclude_root: bool, +// root: *const Node, +// registry: *Registry, + +// pub const Opts = struct { +// depth: i32 = 0, +// exclude_root: bool = false, +// }; + +// pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { +// if (self.exclude_root) { +// _ = self.writeChildren(self.root, 1, w) catch |err| { +// log.err(.cdp, "node writeChildren", .{ .err = err }); +// return error.WriteFailed; +// }; +// } else { +// self.toJSON(self.root, 0, w) catch |err| { +// // The only error our jsonStringify method can return is +// // @TypeOf(w).Error. In other words, our code can't return its own +// // error, we can only return a writer error. Kinda sucks. +// log.err(.cdp, "node toJSON stringify", .{ .err = err }); +// return error.WriteFailed; +// }; +// } +// } + +// fn toJSON(self: *const Writer, node: *const Node, depth: usize, w: anytype) !void { +// try w.beginObject(); +// try self.writeCommon(node, false, w); + +// try w.objectField("children"); +// const child_count = try self.writeChildren(node, depth, w); +// try w.objectField("childNodeCount"); +// try w.write(child_count); + +// try w.endObject(); +// } + +// fn writeChildren(self: *const Writer, node: *const Node, depth: usize, w: anytype) anyerror!usize { +// var registry = self.registry; +// const child_nodes = try parser.nodeGetChildNodes(node._node); +// const child_count = parser.nodeListLength(child_nodes); +// const full_child = self.depth < 0 or self.depth < depth; + +// var i: usize = 0; +// try w.beginArray(); +// for (0..child_count) |_| { +// const child = (parser.nodeListItem(child_nodes, @intCast(i))) orelse break; +// const child_node = try registry.register(child); +// if (full_child) { +// try self.toJSON(child_node, depth + 1, w); +// } else { +// try w.beginObject(); +// try self.writeCommon(child_node, true, w); +// try w.endObject(); +// } + +// i += 1; +// } +// try w.endArray(); + +// return i; +// } + +// fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void { +// try w.objectField("nodeId"); +// try w.write(node.id); + +// try w.objectField("backendNodeId"); +// try w.write(node.id); + +// const n = node._node; + +// if (parser.nodeParentNode(n)) |p| { +// const parent_node = try self.registry.register(p); +// try w.objectField("parentId"); +// try w.write(parent_node.id); +// } + +// const _map = try parser.nodeGetAttributes(n); +// if (_map) |map| { +// const attr_count = try parser.namedNodeMapGetLength(map); +// try w.objectField("attributes"); +// try w.beginArray(); +// for (0..attr_count) |i| { +// const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue; +// try w.write(try parser.attributeGetName(attr)); +// try w.write(try parser.attributeGetValue(attr) orelse continue); +// } +// try w.endArray(); +// } + +// try w.objectField("nodeType"); +// try w.write(@intFromEnum(parser.nodeType(n))); + +// try w.objectField("nodeName"); +// try w.write(try parser.nodeName(n)); + +// try w.objectField("localName"); +// try w.write(try parser.nodeLocalName(n)); + +// try w.objectField("nodeValue"); +// try w.write((parser.nodeValue(n)) orelse ""); + +// if (include_child_count) { +// try w.objectField("childNodeCount"); +// const child_nodes = try parser.nodeGetChildNodes(n); +// try w.write(parser.nodeListLength(child_nodes)); +// } + +// try w.objectField("documentURL"); +// try w.write(null); + +// try w.objectField("baseURL"); +// try w.write(null); + +// try w.objectField("xmlVersion"); +// try w.write(""); + +// try w.objectField("compatibilityMode"); +// try w.write("NoQuirksMode"); + +// try w.objectField("isScrollable"); +// try w.write(false); +// } +// }; + +// const testing = @import("testing.zig"); +// test "cdp Node: Registry register" { +// parser.init(); +// defer parser.deinit(); + +// var registry = Registry.init(testing.allocator); +// defer registry.deinit(); + +// try testing.expectEqual(0, registry.lookup_by_id.count()); +// try testing.expectEqual(0, registry.lookup_by_node.count()); + +// var doc = try testing.Document.init("link1

other

"); +// defer doc.deinit(); + +// { +// const n = (try doc.querySelector("#a1")).?; +// const node = try registry.register(n); +// const n1b = registry.lookup_by_id.get(1).?; +// const n1c = registry.lookup_by_node.get(node._node).?; +// try testing.expectEqual(node, n1b); +// try testing.expectEqual(node, n1c); + +// try testing.expectEqual(1, node.id); +// try testing.expectEqual(n, node._node); +// } + +// { +// const n = (try doc.querySelector("p")).?; +// const node = try registry.register(n); +// const n1b = registry.lookup_by_id.get(2).?; +// const n1c = registry.lookup_by_node.get(node._node).?; +// try testing.expectEqual(node, n1b); +// try testing.expectEqual(node, n1c); + +// try testing.expectEqual(2, node.id); +// try testing.expectEqual(n, node._node); +// } +// } + +// test "cdp Node: search list" { +// parser.init(); +// defer parser.deinit(); + +// var registry = Registry.init(testing.allocator); +// defer registry.deinit(); + +// var search_list = Search.List.init(testing.allocator, ®istry); +// defer search_list.deinit(); + +// { +// // empty search list, noops +// search_list.remove("0"); +// try testing.expectEqual(null, search_list.get("0")); +// } + +// { +// // empty nodes +// const s1 = try search_list.create(&.{}); +// try testing.expectEqual("0", s1.name); +// try testing.expectEqual(0, s1.node_ids.len); + +// const s2 = search_list.get("0").?; +// try testing.expectEqual("0", s2.name); +// try testing.expectEqual(0, s2.node_ids.len); + +// search_list.remove("0"); +// try testing.expectEqual(null, search_list.get("0")); +// } + +// { +// var doc = try testing.Document.init(""); +// defer doc.deinit(); + +// const s1 = try search_list.create(try doc.querySelectorAll("a")); +// try testing.expectEqual("1", s1.name); +// try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); + +// try testing.expectEqual(2, registry.lookup_by_id.count()); +// try testing.expectEqual(2, registry.lookup_by_node.count()); + +// const s2 = try search_list.create(try doc.querySelectorAll("#a1")); +// try testing.expectEqual("2", s2.name); +// try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); + +// const s3 = try search_list.create(try doc.querySelectorAll("#a2")); +// try testing.expectEqual("3", s3.name); +// try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); + +// try testing.expectEqual(2, registry.lookup_by_id.count()); +// try testing.expectEqual(2, registry.lookup_by_node.count()); +// } +// } + +// test "cdp Node: Writer" { +// parser.init(); +// defer parser.deinit(); + +// var registry = Registry.init(testing.allocator); +// defer registry.deinit(); + +// var doc = try testing.Document.init("
"); +// defer doc.deinit(); + +// { +// const node = try registry.register(doc.asNode()); +// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ +// .root = node, +// .depth = 0, +// .exclude_root = false, +// .registry = ®istry, +// }, .{}); +// defer testing.allocator.free(json); + +// try testing.expectJson(.{ +// .nodeId = 1, +// .backendNodeId = 1, +// .nodeType = 9, +// .nodeName = "#document", +// .localName = "", +// .nodeValue = "", +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .isScrollable = false, +// .compatibilityMode = "NoQuirksMode", +// .childNodeCount = 1, +// .children = &.{.{ +// .nodeId = 2, +// .backendNodeId = 2, +// .nodeType = 1, +// .nodeName = "HTML", +// .localName = "html", +// .nodeValue = "", +// .childNodeCount = 2, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// }}, +// }, json); +// } + +// { +// const node = registry.lookup_by_id.get(2).?; +// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ +// .root = node, +// .depth = 1, +// .exclude_root = false, +// .registry = ®istry, +// }, .{}); +// defer testing.allocator.free(json); + +// try testing.expectJson(.{ +// .nodeId = 2, +// .backendNodeId = 2, +// .nodeType = 1, +// .nodeName = "HTML", +// .localName = "html", +// .nodeValue = "", +// .childNodeCount = 2, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// .children = &.{ .{ +// .nodeId = 3, +// .backendNodeId = 3, +// .nodeType = 1, +// .nodeName = "HEAD", +// .localName = "head", +// .nodeValue = "", +// .childNodeCount = 0, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// .parentId = 2, +// }, .{ +// .nodeId = 4, +// .backendNodeId = 4, +// .nodeType = 1, +// .nodeName = "BODY", +// .localName = "body", +// .nodeValue = "", +// .childNodeCount = 2, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// .parentId = 2, +// } }, +// }, json); +// } + +// { +// const node = registry.lookup_by_id.get(2).?; +// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ +// .root = node, +// .depth = -1, +// .exclude_root = true, +// .registry = ®istry, +// }, .{}); +// defer testing.allocator.free(json); + +// try testing.expectJson(&.{ .{ +// .nodeId = 3, +// .backendNodeId = 3, +// .nodeType = 1, +// .nodeName = "HEAD", +// .localName = "head", +// .nodeValue = "", +// .childNodeCount = 0, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// .parentId = 2, +// }, .{ +// .nodeId = 4, +// .backendNodeId = 4, +// .nodeType = 1, +// .nodeName = "BODY", +// .localName = "body", +// .nodeValue = "", +// .childNodeCount = 2, +// .documentURL = null, +// .baseURL = null, +// .xmlVersion = "", +// .compatibilityMode = "NoQuirksMode", +// .isScrollable = false, +// .children = &.{ .{ +// .nodeId = 5, +// .localName = "a", +// .childNodeCount = 0, +// .parentId = 4, +// }, .{ +// .nodeId = 6, +// .localName = "div", +// .childNodeCount = 1, +// .parentId = 4, +// .children = &.{.{ +// .nodeId = 7, +// .localName = "a", +// .childNodeCount = 0, +// .parentId = 6, +// }}, +// } }, +// } }, json); +// } +// } diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 7b6590e8c..73c5e514b 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -24,12 +24,12 @@ const log = @import("../log.zig"); const js = @import("../browser/js/js.zig"); const polyfill = @import("../browser/polyfill/polyfill.zig"); -const App = @import("../app.zig").App; -const Browser = @import("../browser/browser.zig").Browser; -const Session = @import("../browser/session.zig").Session; -const Page = @import("../browser/page.zig").Page; +const App = @import("../App.zig"); +const Browser = @import("../browser/Browser.zig"); +const Session = @import("../browser/Session.zig"); +const Page = @import("../browser/Page.zig"); const Incrementing = @import("../id.zig").Incrementing; -const Notification = @import("../notification.zig").Notification; +const Notification = @import("../Notification.zig"); const LogInterceptor = @import("domains/log.zig").LogInterceptor; const InterceptState = @import("domains/fetch.zig").InterceptState; @@ -37,7 +37,7 @@ pub const URL_BASE = "chrome://newtab/"; pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C"; pub const CDP = CDPT(struct { - const Client = *@import("../server.zig").Client; + const Client = *@import("../Server.zig").Client; }); const SessionIdGen = Incrementing(u32, "SID"); @@ -117,7 +117,7 @@ pub fn CDPT(comptime TypeProvider: type) type { // timeouts (or http events) which are ready to be processed. pub fn hasPage() bool {} - pub fn pageWait(self: *Self, ms: i32) Session.WaitResult { + pub fn pageWait(self: *Self, ms: u32) Session.WaitResult { const session = &(self.browser.session orelse return .no_page); return session.wait(ms); } @@ -203,7 +203,8 @@ pub fn CDPT(comptime TypeProvider: type) type { }, 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command), - asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), + // @ZIGDOM + // asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { @@ -286,7 +287,8 @@ pub fn CDPT(comptime TypeProvider: type) type { } pub fn BrowserContext(comptime CDP_T: type) type { - const Node = @import("Node.zig"); + // @ZIGMOD + // const Node = @import("Node.zig"); return struct { id: []const u8, @@ -326,8 +328,9 @@ pub fn BrowserContext(comptime CDP_T: type) type { security_origin: []const u8, page_life_cycle_events: bool, secure_context_type: []const u8, - node_registry: Node.Registry, - node_search_list: Node.Search.List, + // @ZIGDOM + // node_registry: Node.Registry, + // node_search_list: Node.Search.List, inspector: js.Inspector, isolated_worlds: std.ArrayListUnmanaged(IsolatedWorld), @@ -360,8 +363,9 @@ pub fn BrowserContext(comptime CDP_T: type) type { const inspector = try cdp.browser.env.newInspector(arena, self); - var registry = Node.Registry.init(allocator); - errdefer registry.deinit(); + // @ZIGDOM + // var registry = Node.Registry.init(allocator); + // errdefer registry.deinit(); self.* = .{ .id = id, @@ -374,8 +378,9 @@ pub fn BrowserContext(comptime CDP_T: type) type { .secure_context_type = "Secure", // TODO = enum .loader_id = LOADER_ID, .page_life_cycle_events = false, // TODO; Target based value - .node_registry = registry, - .node_search_list = undefined, + // @ZIGDOM + // .node_registry = registry, + // .node_search_list = undefined, .isolated_worlds = .empty, .inspector = inspector, .notification_arena = cdp.notification_arena.allocator(), @@ -383,7 +388,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { .captured_responses = .empty, .log_interceptor = LogInterceptor(Self).init(allocator, self), }; - self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); + // ZIGDOM + // self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); errdefer self.deinit(); try cdp.browser.notification.register(.page_remove, self, onPageRemove); @@ -418,8 +424,9 @@ pub fn BrowserContext(comptime CDP_T: type) type { world.deinit(); } self.isolated_worlds.clearRetainingCapacity(); - self.node_registry.deinit(); - self.node_search_list.deinit(); + // @ZIGDOM + // self.node_registry.deinit(); + // self.node_search_list.deinit(); self.cdp.browser.notification.unregisterAll(self); if (self.http_proxy_changed) { @@ -433,8 +440,10 @@ pub fn BrowserContext(comptime CDP_T: type) type { } pub fn reset(self: *Self) void { - self.node_registry.reset(); - self.node_search_list.reset(); + // @ZIGDOM + _ = self; + // self.node_registry.reset(); + // self.node_search_list.reset(); } pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { @@ -453,19 +462,20 @@ pub fn BrowserContext(comptime CDP_T: type) type { return world; } - pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer { - return .{ - .root = root, - .depth = opts.depth, - .exclude_root = opts.exclude_root, - .registry = &self.node_registry, - }; - } + // @ZIGDOM + // pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer { + // return .{ + // .root = root, + // .depth = opts.depth, + // .exclude_root = opts.exclude_root, + // .registry = &self.node_registry, + // }; + // } - pub fn getURL(self: *const Self) ?[]const u8 { + pub fn getURL(self: *const Self) ?[:0]const u8 { const page = self.session.currentPage() orelse return null; - const raw_url = page.url.raw; - return if (raw_url.len == 0) null else raw_url; + const url = page.url; + return if (url.len == 0) null else url; } pub fn networkEnable(self: *Self) !void { diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 0f0ff8f8b..e99fd6b65 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -19,655 +19,656 @@ const std = @import("std"); const log = @import("../../log.zig"); const Allocator = std.mem.Allocator; -const Node = @import("../Node.zig"); -const css = @import("../../browser/dom/css.zig"); -const parser = @import("../../browser/netsurf.zig"); -const dom_node = @import("../../browser/dom/node.zig"); -const Element = @import("../../browser/dom/element.zig").Element; +// const css = @import("../../browser/dom/css.zig"); +// const parser = @import("../../browser/netsurf.zig"); +// const dom_node = @import("../../browser/dom/node.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - getDocument, - performSearch, - getSearchResults, - discardSearchResults, - querySelector, - querySelectorAll, - resolveNode, - describeNode, - scrollIntoViewIfNeeded, - getContentQuads, - getBoxModel, - requestChildNodes, - getFrameOwner, + // ZIGDOM + // getDocument, + // performSearch, + // getSearchResults, + // discardSearchResults, + // querySelector, + // querySelectorAll, + // resolveNode, + // describeNode, + // scrollIntoViewIfNeeded, + // getContentQuads, + // getBoxModel, + // requestChildNodes, + // getFrameOwner, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), - .getDocument => return getDocument(cmd), - .performSearch => return performSearch(cmd), - .getSearchResults => return getSearchResults(cmd), - .discardSearchResults => return discardSearchResults(cmd), - .querySelector => return querySelector(cmd), - .querySelectorAll => return querySelectorAll(cmd), - .resolveNode => return resolveNode(cmd), - .describeNode => return describeNode(cmd), - .scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd), - .getContentQuads => return getContentQuads(cmd), - .getBoxModel => return getBoxModel(cmd), - .requestChildNodes => return requestChildNodes(cmd), - .getFrameOwner => return getFrameOwner(cmd), + // @ZIGDOM + // .getDocument => return getDocument(cmd), + // .performSearch => return performSearch(cmd), + // .getSearchResults => return getSearchResults(cmd), + // .discardSearchResults => return discardSearchResults(cmd), + // .querySelector => return querySelector(cmd), + // .querySelectorAll => return querySelectorAll(cmd), + // .resolveNode => return resolveNode(cmd), + // .describeNode => return describeNode(cmd), + // .scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd), + // .getContentQuads => return getContentQuads(cmd), + // .getBoxModel => return getBoxModel(cmd), + // .requestChildNodes => return requestChildNodes(cmd), + // .getFrameOwner => return getFrameOwner(cmd), } } -// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument -fn getDocument(cmd: anytype) !void { - const Params = struct { - // CDP documentation implies that 0 isn't valid, but it _does_ work in Chrome - depth: i32 = 3, - pierce: bool = false, - }; - const params = try cmd.params(Params) orelse Params{}; - - if (params.pierce) { - log.warn(.cdp, "not implemented", .{ .feature = "DOM.getDocument: Not implemented pierce parameter" }); - } - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const doc = parser.documentHTMLToDocument(page.window.document); - - const node = try bc.node_registry.register(parser.documentToNode(doc)); - return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); -} - -// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch -fn performSearch(cmd: anytype) !void { - const params = (try cmd.params(struct { - query: []const u8, - includeUserAgentShadowDOM: ?bool = null, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const doc = parser.documentHTMLToDocument(page.window.document); - - const allocator = cmd.cdp.allocator; - var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query); - defer list.deinit(allocator); - - const search = try bc.node_search_list.create(list.nodes.items); - - // dispatch setChildNodesEvents to inform the client of the subpart of node - // tree covering the results. - try dispatchSetChildNodes(cmd, list.nodes.items); - - return cmd.sendResult(.{ - .searchId = search.name, - .resultCount = @as(u32, @intCast(search.node_ids.len)), - }, .{}); -} - -// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree -// hierarchy of each nodes. -// We dispatch event in the reverse order: from the top level to the direct parents. -// We should dispatch a node only if it has never been sent. -fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void { - const arena = cmd.arena; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const session_id = bc.session_id orelse return error.SessionIdNotLoaded; - - var parents: std.ArrayListUnmanaged(*Node) = .{}; - for (nodes) |_n| { - var n = _n; - while (true) { - const p = parser.nodeParentNode(n) orelse break; - - // Register the node. - const node = try bc.node_registry.register(p); - if (node.set_child_nodes_event) break; - try parents.append(arena, node); - n = p; - } - } - - const plen = parents.items.len; - if (plen == 0) return; - - var i: usize = plen; - // We're going to iterate in reverse order from how we added them. - // This ensures that we're emitting the tree of nodes top-down. - while (i > 0) { - i -= 1; - const node = parents.items[i]; - // Although our above loop won't add an already-sent node to `parents` - // this can still be true because two nodes can share the same parent node - // so we might have just sent the node a previous iteration of this loop - if (node.set_child_nodes_event) continue; - - node.set_child_nodes_event = true; - - // If the node has no parent, it's the root node. - // We don't dispatch event for it because we assume the root node is - // dispatched via the DOM.getDocument command. - const p = parser.nodeParentNode(node._node) orelse { - continue; - }; - - // Retrieve the parent from the registry. - const parent_node = try bc.node_registry.register(p); - - try cmd.sendEvent("DOM.setChildNodes", .{ - .parentId = parent_node.id, - .nodes = .{bc.nodeWriter(node, .{})}, - }, .{ - .session_id = session_id, - }); - } -} - -// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults -fn discardSearchResults(cmd: anytype) !void { - const params = (try cmd.params(struct { - searchId: []const u8, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - - bc.node_search_list.remove(params.searchId); - return cmd.sendResult(null, .{}); -} - -// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults -fn getSearchResults(cmd: anytype) !void { - const params = (try cmd.params(struct { - searchId: []const u8, - fromIndex: u32, - toIndex: u32, - })) orelse return error.InvalidParams; - - if (params.fromIndex >= params.toIndex) { - return error.BadIndices; - } - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - - const search = bc.node_search_list.get(params.searchId) orelse { - return error.SearchResultNotFound; - }; - - const node_ids = search.node_ids; - - if (params.fromIndex >= node_ids.len) return error.BadFromIndex; - if (params.toIndex > node_ids.len) return error.BadToIndex; - - return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{}); -} - -fn querySelector(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: Node.Id, - selector: []const u8, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - - const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { - return cmd.sendError(-32000, "Could not find node with given id", .{}); - }; - - const selected_node = try css.querySelector( - cmd.arena, - node._node, - params.selector, - ) orelse return error.NodeNotFoundForGivenId; - - const registered_node = try bc.node_registry.register(selected_node); - - // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. - var array = [1]*parser.Node{selected_node}; - try dispatchSetChildNodes(cmd, array[0..]); - - return cmd.sendResult(.{ - .nodeId = registered_node.id, - }, .{}); -} - -fn querySelectorAll(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: Node.Id, - selector: []const u8, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - - const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { - return cmd.sendError(-32000, "Could not find node with given id", .{}); - }; - - const arena = cmd.arena; - const selected_nodes = try css.querySelectorAll(arena, node._node, params.selector); - const nodes = selected_nodes.nodes.items; - - const node_ids = try arena.alloc(Node.Id, nodes.len); - for (nodes, node_ids) |selected_node, *node_id| { - node_id.* = (try bc.node_registry.register(selected_node)).id; - } - - // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. - try dispatchSetChildNodes(cmd, nodes); - - return cmd.sendResult(.{ - .nodeIds = node_ids, - }, .{}); -} - -fn resolveNode(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: ?Node.Id = null, - backendNodeId: ?u32 = null, - objectGroup: ?[]const u8 = null, - executionContextId: ?u32 = null, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - - var js_context = page.js; - if (params.executionContextId) |context_id| { - if (js_context.v8_context.debugContextId() != context_id) { - for (bc.isolated_worlds.items) |*isolated_world| { - js_context = &(isolated_world.executor.context orelse return error.ContextNotFound); - if (js_context.v8_context.debugContextId() == context_id) { - break; - } - } else return error.ContextNotFound; - } - } - - const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; - const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; - - // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement - // So we use the Node.Union when retrieve the value from the environment - const remote_object = try bc.inspector.getRemoteObject( - js_context, - params.objectGroup orelse "", - try dom_node.Node.toInterface(node._node), - ); - defer remote_object.deinit(); - - const arena = cmd.arena; - return cmd.sendResult(.{ .object = .{ - .type = try remote_object.getType(arena), - .subtype = try remote_object.getSubtype(arena), - .className = try remote_object.getClassName(arena), - .description = try remote_object.getDescription(arena), - .objectId = try remote_object.getObjectId(arena), - } }, .{}); -} - -fn describeNode(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: ?Node.Id = null, - backendNodeId: ?Node.Id = null, - objectId: ?[]const u8 = null, - depth: i32 = 1, - pierce: bool = false, - })) orelse return error.InvalidParams; - - if (params.pierce) { - log.warn(.cdp, "not implemented", .{ .feature = "DOM.describeNode: Not implemented pierce parameter" }); - } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - - const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - - return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); -} - -// An array of quad vertices, x immediately followed by y for each point, points clock-wise. -// Note Y points downward -// We are assuming the start/endpoint is not repeated. -const Quad = [8]f64; - -const BoxModel = struct { - content: Quad, - padding: Quad, - border: Quad, - margin: Quad, - width: i32, - height: i32, - // shapeOutside: ?ShapeOutsideInfo, -}; - -fn rectToQuad(rect: Element.DOMRect) Quad { - return Quad{ - rect.x, - rect.y, - rect.x + rect.width, - rect.y, - rect.x + rect.width, - rect.y + rect.height, - rect.x, - rect.y + rect.height, - }; -} - -fn scrollIntoViewIfNeeded(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: ?Node.Id = null, - backendNodeId: ?u32 = null, - objectId: ?[]const u8 = null, - rect: ?Element.DOMRect = null, - })) orelse return error.InvalidParams; - // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null - - // We retrieve the node to at least check if it exists and is valid. - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - - const node_type = parser.nodeType(node._node); - switch (node_type) { - .element => {}, - .document => {}, - .text => {}, - else => return error.NodeDoesNotHaveGeometry, - } - - return cmd.sendResult(null, .{}); -} - -fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node { - const input_node_id = node_id orelse backend_node_id; - if (input_node_id) |input_node_id_| { - return browser_context.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound; - } - if (object_id) |object_id_| { - // Retrieve the object from which ever context it is in. - const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_); - return try browser_context.node_registry.register(@ptrCast(@alignCast(parser_node))); - } - return error.MissingParams; -} - -// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads -// Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface -fn getContentQuads(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: ?Node.Id = null, - backendNodeId: ?Node.Id = null, - objectId: ?[]const u8 = null, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - - const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - - // TODO likely if the following CSS properties are set the quads should be empty - // visibility: hidden - // display: none - - if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; - // TODO implement for document or text - // Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example. - // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? - // Elements like SVGElement may have multiple quads. - - const element = parser.nodeToElement(node._node); - const rect = try Element._getBoundingClientRect(element, page); - const quad = rectToQuad(rect); - - return cmd.sendResult(.{ .quads = &.{quad} }, .{}); -} - -fn getBoxModel(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: ?Node.Id = null, - backendNodeId: ?u32 = null, - objectId: ?[]const u8 = null, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - - const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - - // TODO implement for document or text - if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; - const element = parser.nodeToElement(node._node); - - const rect = try Element._getBoundingClientRect(element, page); - const quad = rectToQuad(rect); - - return cmd.sendResult(.{ .model = BoxModel{ - .content = quad, - .padding = quad, - .border = quad, - .margin = quad, - .width = @intFromFloat(rect.width), - .height = @intFromFloat(rect.height), - } }, .{}); -} - -fn requestChildNodes(cmd: anytype) !void { - const params = (try cmd.params(struct { - nodeId: Node.Id, - depth: i32 = 1, - pierce: bool = false, - })) orelse return error.InvalidParams; - - if (params.depth == 0) return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const session_id = bc.session_id orelse return error.SessionIdNotLoaded; - const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { - return error.InvalidNode; - }; - - try cmd.sendEvent("DOM.setChildNodes", .{ - .parentId = node.id, - .nodes = bc.nodeWriter(node, .{ .depth = params.depth, .exclude_root = true }), - }, .{ - .session_id = session_id, - }); - - return cmd.sendResult(null, .{}); -} - -fn getFrameOwner(cmd: anytype) !void { - const params = (try cmd.params(struct { - frameId: []const u8, - })) orelse return error.InvalidParams; - - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const target_id = bc.target_id orelse return error.TargetNotLoaded; - if (std.mem.eql(u8, target_id, params.frameId) == false) { - return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); - } - - const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const doc = parser.documentHTMLToDocument(page.window.document); - - const node = try bc.node_registry.register(parser.documentToNode(doc)); - return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); -} - -const testing = @import("../testing.zig"); - -test "cdp.dom: getSearchResults unknown search id" { - var ctx = testing.context(); - defer ctx.deinit(); - - try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ - .id = 8, - .method = "DOM.getSearchResults", - .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, - })); -} - -test "cdp.dom: search flow" { - var ctx = testing.context(); - defer ctx.deinit(); - - _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - - try ctx.processMessage(.{ - .id = 12, - .method = "DOM.performSearch", - .params = .{ .query = "p" }, - }); - try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 }); - - { - // getSearchResults - try ctx.processMessage(.{ - .id = 13, - .method = "DOM.getSearchResults", - .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 }, - }); - try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2 } }, .{ .id = 13 }); - - // different fromIndex - try ctx.processMessage(.{ - .id = 14, - .method = "DOM.getSearchResults", - .params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 }, - }); - try ctx.expectSentResult(.{ .nodeIds = &.{2} }, .{ .id = 14 }); - - // different toIndex - try ctx.processMessage(.{ - .id = 15, - .method = "DOM.getSearchResults", - .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, - }); - try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 15 }); - } - - try ctx.processMessage(.{ - .id = 16, - .method = "DOM.discardSearchResults", - .params = .{ .searchId = "0" }, - }); - try ctx.expectSentResult(null, .{ .id = 16 }); - - // make sure the delete actually did something - try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{ - .id = 17, - .method = "DOM.getSearchResults", - .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, - })); -} - -test "cdp.dom: querySelector unknown search id" { - var ctx = testing.context(); - defer ctx.deinit(); - - _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - - try ctx.processMessage(.{ - .id = 9, - .method = "DOM.querySelector", - .params = .{ .nodeId = 99, .selector = "" }, - }); - try ctx.expectSentError(-32000, "Could not find node with given id", .{}); - - try ctx.processMessage(.{ - .id = 9, - .method = "DOM.querySelectorAll", - .params = .{ .nodeId = 99, .selector = "" }, - }); - try ctx.expectSentError(-32000, "Could not find node with given id", .{}); -} - -test "cdp.dom: querySelector Node not found" { - var ctx = testing.context(); - defer ctx.deinit(); - - _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - - try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry - .id = 3, - .method = "DOM.performSearch", - .params = .{ .query = "p" }, - }); - try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 }); - - try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{ - .id = 4, - .method = "DOM.querySelector", - .params = .{ .nodeId = 1, .selector = "a" }, - })); - - try ctx.processMessage(.{ - .id = 5, - .method = "DOM.querySelectorAll", - .params = .{ .nodeId = 1, .selector = "a" }, - }); - try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 }); -} - -test "cdp.dom: querySelector Nodes found" { - var ctx = testing.context(); - defer ctx.deinit(); - - _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); - - try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry - .id = 3, - .method = "DOM.performSearch", - .params = .{ .query = "div" }, - }); - try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 }); - - try ctx.processMessage(.{ - .id = 4, - .method = "DOM.querySelector", - .params = .{ .nodeId = 1, .selector = "p" }, - }); - try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); - try ctx.expectSentResult(.{ .nodeId = 6 }, .{ .id = 4 }); - - try ctx.processMessage(.{ - .id = 5, - .method = "DOM.querySelectorAll", - .params = .{ .nodeId = 1, .selector = "p" }, - }); - try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); - try ctx.expectSentResult(.{ .nodeIds = &.{6} }, .{ .id = 5 }); -} - -test "cdp.dom: getBoxModel" { - var ctx = testing.context(); - defer ctx.deinit(); - - _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); - - try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry - .id = 3, - .method = "DOM.getDocument", - }); - - try ctx.processMessage(.{ - .id = 4, - .method = "DOM.querySelector", - .params = .{ .nodeId = 1, .selector = "p" }, - }); - try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 }); - - try ctx.processMessage(.{ - .id = 5, - .method = "DOM.getBoxModel", - .params = .{ .nodeId = 6 }, - }); - try ctx.expectSentResult(.{ .model = BoxModel{ - .content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .width = 1, - .height = 1, - } }, .{ .id = 5 }); -} +// ZIGDOM +// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument +// fn getDocument(cmd: anytype) !void { +// const Params = struct { +// // CDP documentation implies that 0 isn't valid, but it _does_ work in Chrome +// depth: i32 = 3, +// pierce: bool = false, +// }; +// const params = try cmd.params(Params) orelse Params{}; + +// if (params.pierce) { +// log.warn(.cdp, "not implemented", .{ .feature = "DOM.getDocument: Not implemented pierce parameter" }); +// } + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; +// const doc = parser.documentHTMLToDocument(page.window.document); + +// const node = try bc.node_registry.register(parser.documentToNode(doc)); +// return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); +// } + +// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch +// fn performSearch(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// query: []const u8, +// includeUserAgentShadowDOM: ?bool = null, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; +// const doc = parser.documentHTMLToDocument(page.window.document); + +// const allocator = cmd.cdp.allocator; +// var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query); +// defer list.deinit(allocator); + +// const search = try bc.node_search_list.create(list.nodes.items); + +// // dispatch setChildNodesEvents to inform the client of the subpart of node +// // tree covering the results. +// try dispatchSetChildNodes(cmd, list.nodes.items); + +// return cmd.sendResult(.{ +// .searchId = search.name, +// .resultCount = @as(u32, @intCast(search.node_ids.len)), +// }, .{}); +// } + +// // dispatchSetChildNodes send the setChildNodes event for the whole DOM tree +// // hierarchy of each nodes. +// // We dispatch event in the reverse order: from the top level to the direct parents. +// // We should dispatch a node only if it has never been sent. +// fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void { +// const arena = cmd.arena; +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const session_id = bc.session_id orelse return error.SessionIdNotLoaded; + +// var parents: std.ArrayListUnmanaged(*Node) = .{}; +// for (nodes) |_n| { +// var n = _n; +// while (true) { +// const p = parser.nodeParentNode(n) orelse break; + +// // Register the node. +// const node = try bc.node_registry.register(p); +// if (node.set_child_nodes_event) break; +// try parents.append(arena, node); +// n = p; +// } +// } + +// const plen = parents.items.len; +// if (plen == 0) return; + +// var i: usize = plen; +// // We're going to iterate in reverse order from how we added them. +// // This ensures that we're emitting the tree of nodes top-down. +// while (i > 0) { +// i -= 1; +// const node = parents.items[i]; +// // Although our above loop won't add an already-sent node to `parents` +// // this can still be true because two nodes can share the same parent node +// // so we might have just sent the node a previous iteration of this loop +// if (node.set_child_nodes_event) continue; + +// node.set_child_nodes_event = true; + +// // If the node has no parent, it's the root node. +// // We don't dispatch event for it because we assume the root node is +// // dispatched via the DOM.getDocument command. +// const p = parser.nodeParentNode(node._node) orelse { +// continue; +// }; + +// // Retrieve the parent from the registry. +// const parent_node = try bc.node_registry.register(p); + +// try cmd.sendEvent("DOM.setChildNodes", .{ +// .parentId = parent_node.id, +// .nodes = .{bc.nodeWriter(node, .{})}, +// }, .{ +// .session_id = session_id, +// }); +// } +// } + +// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults +// fn discardSearchResults(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// searchId: []const u8, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + +// bc.node_search_list.remove(params.searchId); +// return cmd.sendResult(null, .{}); +// } + +// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults +// fn getSearchResults(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// searchId: []const u8, +// fromIndex: u32, +// toIndex: u32, +// })) orelse return error.InvalidParams; + +// if (params.fromIndex >= params.toIndex) { +// return error.BadIndices; +// } + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + +// const search = bc.node_search_list.get(params.searchId) orelse { +// return error.SearchResultNotFound; +// }; + +// const node_ids = search.node_ids; + +// if (params.fromIndex >= node_ids.len) return error.BadFromIndex; +// if (params.toIndex > node_ids.len) return error.BadToIndex; + +// return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{}); +// } + +// fn querySelector(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: Node.Id, +// selector: []const u8, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + +// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { +// return cmd.sendError(-32000, "Could not find node with given id", .{}); +// }; + +// const selected_node = try css.querySelector( +// cmd.arena, +// node._node, +// params.selector, +// ) orelse return error.NodeNotFoundForGivenId; + +// const registered_node = try bc.node_registry.register(selected_node); + +// // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. +// var array = [1]*parser.Node{selected_node}; +// try dispatchSetChildNodes(cmd, array[0..]); + +// return cmd.sendResult(.{ +// .nodeId = registered_node.id, +// }, .{}); +// } + +// fn querySelectorAll(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: Node.Id, +// selector: []const u8, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + +// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { +// return cmd.sendError(-32000, "Could not find node with given id", .{}); +// }; + +// const arena = cmd.arena; +// const selected_nodes = try css.querySelectorAll(arena, node._node, params.selector); +// const nodes = selected_nodes.nodes.items; + +// const node_ids = try arena.alloc(Node.Id, nodes.len); +// for (nodes, node_ids) |selected_node, *node_id| { +// node_id.* = (try bc.node_registry.register(selected_node)).id; +// } + +// // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. +// try dispatchSetChildNodes(cmd, nodes); + +// return cmd.sendResult(.{ +// .nodeIds = node_ids, +// }, .{}); +// } + +// fn resolveNode(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: ?Node.Id = null, +// backendNodeId: ?u32 = null, +// objectGroup: ?[]const u8 = null, +// executionContextId: ?u32 = null, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; + +// var js_context = page.js; +// if (params.executionContextId) |context_id| { +// if (js_context.v8_context.debugContextId() != context_id) { +// for (bc.isolated_worlds.items) |*isolated_world| { +// js_context = &(isolated_world.executor.context orelse return error.ContextNotFound); +// if (js_context.v8_context.debugContextId() == context_id) { +// break; +// } +// } else return error.ContextNotFound; +// } +// } + +// const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; +// const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; + +// // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement +// // So we use the Node.Union when retrieve the value from the environment +// const remote_object = try bc.inspector.getRemoteObject( +// js_context, +// params.objectGroup orelse "", +// try dom_node.Node.toInterface(node._node), +// ); +// defer remote_object.deinit(); + +// const arena = cmd.arena; +// return cmd.sendResult(.{ .object = .{ +// .type = try remote_object.getType(arena), +// .subtype = try remote_object.getSubtype(arena), +// .className = try remote_object.getClassName(arena), +// .description = try remote_object.getDescription(arena), +// .objectId = try remote_object.getObjectId(arena), +// } }, .{}); +// } + +// fn describeNode(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: ?Node.Id = null, +// backendNodeId: ?Node.Id = null, +// objectId: ?[]const u8 = null, +// depth: i32 = 1, +// pierce: bool = false, +// })) orelse return error.InvalidParams; + +// if (params.pierce) { +// log.warn(.cdp, "not implemented", .{ .feature = "DOM.describeNode: Not implemented pierce parameter" }); +// } +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + +// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + +// return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); +// } + +// // An array of quad vertices, x immediately followed by y for each point, points clock-wise. +// // Note Y points downward +// // We are assuming the start/endpoint is not repeated. +// const Quad = [8]f64; + +// const BoxModel = struct { +// content: Quad, +// padding: Quad, +// border: Quad, +// margin: Quad, +// width: i32, +// height: i32, +// // shapeOutside: ?ShapeOutsideInfo, +// }; + +// fn rectToQuad(rect: Element.DOMRect) Quad { +// return Quad{ +// rect.x, +// rect.y, +// rect.x + rect.width, +// rect.y, +// rect.x + rect.width, +// rect.y + rect.height, +// rect.x, +// rect.y + rect.height, +// }; +// } + +// fn scrollIntoViewIfNeeded(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: ?Node.Id = null, +// backendNodeId: ?u32 = null, +// objectId: ?[]const u8 = null, +// rect: ?Element.DOMRect = null, +// })) orelse return error.InvalidParams; +// // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null + +// // We retrieve the node to at least check if it exists and is valid. +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + +// const node_type = parser.nodeType(node._node); +// switch (node_type) { +// .element => {}, +// .document => {}, +// .text => {}, +// else => return error.NodeDoesNotHaveGeometry, +// } + +// return cmd.sendResult(null, .{}); +// } + +// fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node { +// const input_node_id = node_id orelse backend_node_id; +// if (input_node_id) |input_node_id_| { +// return browser_context.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound; +// } +// if (object_id) |object_id_| { +// // Retrieve the object from which ever context it is in. +// const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_); +// return try browser_context.node_registry.register(@ptrCast(@alignCast(parser_node))); +// } +// return error.MissingParams; +// } + +// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads +// // Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface +// fn getContentQuads(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: ?Node.Id = null, +// backendNodeId: ?Node.Id = null, +// objectId: ?[]const u8 = null, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; + +// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + +// // TODO likely if the following CSS properties are set the quads should be empty +// // visibility: hidden +// // display: none + +// if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; +// // TODO implement for document or text +// // Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example. +// // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? +// // Elements like SVGElement may have multiple quads. + +// const element = parser.nodeToElement(node._node); +// const rect = try Element._getBoundingClientRect(element, page); +// const quad = rectToQuad(rect); + +// return cmd.sendResult(.{ .quads = &.{quad} }, .{}); +// } + +// fn getBoxModel(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: ?Node.Id = null, +// backendNodeId: ?u32 = null, +// objectId: ?[]const u8 = null, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; + +// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + +// // TODO implement for document or text +// if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; +// const element = parser.nodeToElement(node._node); + +// const rect = try Element._getBoundingClientRect(element, page); +// const quad = rectToQuad(rect); + +// return cmd.sendResult(.{ .model = BoxModel{ +// .content = quad, +// .padding = quad, +// .border = quad, +// .margin = quad, +// .width = @intFromFloat(rect.width), +// .height = @intFromFloat(rect.height), +// } }, .{}); +// } + +// fn requestChildNodes(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// nodeId: Node.Id, +// depth: i32 = 1, +// pierce: bool = false, +// })) orelse return error.InvalidParams; + +// if (params.depth == 0) return error.InvalidParams; +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const session_id = bc.session_id orelse return error.SessionIdNotLoaded; +// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { +// return error.InvalidNode; +// }; + +// try cmd.sendEvent("DOM.setChildNodes", .{ +// .parentId = node.id, +// .nodes = bc.nodeWriter(node, .{ .depth = params.depth, .exclude_root = true }), +// }, .{ +// .session_id = session_id, +// }); + +// return cmd.sendResult(null, .{}); +// } + +// fn getFrameOwner(cmd: anytype) !void { +// const params = (try cmd.params(struct { +// frameId: []const u8, +// })) orelse return error.InvalidParams; + +// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; +// const target_id = bc.target_id orelse return error.TargetNotLoaded; +// if (std.mem.eql(u8, target_id, params.frameId) == false) { +// return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); +// } + +// const page = bc.session.currentPage() orelse return error.PageNotLoaded; +// const doc = parser.documentHTMLToDocument(page.window.document); + +// const node = try bc.node_registry.register(parser.documentToNode(doc)); +// return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); +// } + +// const testing = @import("../testing.zig"); + +// test "cdp.dom: getSearchResults unknown search id" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ +// .id = 8, +// .method = "DOM.getSearchResults", +// .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, +// })); +// } + +// test "cdp.dom: search flow" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); + +// try ctx.processMessage(.{ +// .id = 12, +// .method = "DOM.performSearch", +// .params = .{ .query = "p" }, +// }); +// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 }); + +// { +// // getSearchResults +// try ctx.processMessage(.{ +// .id = 13, +// .method = "DOM.getSearchResults", +// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 }, +// }); +// try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2 } }, .{ .id = 13 }); + +// // different fromIndex +// try ctx.processMessage(.{ +// .id = 14, +// .method = "DOM.getSearchResults", +// .params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 }, +// }); +// try ctx.expectSentResult(.{ .nodeIds = &.{2} }, .{ .id = 14 }); + +// // different toIndex +// try ctx.processMessage(.{ +// .id = 15, +// .method = "DOM.getSearchResults", +// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, +// }); +// try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 15 }); +// } + +// try ctx.processMessage(.{ +// .id = 16, +// .method = "DOM.discardSearchResults", +// .params = .{ .searchId = "0" }, +// }); +// try ctx.expectSentResult(null, .{ .id = 16 }); + +// // make sure the delete actually did something +// try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{ +// .id = 17, +// .method = "DOM.getSearchResults", +// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, +// })); +// } + +// test "cdp.dom: querySelector unknown search id" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); + +// try ctx.processMessage(.{ +// .id = 9, +// .method = "DOM.querySelector", +// .params = .{ .nodeId = 99, .selector = "" }, +// }); +// try ctx.expectSentError(-32000, "Could not find node with given id", .{}); + +// try ctx.processMessage(.{ +// .id = 9, +// .method = "DOM.querySelectorAll", +// .params = .{ .nodeId = 99, .selector = "" }, +// }); +// try ctx.expectSentError(-32000, "Could not find node with given id", .{}); +// } + +// test "cdp.dom: querySelector Node not found" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); + +// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry +// .id = 3, +// .method = "DOM.performSearch", +// .params = .{ .query = "p" }, +// }); +// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 }); + +// try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{ +// .id = 4, +// .method = "DOM.querySelector", +// .params = .{ .nodeId = 1, .selector = "a" }, +// })); + +// try ctx.processMessage(.{ +// .id = 5, +// .method = "DOM.querySelectorAll", +// .params = .{ .nodeId = 1, .selector = "a" }, +// }); +// try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 }); +// } + +// test "cdp.dom: querySelector Nodes found" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); + +// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry +// .id = 3, +// .method = "DOM.performSearch", +// .params = .{ .query = "div" }, +// }); +// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 }); + +// try ctx.processMessage(.{ +// .id = 4, +// .method = "DOM.querySelector", +// .params = .{ .nodeId = 1, .selector = "p" }, +// }); +// try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); +// try ctx.expectSentResult(.{ .nodeId = 6 }, .{ .id = 4 }); + +// try ctx.processMessage(.{ +// .id = 5, +// .method = "DOM.querySelectorAll", +// .params = .{ .nodeId = 1, .selector = "p" }, +// }); +// try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); +// try ctx.expectSentResult(.{ .nodeIds = &.{6} }, .{ .id = 5 }); +// } + +// test "cdp.dom: getBoxModel" { +// var ctx = testing.context(); +// defer ctx.deinit(); + +// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); + +// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry +// .id = 3, +// .method = "DOM.getDocument", +// }); + +// try ctx.processMessage(.{ +// .id = 4, +// .method = "DOM.querySelector", +// .params = .{ .nodeId = 1, .selector = "p" }, +// }); +// try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 }); + +// try ctx.processMessage(.{ +// .id = 5, +// .method = "DOM.getBoxModel", +// .params = .{ .nodeId = 6 }, +// }); +// try ctx.expectSentResult(.{ .model = BoxModel{ +// .content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, +// .padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, +// .border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, +// .margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, +// .width = 1, +// .height = 1, +// } }, .{ .id = 5 }); +// } diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index f6fb302b9..ef11e15de 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -23,7 +23,7 @@ const log = @import("../../log.zig"); const network = @import("network.zig"); const Http = @import("../../http/Http.zig"); -const Notification = @import("../../notification.zig").Notification; +const Notification = @import("../../Notification.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig index d81fb1c8c..b4f2990a0 100644 --- a/src/cdp/domains/input.zig +++ b/src/cdp/domains/input.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . const std = @import("std"); -const Page = @import("../../browser/page.zig").Page; +const Page = @import("../../browser/Page.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { diff --git a/src/cdp/domains/log.zig b/src/cdp/domains/log.zig index 368a79545..07d3c6d65 100644 --- a/src/cdp/domains/log.zig +++ b/src/cdp/domains/log.zig @@ -101,7 +101,7 @@ pub fn LogInterceptor(comptime BC: type) type { .fatal => "error", }, .text = self.allocating.written(), - .timestamp = @import("../../datetime.zig").milliTimestamp(), + .timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic), }, }, .{ .session_id = self.bc.session_id, diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 0d7014d0e..c41d19887 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -21,7 +21,7 @@ const Allocator = std.mem.Allocator; const CdpStorage = @import("storage.zig"); const Transfer = @import("../../http/Client.zig").Transfer; -const Notification = @import("../../notification.zig").Notification; +const Notification = @import("../../Notification.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -87,7 +87,7 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -const Cookie = @import("../../browser/storage/storage.zig").Cookie; +const Cookie = @import("../../browser/webapi/storage/storage.zig").Cookie; // Only matches the cookie on provided parameters fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { @@ -173,7 +173,7 @@ fn getCookies(cmd: anytype) !void { const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; // If not specified, use the URLs of the page and all of its subframes. TODO subframes - const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL + const page_url = if (bc.session.page) |page| page.url else null; const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams}; var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len); @@ -247,7 +247,7 @@ pub fn httpRequestStart(arena: Allocator, bc: anytype, msg: *const Notification. .requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}), .frameId = target_id, .loaderId = bc.loader_id, - .documentUrl = DocumentUrlWriter.init(&page.url.uri), + .documentUrl = page.url, .request = TransferAsRequestWriter.init(transfer), .initiator = .{ .type = "other" }, }, .{ .session_id = session_id }); @@ -416,34 +416,35 @@ const TransferAsResponseWriter = struct { } }; -const DocumentUrlWriter = struct { - uri: *std.Uri, - - fn init(uri: *std.Uri) DocumentUrlWriter { - return .{ - .uri = uri, - }; - } - - pub fn jsonStringify(self: *const DocumentUrlWriter, jws: anytype) !void { - self._jsonStringify(jws) catch return error.WriteFailed; - } - fn _jsonStringify(self: *const DocumentUrlWriter, jws: anytype) !void { - const writer = jws.writer; - - try jws.beginWriteRaw(); - try writer.writeByte('\"'); - try self.uri.writeToStream(writer, .{ - .scheme = true, - .authentication = true, - .authority = true, - .path = true, - .query = true, - }); - try writer.writeByte('\"'); - jws.endWriteRaw(); - } -}; +// @ZIGDOM - do we still need this? just send the full URL? +// const DocumentUrlWriter = struct { +// uri: *std.Uri, + +// fn init(uri: *std.Uri) DocumentUrlWriter { +// return .{ +// .uri = uri, +// }; +// } + +// pub fn jsonStringify(self: *const DocumentUrlWriter, jws: anytype) !void { +// self._jsonStringify(jws) catch return error.WriteFailed; +// } +// fn _jsonStringify(self: *const DocumentUrlWriter, jws: anytype) !void { +// const writer = jws.writer; + +// try jws.beginWriteRaw(); +// try writer.writeByte('\"'); +// try self.uri.writeToStream(writer, .{ +// .scheme = true, +// .authentication = true, +// .authority = true, +// .path = true, +// .query = true, +// }); +// try writer.writeByte('\"'); +// jws.endWriteRaw(); +// } +// }; fn idFromRequestId(request_id: []const u8) !u64 { if (!std.mem.startsWith(u8, request_id, "REQ-")) { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 1f6b720aa..7107d6866 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -17,8 +17,8 @@ // along with this program. If not, see . const std = @import("std"); -const Page = @import("../../browser/page.zig").Page; -const Notification = @import("../../notification.zig").Notification; +const Page = @import("../../browser/Page.zig"); +const Notification = @import("../../Notification.zig"); const Allocator = std.mem.Allocator; @@ -134,7 +134,7 @@ fn createIsolatedWorld(cmd: anytype) !void { fn navigate(cmd: anytype) !void { const params = (try cmd.params(struct { - url: []const u8, + url: [:0]const u8, // referrer: ?[]const u8 = null, // transitionType: ?[]const u8 = null, // TODO: enum // frameId: ?[]const u8 = null, @@ -253,7 +253,8 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa bc.inspector.contextCreated( page.js, "", - try page.origin(arena), + "", // @ZIGDOM + // try page.origin(arena), aux_data, true, ); @@ -360,7 +361,7 @@ pub fn pageNetworkAlmostIdle(bc: anytype, event: *const Notification.PageNetwork return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp); } -fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u32) !void { +fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u64) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; @@ -379,7 +380,7 @@ const LifecycleEvent = struct { frameId: []const u8, loaderId: ?[]const u8, name: []const u8, - timestamp: u32, + timestamp: u64, }; const testing = @import("../testing.zig"); diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 662d079f0..83547502a 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -19,9 +19,9 @@ const std = @import("std"); const log = @import("../../log.zig"); -const Cookie = @import("../../browser/storage/storage.zig").Cookie; -const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; -pub const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri; +const Cookie = @import("../../browser/webapi/storage/storage.zig").Cookie; +const CookieJar = @import("../../browser/webapi/storage/storage.zig").Jar; +pub const PreparedUri = @import("../../browser/webapi/storage/cookie.zig").PreparedUri; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 26f4cfbe3..3ea78b718 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -143,13 +143,14 @@ fn createTarget(cmd: anytype) !void { bc.target_id = target_id; - var page = try bc.session.createPage(); + const page = try bc.session.createPage(); { const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id}); bc.inspector.contextCreated( page.js, "", - try page.origin(cmd.arena), + "", // @ZIGDOM + // try page.origin(arena), aux_data, true, ); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 0c052d12a..7c086f6f2 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -24,7 +24,6 @@ const ArenaAllocator = std.heap.ArenaAllocator; const Testing = @This(); const main = @import("cdp.zig"); -const parser = @import("../browser/netsurf.zig"); const base = @import("../testing.zig"); pub const allocator = base.allocator; diff --git a/src/http/Client.zig b/src/http/Client.zig index fe0a5a1f7..65f310667 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -176,7 +176,7 @@ pub fn abort(self: *Client) void { } } -pub fn tick(self: *Client, timeout_ms: i32) !PerformStatus { +pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus { while (true) { if (self.handles.hasAvailable() == false) { break; @@ -188,7 +188,7 @@ pub fn tick(self: *Client, timeout_ms: i32) !PerformStatus { const handle = self.handles.getFreeHandle().?; try self.makeRequest(handle, transfer); } - return self.perform(timeout_ms); + return self.perform(@intCast(timeout_ms)); } pub fn request(self: *Client, req: Request) !void { diff --git a/src/http/Http.zig b/src/http/Http.zig index 17b481d09..e5be87ee2 100644 --- a/src/http/Http.zig +++ b/src/http/Http.zig @@ -83,7 +83,7 @@ pub fn deinit(self: *Http) void { self.arena.deinit(); } -pub fn poll(self: *Http, timeout_ms: i32) Client.PerformStatus { +pub fn poll(self: *Http, timeout_ms: u32) Client.PerformStatus { return self.client.tick(timeout_ms) catch |err| { log.err(.app, "http poll", .{ .err = err }); return .normal; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 54e425735..f037ce3e0 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -1,5 +1,7 @@ const std = @import("std"); pub const App = @import("App.zig"); +pub const Server = @import("Server.zig"); + pub const log = @import("log.zig"); pub const dump = @import("browser/dump.zig"); pub const build_config = @import("build_config"); diff --git a/src/main.zig b/src/main.zig index 6c90196d2..1da7af4bc 100644 --- a/src/main.zig +++ b/src/main.zig @@ -99,27 +99,24 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { app.telemetry.record(.{ .run = {} }); switch (args.mode) { - .serve => { - return; - // @ZIGDOM-CDP - // .serve => |opts| { - // log.debug(.app, "startup", .{ .mode = "serve" }); - // const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| { - // log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); - // return args.printUsageAndExit(false); - // }; - - // // _server is global to handle graceful shutdown. - // _server = try lp.Server.init(app, address); - // const server = &_server.?; - // defer server.deinit(); - - // // max timeout of 1 week. - // const timeout = if (opts.timeout > 604_800) 604_800_000 else @as(i32, opts.timeout) * 1000; - // server.run(address, timeout) catch |err| { - // log.fatal(.app, "server run error", .{ .err = err }); - // return err; - // }; + .serve => |opts| { + log.debug(.app, "startup", .{ .mode = "serve" }); + const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| { + log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); + return args.printUsageAndExit(false); + }; + + // _server is global to handle graceful shutdown. + _server = try lp.Server.init(app, address); + const server = &_server.?; + defer server.deinit(); + + // max timeout of 1 week. + const timeout = if (opts.timeout > 604_800) 604_800_000 else @as(u32, opts.timeout) * 1000; + server.run(address, timeout) catch |err| { + log.fatal(.app, "server run error", .{ .err = err }); + return err; + }; }, .fetch => |opts| { const url = opts.url; diff --git a/src/server.zig b/src/server.zig index afb55e434..4d42f0010 100644 --- a/src/server.zig +++ b/src/server.zig @@ -26,7 +26,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const log = @import("log.zig"); -const App = @import("app.zig").App; +const App = @import("App.zig"); const CDP = @import("cdp/cdp.zig").CDP; const MAX_HTTP_REQUEST_SIZE = 4096; @@ -69,7 +69,7 @@ pub fn deinit(self: *Server) void { self.allocator.free(self.json_version_response); } -pub fn run(self: *Server, address: net.Address, timeout_ms: i32) !void { +pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void { const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC; const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); self.listener = listener; @@ -112,7 +112,7 @@ pub fn run(self: *Server, address: net.Address, timeout_ms: i32) !void { } } -fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: i32) !void { +fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { // This shouldn't be necessary, but the Client is HUGE (> 512KB) because // it has a large read buffer. I don't know why, but v8 crashes if this // is on the stack (and I assume it's related to its size). @@ -143,7 +143,7 @@ fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: i32) !void { } var cdp = &client.mode.cdp; - var last_message = timestamp(); + var last_message = timestamp(.monotonic); var ms_remaining = timeout_ms; while (true) { switch (cdp.pageWait(ms_remaining)) { @@ -151,7 +151,7 @@ fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: i32) !void { if (try client.readSocket() == false) { return; } - last_message = timestamp(); + last_message = timestamp(.monotonic); ms_remaining = timeout_ms; }, .no_page => { @@ -162,16 +162,16 @@ fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: i32) !void { if (try client.readSocket() == false) { return; } - last_message = timestamp(); + last_message = timestamp(.monotonic); ms_remaining = timeout_ms; }, .done => { - const elapsed = timestamp() - last_message; + const elapsed = timestamp(.monotonic) - last_message; if (elapsed > ms_remaining) { log.info(.app, "CDP timeout", .{}); return; } - ms_remaining -= @as(i32, @intCast(elapsed)); + ms_remaining -= @intCast(elapsed); }, } } @@ -928,9 +928,7 @@ fn buildJSONVersionResponse( return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address }); } -fn timestamp() u32 { - return @import("datetime.zig").timestamp(); -} +pub const timestamp = @import("datetime.zig").timestamp; // In-place string lowercase fn toLower(str: []u8) []u8 { diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index 621f4742e..cd87bf8ea 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -6,7 +6,7 @@ const Thread = std.Thread; const Allocator = std.mem.Allocator; const log = @import("../log.zig"); -const App = @import("../app.zig").App; +const App = @import("../App.zig"); const Http = @import("../http/Http.zig"); const telemetry = @import("telemetry.zig"); From 59bbfc4e06ca582abee90fdf5ac203d3c1661b93 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 28 Oct 2025 19:07:58 +0800 Subject: [PATCH 004/257] fix casing --- src/{app.zig => App.zig} | 0 src/{notification.zig => Notification.zig} | 0 src/{server.zig => Server.zig} | 0 src/browser/{browser.zig => Browser.zig} | 0 src/browser/{page.zig => Page.zig} | 0 src/browser/{session.zig => Session.zig} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/{app.zig => App.zig} (100%) rename src/{notification.zig => Notification.zig} (100%) rename src/{server.zig => Server.zig} (100%) rename src/browser/{browser.zig => Browser.zig} (100%) rename src/browser/{page.zig => Page.zig} (100%) rename src/browser/{session.zig => Session.zig} (100%) diff --git a/src/app.zig b/src/App.zig similarity index 100% rename from src/app.zig rename to src/App.zig diff --git a/src/notification.zig b/src/Notification.zig similarity index 100% rename from src/notification.zig rename to src/Notification.zig diff --git a/src/server.zig b/src/Server.zig similarity index 100% rename from src/server.zig rename to src/Server.zig diff --git a/src/browser/browser.zig b/src/browser/Browser.zig similarity index 100% rename from src/browser/browser.zig rename to src/browser/Browser.zig diff --git a/src/browser/page.zig b/src/browser/Page.zig similarity index 100% rename from src/browser/page.zig rename to src/browser/Page.zig diff --git a/src/browser/session.zig b/src/browser/Session.zig similarity index 100% rename from src/browser/session.zig rename to src/browser/Session.zig From 1a04ebce35830d474a5a75053ee9cf63a81f6042 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 28 Oct 2025 19:12:47 +0800 Subject: [PATCH 005/257] fix Node.contains --- src/browser/tests/node/child_nodes.html | 2 ++ src/browser/webapi/Node.zig | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/browser/tests/node/child_nodes.html b/src/browser/tests/node/child_nodes.html index 3ecc3150c..7534eff44 100644 --- a/src/browser/tests/node/child_nodes.html +++ b/src/browser/tests/node/child_nodes.html @@ -77,6 +77,8 @@ , it needs to block the caller + // until it's evaluated + var client = self.client; + while (true) { + if (pending_script.complete) { + return pending_script.script.eval(page); + } + _ = try client.tick(200); + } } // Resolve a module specifier to an valid URL. @@ -394,6 +413,7 @@ pub fn getAsyncModule(self: *ScriptManager, url: [:0]const u8, cb: AsyncModule.C .error_callback = AsyncModule.errorCallback, }); } + pub fn pageIsLoaded(self: *ScriptManager) void { std.debug.assert(self.static_scripts_done == false); self.static_scripts_done = true; @@ -415,15 +435,6 @@ fn evaluate(self: *ScriptManager) void { self.is_evaluating = true; defer self.is_evaluating = false; - while (self.scripts.first) |n| { - var pending_script: *PendingScript = @fieldParentPtr("node", n); - if (pending_script.complete == false) { - return; - } - defer pending_script.deinit(); - pending_script.script.eval(page); - } - if (self.static_scripts_done == false) { // We can only execute deferred scripts if // 1 - all the normal scripts are done @@ -460,7 +471,6 @@ fn evaluate(self: *ScriptManager) void { pub fn isDone(self: *const ScriptManager) bool { return self.asyncs.first == null and // there are no more async scripts self.static_scripts_done and // and we've finished parsing the HTML to queue all - self.scripts.first == null and // and there are no more --> +
diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html index 689e9e683..12b98f26e 100644 --- a/src/browser/tests/net/url_search_params.html +++ b/src/browser/tests/net/url_search_params.html @@ -20,8 +20,8 @@ - + --> diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index d808e70f9..453790180 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -56,6 +56,15 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback: js.Fun return page._event_manager.remove(self, typ, callback, use_capture); } +pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { + return switch (self._type) { + .node => |n| n.format(writer), + .window => writer.writeAll(""), + .xhr => writer.writeAll(""), + .abort_signal => writer.writeAll(""), + }; +} + pub const JsApi = struct { pub const bridge = js.Bridge(EventTarget); diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 8813c0928..00e06bf33 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -9,7 +9,7 @@ pub fn registerTypes() []const type { } pub const Jar = @import("cookie.zig").Jar; -pub const Cookie =@import("cookie.zig").Cookie; +pub const Cookie = @import("cookie.zig").Cookie; pub const Shed = struct { _origins: std.StringHashMapUnmanaged(*Bucket) = .empty, diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 7c086f6f2..3912b842f 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -117,11 +117,12 @@ const TestContext = struct { bc.session_id = sid; } - if (opts.html) |html| { - if (bc.session_id == null) bc.session_id = "SID-X"; - const page = try bc.session.createPage(); - page.window.document = (try Document.init(html)).doc; - } + // @ZIGDOM + // if (opts.html) |html| { + // if (bc.session_id == null) bc.session_id = "SID-X"; + // const page = try bc.session.createPage(); + // page.window._document = (try Document.init(html)).doc; + // } return bc; } diff --git a/src/testing.zig b/src/testing.zig index 7526180eb..a4805f985 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -422,9 +422,8 @@ test { const log = @import("log.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); -// @ZIGDOM-CDP -// const Server = @import("Server.zig"); -// var test_cdp_server: ?Server = null; +const Server = @import("Server.zig"); +var test_cdp_server: ?Server = null; var test_http_server: ?TestHTTPServer = null; test "tests:beforeAll" { @@ -446,12 +445,10 @@ test "tests:beforeAll" { var wg: std.Thread.WaitGroup = .{}; wg.startMany(2); - // @ZIGDOM-CDP - // { - // const thread = try std.Thread.spawn(.{}, serveCDP, .{&wg}); - // thread.detach(); - // } - wg.finish(); // @ZIGDOM-CDP REMOVE + { + const thread = try std.Thread.spawn(.{}, serveCDP, .{&wg}); + thread.detach(); + } test_http_server = TestHTTPServer.init(testHTTPHandler); { @@ -465,10 +462,9 @@ test "tests:beforeAll" { } test "tests:afterAll" { - // @ZIGDOM-CDP - // if (test_cdp_server) |*server| { - // server.deinit(); - // } + if (test_cdp_server) |*server| { + server.deinit(); + } if (test_http_server) |*server| { server.deinit(); } @@ -477,20 +473,19 @@ test "tests:afterAll" { test_app.deinit(); } -// @ZIGDOM-CDP -// fn serveCDP(wg: *std.Thread.WaitGroup) !void { -// const address = try std.net.Address.parseIp("127.0.0.1", 9583); -// test_cdp_server = try Server.init(test_app, address); +fn serveCDP(wg: *std.Thread.WaitGroup) !void { + const address = try std.net.Address.parseIp("127.0.0.1", 9583); + test_cdp_server = try Server.init(test_app, address); -// var server = try Server.init(test_app, address); -// defer server.deinit(); -// wg.finish(); + var server = try Server.init(test_app, address); + defer server.deinit(); + wg.finish(); -// test_cdp_server.?.run(address, 5) catch |err| { -// std.debug.print("CDP server error: {}", .{err}); -// return err; -// }; -// } + test_cdp_server.?.run(address, 5) catch |err| { + std.debug.print("CDP server error: {}", .{err}); + return err; + }; +} fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; From 5ae1190ddd411365fb51478ed948a1a9909b979b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 29 Oct 2025 22:23:05 +0800 Subject: [PATCH 007/257] HTMLDocument --- src/browser/Factory.zig | 11 ++ src/browser/Page.zig | 2 +- src/browser/js/bridge.zig | 1 + src/browser/tests/page/meta.html | 30 ++++- src/browser/tests/page/module.html | 8 +- src/browser/webapi/Document.zig | 119 +++++------------ src/browser/webapi/Element.zig | 6 +- src/browser/webapi/HTMLDocument.zig | 131 +++++++++++++++++++ src/browser/webapi/Node.zig | 3 + src/browser/webapi/collections/node_live.zig | 2 +- 10 files changed, 216 insertions(+), 97 deletions(-) create mode 100644 src/browser/webapi/HTMLDocument.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index bd04da757..b1b41f9de 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -10,6 +10,7 @@ const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const Element = @import("webapi/Element.zig"); +const Document = @import("webapi/Document.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); @@ -98,6 +99,16 @@ pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { return child_ptr; } +pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.node(Document{ + ._proto = undefined, + ._type = unionInit(Document.Type, child_ptr), + }); + return child_ptr; +} + pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) { const child_ptr = try self.createT(@TypeOf(child)); child_ptr.* = child; diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1851bdc2f..b44dfb3c5 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -136,7 +136,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self.version = 0; self.url = "about/blank"; - self.document = try self._factory.node(Document{ ._proto = undefined }); + self.document = (try self._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument(); const storage_bucket = try self._factory.create(storage.Bucket{}); self.window = try self._factory.eventTarget(Window{ diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 1e9e9739d..6928a4951 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -417,6 +417,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/CSSStyleDeclaration.zig"), @import("../webapi/css/CSSStyleProperties.zig"), @import("../webapi/Document.zig"), + @import("../webapi/HTMLDocument.zig"), @import("../webapi/DocumentFragment.zig"), @import("../webapi/DOMException.zig"), @import("../webapi/DOMTreeWalker.zig"), diff --git a/src/browser/tests/page/meta.html b/src/browser/tests/page/meta.html index fe2d32691..bf310c416 100644 --- a/src/browser/tests/page/meta.html +++ b/src/browser/tests/page/meta.html @@ -1,7 +1,8 @@ diff --git a/src/browser/tests/page/module.html b/src/browser/tests/page/module.html index 4a431b1fe..f3dae6d1b 100644 --- a/src/browser/tests/page/module.html +++ b/src/browser/tests/page/module.html @@ -1,7 +1,7 @@ - + --> - + --> +

Direct child paragraph

@@ -51,15 +51,15 @@ { const container = $('#desc-container'); - // testing.expectEqual('Nested paragraph', container.querySelector('div p').textContent); - // testing.expectEqual('Nested paragraph', container.querySelector('div.nested p').textContent); + testing.expectEqual('Direct child paragraph', container.querySelector('div p').textContent); + testing.expectEqual('Nested paragraph', container.querySelector('div.nested p').textContent); testing.expectEqual('Deeply nested paragraph', container.querySelector('div span p').textContent); - // testing.expectEqual('Nested paragraph', container.querySelector('.nested .text').textContent); - // testing.expectEqual(null, container.querySelector('article div p')); + testing.expectEqual('Nested paragraph', container.querySelector('.nested .text').textContent); + testing.expectEqual(null, container.querySelector('article div p')); - // const outerDiv = $('#outer-div'); - // testing.expectEqual('deep-span', outerDiv.querySelector('div div span').id); - // testing.expectEqual('deep-span', outerDiv.querySelector('.level1 span').id); - // testing.expectEqual('deep-span', outerDiv.querySelector('.level1 .level2 span').id); + const outerDiv = $('#outer-div'); + testing.expectEqual('deep-span', outerDiv.querySelector('div div span').id); + testing.expectEqual('deep-span', outerDiv.querySelector('.level1 span').id); + testing.expectEqual('deep-span', outerDiv.querySelector('.level1 .level2 span').id); } diff --git a/src/browser/tests/page/module.html b/src/browser/tests/page/module.html index f3dae6d1b..1dd797944 100644 --- a/src/browser/tests/page/module.html +++ b/src/browser/tests/page/module.html @@ -1,7 +1,7 @@ - + - - - diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 89c3d16be..d5ac8b971 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -2,6 +2,7 @@ let failed = false; let observed_ids = {}; let eventuallies = []; + let async_capture = null; let current_script_id = null; function expectTrue(actual) { @@ -12,14 +13,17 @@ expectEqual(false, actual); } - function expectEqual(expected, actual) { + function expectEqual(expected, actual, opts) { if (_equal(expected, actual)) { - _registerObservation('ok'); + _registerObservation('ok', opts); return; } failed = true; - _registerObservation('fail'); + _registerObservation('fail', opts); let err = `expected: ${_displayValue(expected)}, got: ${_displayValue(actual)}\n script_id: ${_currentScriptId()}`; + if (async_capture) { + err += `\n stack: ${async_capture.stack}`; + } console.error(err); throw new Error('expectEqual failed'); } @@ -57,7 +61,14 @@ callback: cb, script_id: script_id, }); + } + async function async(cb) { + const script_id = document.currentScript.id; + const stack = new Error().stack; + async_capture = {script_id: script_id, stack: stack}; + await cb(); + async_capture = null; } function assertOk() { @@ -92,6 +103,7 @@ window.testing = { fail: fail, + async: async, assertOk: assertOk, expectTrue: expectTrue, expectFalse: expectFalse, @@ -125,7 +137,6 @@ return false; } - if (expected instanceof Node) { if (!(actual instanceof Node)) { return false; @@ -145,8 +156,8 @@ return true; } - function _registerObservation(status) { - const script_id = _currentScriptId(); + function _registerObservation(status, opts) { + script_id = opts?.script_id || _currentScriptId(); if (!script_id) { return; } @@ -161,7 +172,12 @@ return current_script_id; } + if (async_capture) { + return async_capture.script_id; + } + const current_script = document.currentScript; + if (!current_script) { return null; } diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html index f5ce7ffb4..01a4049db 100644 --- a/src/browser/tests/window/location.html +++ b/src/browser/tests/window/location.html @@ -2,6 +2,6 @@ diff --git a/src/browser/tests/window/report_error.html b/src/browser/tests/window/report_error.html index 6796d46f8..c2d66125a 100644 --- a/src/browser/tests/window/report_error.html +++ b/src/browser/tests/window/report_error.html @@ -35,9 +35,6 @@ window.reportError(err); testing.expectEqual(true, evt.message.includes('Detailed error')); - testing.expectEqual('script.js', evt.filename); - testing.expectEqual(100, evt.lineno); - testing.expectEqual(25, evt.colno); testing.expectEqual(err, evt.error); } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d5261fede..0ea5fc06c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -147,6 +147,29 @@ pub fn cancelAnimationFrame(self: *Window, id: u32) void { sc.removed = true; } +pub fn reportError(self: *Window, err: js.Object, page: *Page) !void { + const error_event = try ErrorEvent.init("error", .{ + .@"error" = err, + .message = err.toString() catch "Unknown error", + .bubbles = false, + .cancelable = true, + }, page); + + const event = error_event.asEvent(); + try page._event_manager.dispatch(self.asEventTarget(), event); + + if (comptime builtin.is_test == false) { + if (!event._prevent_default) { + log.warn(.js, "window.reportError", .{ + .message = error_event._message, + .filename = error_event._filename, + .line_number = error_event._line_number, + .column_number = error_event._column_number, + }); + } + } +} + pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQueryList { return page._factory.eventTarget(MediaQueryList{ ._proto = undefined, @@ -290,6 +313,7 @@ pub const JsApi = struct { pub const matchMedia = bridge.function(Window.matchMedia, .{}); pub const btoa = bridge.function(Window.btoa, .{}); pub const atob = bridge.function(Window.atob, .{}); + pub const reportError = bridge.function(Window.reportError, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 9822502fb..585e74d63 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -30,11 +30,11 @@ pub const JsApi = struct { pub const Build = struct { pub fn complete(node: *Node, page: *Page) !void { - _ = node; - _ = page; - // @ZIGDOM - // const el = node.as(Element); - // const on_load = el.getAttributeSafe("onload") orelse return; - // page.window._on_load = page.js.stringToFunction(on_load); + const el = node.as(Element); + const on_load = el.getAttributeSafe("onload") orelse return; + page.window._on_load = page.js.stringToFunction(on_load) catch |err| blk: { + log.err(.js, "body.onload", .{.err = err, .str = on_load}); + break :blk null; + }; } }; diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index e60811069..df224ae25 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -1,3 +1,4 @@ +const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); @@ -77,15 +78,19 @@ pub const Build = struct { const element = self.asElement(); self._src = element.getAttributeSafe("src") orelse ""; - // @ZIGDOM - _ = page; - // if (element.getAttributeSafe("onload")) |on_load| { - // self._on_load = page.js.stringToFunction(on_load); - // } - - // if (element.getAttributeSafe("onerror")) |on_error| { - // self._on_error = page.js.stringToFunction(on_error); - // } + if (element.getAttributeSafe("onload")) |on_load| { + self._on_load = page.js.stringToFunction(on_load) catch |err| blk: { + log.err(.js, "script.onload", .{.err = err, .str = on_load}); + break :blk null; + }; + } + + if (element.getAttributeSafe("onerror")) |on_error| { + self._on_error = page.js.stringToFunction(on_error) catch |err| blk: { + log.err(.js, "script.onerror", .{.err = err, .str = on_error}); + break :blk null; + }; + } } }; diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 0283178db..0bedc46ca 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -22,32 +22,26 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); -pub fn init(arena: Allocator, root: *Node, selector: Selector.Selector, page: *Page) !*List { - var list = try page._factory.create(List{ - ._arena = arena, - ._nodes = &.{}, - }); - +pub fn collect( + allocator: std.mem.Allocator, + root: *Node, + selector: Selector.Selector, + nodes: *std.AutoArrayHashMapUnmanaged(*Node, void), + page: *Page, +) !void { if (optimizeSelector(root, &selector, page)) |result| { - var nodes: std.ArrayListUnmanaged(*Node) = .empty; - var tw = TreeWalker.init(result.root, .{}); - const optimized_selector = result.selector; if (result.exclude_root) { _ = tw.next(); } - // When exclude_root is true, pass root as boundary so it can match but we won't search beyond it - // When exclude_root is false, pass null so there's no boundary (root already matched, searching descendants) + const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, optimized_selector, boundary)) { - try nodes.append(arena, node); + if (matches(node, result.selector, boundary)) { + try nodes.put(allocator, node, {}); } } - list._nodes = nodes.items; } - - return list; } // used internally to find the first match @@ -135,7 +129,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page .first = selector.first, .segments = selector.segments, }, - .exclude_root = false, + .exclude_root = true, }; } @@ -238,7 +232,7 @@ fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { return null; } -fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { +pub fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { @@ -333,8 +327,9 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary + // If there's a boundary, check if parent is outside (an ancestor of) the boundary if (root) |boundary| { - if (parent == boundary) { + if (!boundary.contains(parent)) { return null; } } diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 7fccc812d..0e88df0df 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; + const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); @@ -8,7 +10,6 @@ const Part = Selector.Part; const Combinator = Selector.Combinator; const Segment = Selector.Segment; const Attribute = @import("../element/Attribute.zig"); -const Allocator = std.mem.Allocator; const Parser = @This(); @@ -26,10 +27,56 @@ const ParseError = error{ InvalidTagSelector, InvalidSelector, }; + +pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![]const Selector.Selector { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + var remaining = input; + while (true) { + const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace); + if (trimmed.len == 0) break; + + var comma_pos: usize = trimmed.len; + var depth: usize = 0; + for (trimmed, 0..) |c, i| { + switch (c) { + '(' => depth += 1, + ')' => { + if (depth > 0) depth -= 1; + }, + ',' => { + if (depth == 0) { + comma_pos = i; + break; + } + }, + else => {}, + } + } + + const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace); + + if (selector_input.len > 0) { + const selector = try parse(arena, selector_input, page); + try selectors.append(arena, selector); + } + + if (comma_pos >= trimmed.len) break; + remaining = trimmed[comma_pos + 1 ..]; + } + + if (selectors.items.len == 0) { + return error.InvalidSelector; + } + + return selectors.items; +} + pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Selector.Selector { var parser = Parser{ .input = input }; - var segments: std.ArrayListUnmanaged(Segment) = .empty; - var current_compound: std.ArrayListUnmanaged(Part) = .empty; + var segments: std.ArrayList(Segment) = .empty; + var current_compound: std.ArrayList(Part) = .empty; + // Parse the first compound (no combinator before it) while (parser.skipSpaces()) { @@ -302,7 +349,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (std.mem.eql(u8, name, "not")) { // CSS Level 4: :not() can contain a full selector list (comma-separated selectors) // e.g., :not(div, .class, #id > span) - var selectors: std.ArrayListUnmanaged(Selector.Selector) = .empty; + var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 7e4da77c9..8839b1b6f 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -10,23 +10,28 @@ pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Elemen return error.SyntaxError; } - const selector = try Parser.parse(page.call_arena, input, page); - - // Fast path: single compound with only an ID selector - if (selector.segments.len == 0 and selector.first.parts.len == 1) { - const first = selector.first.parts[0]; - if (first == .id) { - const el = page.document._elements_by_id.get(first.id) orelse return null; - // Check if the element is within the root subtree - if (root.contains(el.asNode())) { - return el; + const arena = page.call_arena; + const selectors = try Parser.parseList(arena, input, page); + + for (selectors) |selector| { + // Fast path: single compound with only an ID selector + if (selector.segments.len == 0 and selector.first.parts.len == 1) { + const first = selector.first.parts[0]; + if (first == .id) { + const el = page.document._elements_by_id.get(first.id) orelse continue; + // Check if the element is within the root subtree + if (root.contains(el.asNode())) { + return el; + } + continue; } - return null; } - } - if (List.initOne(root, selector, page)) |node| { - return node.is(Node.Element); + if (List.initOne(root, selector, page)) |node| { + if (node.is(Node.Element)) |el| { + return el; + } + } } return null; } @@ -37,8 +42,33 @@ pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { } const arena = page.arena; - const selector = try Parser.parse(arena, input, page); - return List.init(arena, root, selector, page); + var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; + + const selectors = try Parser.parseList(arena, input, page); + for (selectors) |selector| { + try List.collect(arena, root, selector, &nodes, page); + } + + return page._factory.create(List{ + ._arena = arena, + ._nodes = nodes.keys(), + }); +} + +pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { + if (input.len == 0) { + return error.SyntaxError; + } + + const arena = page.call_arena; + const selectors = try Parser.parseList(arena, input, page); + + for (selectors) |selector| { + if (List.matches(el.asNode(), selector, null)) { + return true; + } + } + return false; } pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool { diff --git a/src/log.zig b/src/log.zig index d0f02bf9d..f791e9f7b 100644 --- a/src/log.zig +++ b/src/log.zig @@ -131,7 +131,7 @@ pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: var writer = stderr.writer(&buf); logTo(scope, level, msg, data, &writer.interface) catch |log_err| { - std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); + std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); }; } @@ -147,7 +147,6 @@ fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: an } } } - switch (opts.format) { .logfmt => try logLogfmt(scope, level, msg, data, out), .pretty => try logPretty(scope, level, msg, data, out), From 32bad5f8bb63a4ee92c046ddf5935294df89ebec Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 20:09:38 +0800 Subject: [PATCH 018/257] Element.matches, Element.hasAttributes and DOMStringMap (Element.dataset) --- src/browser/Page.zig | 4 + src/browser/ScriptManager.zig | 1 - src/browser/js/Caller.zig | 49 +++++- src/browser/js/Env.zig | 14 +- src/browser/js/bridge.zig | 52 ++++-- src/browser/tests/element/attributes.html | 19 +++ src/browser/tests/element/dataset.html | 150 ++++++++++++++++++ src/browser/tests/element/matches.html | 76 +++++++++ src/browser/webapi/Document.zig | 4 +- src/browser/webapi/Element.zig | 24 +++ src/browser/webapi/KeyValueList.zig | 2 +- src/browser/webapi/Node.zig | 4 +- src/browser/webapi/Window.zig | 2 +- .../webapi/collections/HTMLAllCollection.zig | 2 +- .../webapi/collections/HTMLCollection.zig | 2 +- src/browser/webapi/css/CSSStyleProperties.zig | 2 +- src/browser/webapi/css/MediaQueryList.zig | 2 +- src/browser/webapi/element/Attribute.zig | 2 +- src/browser/webapi/element/DOMStringMap.zig | 87 ++++++++++ src/browser/webapi/element/html/Body.zig | 2 +- src/browser/webapi/element/html/Script.zig | 4 +- src/browser/webapi/selector/Parser.zig | 1 - 22 files changed, 467 insertions(+), 38 deletions(-) create mode 100644 src/browser/tests/element/dataset.html create mode 100644 src/browser/tests/element/matches.html create mode 100644 src/browser/webapi/element/DOMStringMap.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 6f0afe853..fa1c9fdd8 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -63,6 +63,9 @@ _attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute), // the return of elements.attributes. _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap), +// element.dataset -> DOMStringMap +_element_datasets: std.AutoHashMapUnmanaged(*Element, *Element.DOMStringMap), + _script_manager: ScriptManager, _polyfill_loader: polyfill.Loader = .{}, @@ -152,6 +155,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._load_state = .parsing; self._attribute_lookup = .empty; self._attribute_named_node_map_lookup = .empty; + self._element_datasets = .empty; self._event_manager = EventManager.init(self); self._script_manager = ScriptManager.init(self); diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 4a394f651..29e9f6839 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -707,7 +707,6 @@ const Script = struct { .cacheable = cacheable, }); - // Handle importmap special case here: the content is a JSON containing // imports. if (self.kind == .importmap) { diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index aea3a1af3..0b1b5e4a2 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -157,7 +157,7 @@ pub fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: @field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis()); @field(args, "1") = idx; const ret = @call(.auto, func, args); - return self.handleIndexedReturn(T, F, ret, info, opts); + return self.handleIndexedReturn(T, F, true, ret, info, opts); } pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { @@ -173,10 +173,49 @@ pub fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.N @field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis()); @field(args, "1") = try self.nameToString(name); const ret = @call(.auto, func, args); - return self.handleIndexedReturn(T, F, ret, info, opts); + return self.handleIndexedReturn(T, F, true, ret, info, opts); } -fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, ret: anytype, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { + return self._setNamedIndex(T, func, name, js_value, info, opts) catch |err| { + self.handleError(T, @TypeOf(func), err, info, opts); + return v8.Intercepted.No; + }; +} + +pub fn _setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 { + const F = @TypeOf(func); + var args: ParameterTypes(F) = undefined; + @field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis()); + @field(args, "1") = try self.nameToString(name); + @field(args, "2") = try self.context.jsValueToZig(@TypeOf(@field(args, "2")), js_value); + if (@typeInfo(F).@"fn".params.len == 4) { + @field(args, "3") = self.context.page; + } + const ret = @call(.auto, func, args); + return self.handleIndexedReturn(T, F, false, ret, info, opts); +} + +pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { + return self._deleteNamedIndex(T, func, name, info, opts) catch |err| { + self.handleError(T, @TypeOf(func), err, info, opts); + return v8.Intercepted.No; + }; +} + +pub fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 { + const F = @TypeOf(func); + var args: ParameterTypes(F) = undefined; + @field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis()); + @field(args, "1") = try self.nameToString(name); + if (@typeInfo(F).@"fn".params.len == 3) { + @field(args, "2") = self.context.page; + } + const ret = @call(.auto, func, args); + return self.handleIndexedReturn(T, F, false, ret, info, opts); +} + +fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 { // need to unwrap this error immediately for when opts.null_as_undefined == true // and we need to compare it to null; const non_error_ret = switch (@typeInfo(@TypeOf(ret))) { @@ -197,7 +236,9 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, ret: a else => ret, }; - info.getReturnValue().set(try self.context.zigValueToJs(non_error_ret, opts)); + if (comptime getter) { + info.getReturnValue().set(try self.context.zigValueToJs(non_error_ret, opts)); + } return v8.Intercepted.Yes; } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index fa4595e32..71bed313a 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -253,13 +253,12 @@ pub fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.Funct }; template_proto.setIndexedProperty(configuration, null); }, - bridge.NamedIndexed => { - const configuration = v8.NamedPropertyHandlerConfiguration{ - .getter = value.getter, - .flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking, - }; - template_proto.setNamedProperty(configuration, null); - }, + bridge.NamedIndexed => template.getInstanceTemplate().setNamedProperty(.{ + .getter = value.getter, + .setter = value.setter, + .deleter = value.deleter, + .flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking, + }, null), bridge.Iterator => { // Same as a function, but with a specific name const function_template = v8.FunctionTemplate.initCallback(isolate, value.func); @@ -326,7 +325,6 @@ fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTem // if (has_js_call_as_function) { - // if (@hasDecl(Struct, "htmldda") and Struct.htmldda) { // if (!has_js_call_as_function) { // @compileError(@typeName(Struct) ++ ": htmldda required jsCallAsFunction to be defined. This is a hard-coded requirement in V8, because mark_as_undetectable only exists for HTMLAllCollection which is also callable."); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4a313b6b3..fe1d4ec12 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -45,8 +45,8 @@ pub fn Builder(comptime T: type) type { return Indexed.init(T, getter_func, opts); } - pub fn namedIndexed(comptime getter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { - return NamedIndexed.init(T, getter_func, opts); + pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { + return NamedIndexed.init(T, getter_func, setter_func, deleter_func, opts); } pub fn iterator(comptime func: anytype, comptime opts: Iterator.Opts) Iterator { @@ -221,14 +221,16 @@ pub const Indexed = struct { pub const NamedIndexed = struct { getter: *const fn (c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8, + setter: ?*const fn (c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 = null, + deleter: ?*const fn (c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 = null, const Opts = struct { as_typed_array: bool = false, null_as_undefined: bool = false, }; - fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) NamedIndexed { - return .{ .getter = struct { + fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, comptime opts: Opts) NamedIndexed { + const getter_fn = struct { fn wrap(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { const info = v8.PropertyCallbackInfo.initFromV8(raw_info); var caller = Caller.init(info); @@ -238,7 +240,39 @@ pub const NamedIndexed = struct { .null_as_undefined = opts.null_as_undefined, }); } - }.wrap }; + }.wrap; + + const setter_fn = if (@typeInfo(@TypeOf(setter)) == .null) null else struct { + fn wrap(c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { + const info = v8.PropertyCallbackInfo.initFromV8(raw_info); + var caller = Caller.init(info); + defer caller.deinit(); + + return caller.setNamedIndex(T, setter, .{ .handle = c_name.? }, .{ .handle = c_value.? }, info, .{ + .as_typed_array = opts.as_typed_array, + .null_as_undefined = opts.null_as_undefined, + }); + } + }.wrap; + + const deleter_fn = if (@typeInfo(@TypeOf(deleter)) == .null) null else struct { + fn wrap(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { + const info = v8.PropertyCallbackInfo.initFromV8(raw_info); + var caller = Caller.init(info); + defer caller.deinit(); + + return caller.deleteNamedIndex(T, deleter, .{ .handle = c_name.? }, info, .{ + .as_typed_array = opts.as_typed_array, + .null_as_undefined = opts.null_as_undefined, + }); + } + }.wrap; + + return .{ + .getter = getter_fn, + .setter = setter_fn, + .deleter = deleter_fn, + }; } }; @@ -269,7 +303,6 @@ pub const Iterator = struct { } }; - pub const Callable = struct { func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void, @@ -278,7 +311,7 @@ pub const Callable = struct { }; fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Callable { - return .{.func = struct { + return .{ .func = struct { fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void { const info = v8.FunctionCallbackInfo.initFromV8(raw_info); var caller = Caller.init(info); @@ -286,8 +319,8 @@ pub const Callable = struct { caller.method(T, func, info, .{ .null_as_undefined = opts.null_as_undefined, }); - }}.wrap - }; + } + }.wrap }; } }; @@ -457,6 +490,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/DOMNodeIterator.zig"), @import("../webapi/NodeFilter.zig"), @import("../webapi/Element.zig"), + @import("../webapi/element/DOMStringMap.zig"), @import("../webapi/element/Attribute.zig"), @import("../webapi/element/Html.zig"), @import("../webapi/element/html/IFrame.zig"), diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html index 4f557676a..d4d416f6a 100644 --- a/src/browser/tests/element/attributes.html +++ b/src/browser/tests/element/attributes.html @@ -83,3 +83,22 @@ assertAttributes([{name: 'id', value: 'attr1'}, {name: 'class', value: 'sHow'}]); + + diff --git a/src/browser/tests/element/dataset.html b/src/browser/tests/element/dataset.html new file mode 100644 index 000000000..c9178c743 --- /dev/null +++ b/src/browser/tests/element/dataset.html @@ -0,0 +1,150 @@ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html new file mode 100644 index 000000000..324453cbd --- /dev/null +++ b/src/browser/tests/element/matches.html @@ -0,0 +1,76 @@ + + + +
+

Paragraph 1

+
+

Paragraph 2

+ +

Paragraph 3

+
+
+
+ + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 70a40767d..bbdd267c2 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -195,11 +195,11 @@ pub const JsApi = struct { pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{}); - pub const defaultView = bridge.accessor(struct{ + pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { return page.window; } - }.defaultView, null, .{.cache = "defaultView"}); + }.defaultView, null, .{ .cache = "defaultView" }); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index e00890769..0ca657579 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -12,6 +12,7 @@ const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); pub const Attribute = @import("element/Attribute.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); +pub const DOMStringMap = @import("element/DOMStringMap.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); @@ -247,6 +248,12 @@ pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]con return attributes.get(name, page); } +pub fn hasAttribute(self: *const Element, name: []const u8, page: *Page) !bool { + const attributes = self._attributes orelse return false; + const value = try attributes.get(name, page); + return value != null; +} + pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attribute { const attributes = self._attributes orelse return null; return attributes.getAttribute(name, self, page); @@ -342,6 +349,16 @@ pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList { }; } +pub fn getDataset(self: *Element, page: *Page) !*DOMStringMap { + const gop = try page._element_datasets.getOrPut(page.arena, self); + if (!gop.found_existing) { + gop.value_ptr.* = try page._factory.create(DOMStringMap{ + ._element = self, + }); + } + return gop.value_ptr.*; +} + pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { page.domChanged(); var parent = self.asNode(); @@ -438,6 +455,10 @@ pub fn getChildElementCount(self: *Element) usize { return count; } +pub fn matches(self: *Element, selector: []const u8, page: *Page) !bool { + return Selector.matches(self, selector, page); +} + pub fn querySelector(self: *Element, selector: []const u8, page: *Page) !?*Element { return Selector.querySelector(self.asNode(), selector, page); } @@ -658,8 +679,10 @@ pub const JsApi = struct { pub const id = bridge.accessor(Element.getId, Element.setId, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, null, .{}); + pub const dataset = bridge.accessor(Element.getDataset, null, .{}); pub const style = bridge.accessor(Element.getStyle, null, .{}); pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{}); + pub const hasAttribute = bridge.function(Element.hasAttribute, .{}); pub const getAttribute = bridge.function(Element.getAttribute, .{}); pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{}); pub const setAttribute = bridge.function(Element.setAttribute, .{}); @@ -676,6 +699,7 @@ pub const JsApi = struct { pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{}); pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{}); pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{}); + pub const matches = bridge.function(Element.matches, .{ .dom_exception = true }); pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 9f0cca7c0..3b105bd8f 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -45,7 +45,7 @@ pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { return null; } -pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 { +pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 { const arena = page.call_arena; var arr: std.ArrayList([]const u8) = .empty; for (self._entries.items) |*entry| { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index cdec332ab..7af7c4fad 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -614,9 +614,7 @@ pub const JsApi = struct { pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{}); fn _textContext(self: *Node, page: *const Page) !?[]const u8 { - // can't call node.getTextContent directly, because - // 1 - document should return null, not empty - // 2 - cdata and attributes can return value directly, avoiding the copy + // cdata and attributes can return value directly, avoiding the copy switch (self._type) { .element => |el| { var buf = std.Io.Writer.Allocating.init(page.call_arena); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 0ea5fc06c..605b9fdc7 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -149,7 +149,7 @@ pub fn cancelAnimationFrame(self: *Window, id: u32) void { pub fn reportError(self: *Window, err: js.Object, page: *Page) !void { const error_event = try ErrorEvent.init("error", .{ - .@"error" = err, + .@"error" = err, .message = err.toString() catch "Unknown error", .bubbles = false, .cancelable = true, diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig index 9e883ad5a..60aba31cf 100644 --- a/src/browser/webapi/collections/HTMLAllCollection.zig +++ b/src/browser/webapi/collections/HTMLAllCollection.zig @@ -152,7 +152,7 @@ pub const JsApi = struct { pub const length = bridge.accessor(HTMLAllCollection.length, null, .{}); pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, .{ .null_as_undefined = true }); - pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *HTMLAllCollection, index: i32, page: *Page) ?*Element { diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 134ac5cc7..34d2f0713 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -83,7 +83,7 @@ pub const JsApi = struct { pub const length = bridge.accessor(HTMLCollection.length, null, .{}); pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, .{ .null_as_undefined = true }); - pub const @"[str]" = bridge.namedIndexed(HTMLCollection.getByName, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *HTMLCollection, index: i32, page: *Page) ?*Element { diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index d0b4a6087..2eeefff64 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -120,7 +120,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const @"[]" = bridge.namedIndexed(_getPropertyIndexed, .{}); + pub const @"[]" = bridge.namedIndexed(_getPropertyIndexed, null, null, .{}); const method_names = std.StaticStringMap(void).initComptime(.{ .{ "getPropertyValue", {} }, diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig index 25f813b34..f67e754c7 100644 --- a/src/browser/webapi/css/MediaQueryList.zig +++ b/src/browser/webapi/css/MediaQueryList.zig @@ -31,7 +31,7 @@ pub const JsApi = struct { pub const Meta = struct { pub const name = "MediaQueryList"; pub const prototype_chain = bridge.prototypeChain(); - pub var class_id: bridge.ClassId = undefined; + pub var class_id: bridge.ClassId = undefined; }; pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{}); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index dce7655ca..85d6bf6d5 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -386,7 +386,7 @@ pub const NamedNodeMap = struct { pub const length = bridge.accessor(NamedNodeMap.length, null, .{}); pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, .{ .null_as_undefined = true }); - pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, .{ .null_as_undefined = true }); pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{}); pub const item = bridge.function(_item, .{}); fn _item(self: *const NamedNodeMap, index: i32, page: *Page) !?*Attribute { diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig new file mode 100644 index 000000000..b2aa8babf --- /dev/null +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -0,0 +1,87 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Element = @import("../Element.zig"); +const Page = @import("../../Page.zig"); + +const Allocator = std.mem.Allocator; + +const DOMStringMap = @This(); + +_element: *Element, + +fn _getProperty(self: *DOMStringMap, name: []const u8, page: *Page) !?[]const u8 { + const attr_name = try camelToKebab(page.call_arena, name); + return try self._element.getAttribute(attr_name, page); +} + +fn _setProperty(self: *DOMStringMap, name: []const u8, value: []const u8, page: *Page) !void { + const attr_name = try camelToKebab(page.call_arena, name); + return self._element.setAttributeSafe(attr_name, value, page); +} + +fn _deleteProperty(self: *DOMStringMap, name: []const u8, page: *Page) !void { + const attr_name = try camelToKebab(page.call_arena, name); + try self._element.removeAttribute(attr_name, page); +} + +// fooBar -> foo-bar +fn camelToKebab(arena: Allocator, camel: []const u8) ![]const u8 { + var result: std.ArrayList(u8) = .empty; + try result.ensureTotalCapacity(arena, 5 + camel.len * 2); + result.appendSliceAssumeCapacity("data-"); + + for (camel, 0..) |c, i| { + if (std.ascii.isUpper(c)) { + if (i > 0) { + result.appendAssumeCapacity('-'); + } + result.appendAssumeCapacity(std.ascii.toLower(c)); + } else { + result.appendAssumeCapacity(c); + } + } + + return result.items; +} + +// data-foo-bar -> fooBar +fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 { + if (!std.mem.startsWith(u8, kebab, "data-")) { + return null; + } + + const data_part = kebab[5..]; // Skip "data-" + if (data_part.len == 0) { + return null; + } + + var result: std.ArrayList(u8) = .empty; + try result.ensureTotalCapacity(arena, data_part.len); + + var capitalize_next = false; + for (data_part) |c| { + if (c == '-') { + capitalize_next = true; + } else if (capitalize_next) { + result.appendAssumeCapacity(std.ascii.toUpper(c)); + capitalize_next = false; + } else { + result.appendAssumeCapacity(c); + } + } + + return result.items; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMStringMap); + + pub const Meta = struct { + pub const name = "DOMStringMap"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const @"[]" = bridge.namedIndexed(_getProperty, _setProperty, _deleteProperty, .{.null_as_undefined = true}); +}; diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 585e74d63..cf04a9397 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -33,7 +33,7 @@ pub const Build = struct { const el = node.as(Element); const on_load = el.getAttributeSafe("onload") orelse return; page.window._on_load = page.js.stringToFunction(on_load) catch |err| blk: { - log.err(.js, "body.onload", .{.err = err, .str = on_load}); + log.err(.js, "body.onload", .{ .err = err, .str = on_load }); break :blk null; }; } diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index df224ae25..6bf306d3c 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -80,14 +80,14 @@ pub const Build = struct { if (element.getAttributeSafe("onload")) |on_load| { self._on_load = page.js.stringToFunction(on_load) catch |err| blk: { - log.err(.js, "script.onload", .{.err = err, .str = on_load}); + log.err(.js, "script.onload", .{ .err = err, .str = on_load }); break :blk null; }; } if (element.getAttributeSafe("onerror")) |on_error| { self._on_error = page.js.stringToFunction(on_error) catch |err| blk: { - log.err(.js, "script.onerror", .{.err = err, .str = on_error}); + log.err(.js, "script.onerror", .{ .err = err, .str = on_error }); break :blk null; }; } diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 0e88df0df..335f22456 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -77,7 +77,6 @@ pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Select var segments: std.ArrayList(Segment) = .empty; var current_compound: std.ArrayList(Part) = .empty; - // Parse the first compound (no combinator before it) while (parser.skipSpaces()) { if (parser.peek() == 0) break; From c5a1d8a8bdb398e196ef30927d089dffe0b1107e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 20:18:34 +0800 Subject: [PATCH 019/257] Element.checkVisibility and Element.checkVisibility --- src/browser/js/bridge.zig | 1 + src/browser/webapi/DOMRect.zig | 64 +++++++++ src/browser/webapi/Element.zig | 124 ++++++++++++++++++ src/browser/webapi/css.zig | 14 ++ .../webapi/css/CSSStyleDeclaration.zig | 4 +- src/browser/webapi/css/CSSStyleProperties.zig | 2 +- 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 src/browser/webapi/DOMRect.zig create mode 100644 src/browser/webapi/css.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index fe1d4ec12..4fb65b705 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -488,6 +488,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/DOMImplementation.zig"), @import("../webapi/DOMTreeWalker.zig"), @import("../webapi/DOMNodeIterator.zig"), + @import("../webapi/DOMRect.zig"), @import("../webapi/NodeFilter.zig"), @import("../webapi/Element.zig"), @import("../webapi/element/DOMStringMap.zig"), diff --git a/src/browser/webapi/DOMRect.zig b/src/browser/webapi/DOMRect.zig new file mode 100644 index 000000000..6309a20ea --- /dev/null +++ b/src/browser/webapi/DOMRect.zig @@ -0,0 +1,64 @@ +const DOMRect = @This(); + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +_x: f64, +_y: f64, +_width: f64, +_height: f64, +_top: f64, +_right: f64, +_bottom: f64, +_left: f64, + +pub fn getX(self: *DOMRect) f64 { + return self._x; +} + +pub fn getY(self: *DOMRect) f64 { + return self._y; +} + +pub fn getWidth(self: *DOMRect) f64 { + return self._width; +} + +pub fn getHeight(self: *DOMRect) f64 { + return self._height; +} + +pub fn getTop(self: *DOMRect) f64 { + return self._top; +} + +pub fn getRight(self: *DOMRect) f64 { + return self._right; +} + +pub fn getBottom(self: *DOMRect) f64 { + return self._bottom; +} + +pub fn getLeft(self: *DOMRect) f64 { + return self._left; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMRect); + + pub const Meta = struct { + pub const name = "DOMRect"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const x = bridge.accessor(DOMRect.getX, null, .{}); + pub const y = bridge.accessor(DOMRect.getY, null, .{}); + pub const width = bridge.accessor(DOMRect.getWidth, null, .{}); + pub const height = bridge.accessor(DOMRect.getHeight, null, .{}); + pub const top = bridge.accessor(DOMRect.getTop, null, .{}); + pub const right = bridge.accessor(DOMRect.getRight, null, .{}); + pub const bottom = bridge.accessor(DOMRect.getBottom, null, .{}); + pub const left = bridge.accessor(DOMRect.getLeft, null, .{}); +}; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 0ca657579..7849e3e37 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -13,6 +13,8 @@ const Selector = @import("selector/Selector.zig"); pub const Attribute = @import("element/Attribute.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMStringMap = @import("element/DOMStringMap.zig"); +const DOMRect = @import("DOMRect.zig"); +const css = @import("css.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); @@ -467,6 +469,126 @@ pub fn querySelectorAll(self: *Element, input: []const u8, page: *Page) !*Select return Selector.querySelectorAll(self.asNode(), input, page); } +pub fn parentElement(self: *Element) ?*Element { + return self._proto.parentElement(); +} + +pub fn checkVisibility(self: *Element, page: *Page) !bool { + var current: ?*Element = self; + + while (current) |el| { + const style = try el.getStyle(page); + const display = style.asCSSStyleDeclaration().getPropertyValue("display", page); + if (std.mem.eql(u8, display, "none")) { + return false; + } + current = el.parentElement(); + } + + return true; +} + +pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { + const is_visible = try self.checkVisibility(page); + if (!is_visible) { + return page._factory.create(DOMRect{ + ._x = 0.0, + ._y = 0.0, + ._width = 0.0, + ._height = 0.0, + ._top = 0.0, + ._right = 0.0, + ._bottom = 0.0, + ._left = 0.0, + }); + } + + const y = calculateDocumentPosition(self.asNode()); + + var width: f64 = 1.0; + var height: f64 = 1.0; + + const style = try self.getStyle(page); + const decl = style.asCSSStyleDeclaration(); + width = css.parseDimension(decl.getPropertyValue("width", page)) orelse 1.0; + height = css.parseDimension(decl.getPropertyValue("height", page)) orelse 1.0; + + if (width == 1.0 or height == 1.0) { + const tag = self.getTag(); + if (tag == .img or tag == .iframe) { + if (self.getAttributeSafe("width")) |w| { + width = std.fmt.parseFloat(f64, w) catch width; + } + if (self.getAttributeSafe("height")) |h| { + height = std.fmt.parseFloat(f64, h) catch height; + } + } + } + + const x: f64 = 0.0; + const top = y; + const left = x; + const right = x + width; + const bottom = y + height; + + return page._factory.create(DOMRect{ + ._x = x, + ._y = y, + ._width = width, + ._height = height, + ._top = top, + ._right = right, + ._bottom = bottom, + ._left = left, + }); +} + +// Calculates a pseudo-position in the document using an efficient heuristic. +// +// Instead of walking the entire DOM tree (which would be O(total_nodes)), this +// function walks UP the tree counting previous siblings at each level. Each level +// uses exponential weighting (1000x per depth level) to preserve document order. +// +// This gives O(depth * avg_siblings) complexity while maintaining relative positioning +// that's useful for scraping and understanding element flow in the document. +// +// Example: +// → position 0 +//
→ position 0 (0 siblings at level 1) +// → position 0 (0 siblings at level 2) +// → position 1 (1 sibling at level 2) +//
+//
→ position 1000 (1 sibling at level 1, weighted by 1000) +//

→ position 1000 (0 siblings at level 2, parent has 1000) +//
+// +// +// Trade-offs: +// - Much faster than full tree-walking for deep/large DOMs +// - Positions reflect document order and parent-child relationships +// - Not pixel-accurate, but sufficient for 1x1 layout heuristics +fn calculateDocumentPosition(node: *Node) f64 { + var position: f64 = 0.0; + var multiplier: f64 = 1.0; + var current = node; + + while (current.parentNode()) |parent| { + var count: f64 = 0.0; + var sibling = parent.firstChild(); + while (sibling) |s| { + if (s == current) break; + count += 1.0; + sibling = s.nextSibling(); + } + + position += count * multiplier; + multiplier *= 1000.0; + current = parent; + } + + return position; +} + const GetElementsByTagNameResult = union(enum) { tag: collections.NodeLive(.tag), tag_name: collections.NodeLive(.tag_name), @@ -702,6 +824,8 @@ pub const JsApi = struct { pub const matches = bridge.function(Element.matches, .{ .dom_exception = true }); pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true }); + pub const checkVisibility = bridge.function(Element.checkVisibility, .{}); + pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{}); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{}); pub const children = bridge.accessor(Element.getChildren, null, .{}); diff --git a/src/browser/webapi/css.zig b/src/browser/webapi/css.zig new file mode 100644 index 000000000..ea4b1e908 --- /dev/null +++ b/src/browser/webapi/css.zig @@ -0,0 +1,14 @@ +const std = @import("std"); + +pub fn parseDimension(value: []const u8) ?f64 { + if (value.len == 0) { + return null; + } + + var num_str = value; + if (std.mem.endsWith(u8, value, "px")) { + num_str = value[0 .. value.len - 2]; + } + + return std.fmt.parseFloat(f64, num_str) catch null; +} diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 1b8c8424a..36569866d 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -57,13 +57,13 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 { return ""; } -pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { +pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const prop = self.findProperty(normalized) orelse return ""; return prop._value.str(); } -pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { +pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const prop = self.findProperty(normalized) orelse return ""; return if (prop._important) "important" else ""; diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index 2eeefff64..1de71ea4e 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -154,7 +154,7 @@ pub const JsApi = struct { } } - const value = try self._proto.getPropertyValue(dash_case, page); + const value = self._proto.getPropertyValue(dash_case, page); // Property accessors have special handling for empty values: // - Known CSS properties return '' when not set From 7a5cade51029b72a3e5c3a2ee2f1d35ef7257f5c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 20:30:02 +0800 Subject: [PATCH 020/257] remove 16 bytes from Element --- src/browser/Page.zig | 12 +++++++--- src/browser/webapi/Element.zig | 26 +++++++++++---------- src/browser/webapi/element/DOMStringMap.zig | 2 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index fa1c9fdd8..47d5f1238 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -63,8 +63,11 @@ _attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute), // the return of elements.attributes. _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap), -// element.dataset -> DOMStringMap -_element_datasets: std.AutoHashMapUnmanaged(*Element, *Element.DOMStringMap), +// Lazily-created style, classList, and dataset objects. Only stored for elements +// that actually access these features via JavaScript, saving 24 bytes per element. +_element_styles: Element.StyleLookup = .{}, +_element_datasets: Element.DatasetLookup = .{}, +_element_class_lists: Element.ClassListLookup = .{}, _script_manager: ScriptManager, @@ -155,7 +158,6 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._load_state = .parsing; self._attribute_lookup = .empty; self._attribute_named_node_map_lookup = .empty; - self._element_datasets = .empty; self._event_manager = EventManager.init(self); self._script_manager = ScriptManager.init(self); @@ -164,6 +166,10 @@ fn reset(self: *Page, comptime initializing: bool) !void { self.js = try self._session.executor.createContext(self, true, JS.GlobalMissingCallback.init(&self._polyfill_loader)); errdefer self.js.deinit(); + self._element_styles = .{}; + self._element_datasets = .{}; + self._element_class_lists = .{}; + try polyfill.preload(self.arena, self.js); try self.registerBackgroundTasks(); } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 7849e3e37..c29ad4c77 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -21,6 +21,10 @@ pub const Html = @import("element/Html.zig"); const Element = @This(); +pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); +pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); +pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); + pub const Namespace = enum(u8) { html, svg, @@ -41,8 +45,6 @@ _type: Type, _proto: *Node, _namespace: Namespace = .html, _attributes: ?*Attribute.List = null, -_style: ?*CSSStyleProperties = null, -_class_list: ?*collections.DOMTokenList = null, pub const Type = union(enum) { html: *Html, @@ -333,22 +335,22 @@ pub fn getAttributeNamedNodeMap(self: *Element, page: *Page) !*Attribute.NamedNo } pub fn getStyle(self: *Element, page: *Page) !*CSSStyleProperties { - return self._style orelse blk: { - const s = try CSSStyleProperties.init(self, page); - self._style = s; - break :blk s; - }; + const gop = try page._element_styles.getOrPut(page.arena, self); + if (!gop.found_existing) { + gop.value_ptr.* = try CSSStyleProperties.init(self, page); + } + return gop.value_ptr.*; } pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList { - return self._class_list orelse blk: { - const cl = try page._factory.create(collections.DOMTokenList{ + const gop = try page._element_class_lists.getOrPut(page.arena, self); + if (!gop.found_existing) { + gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{ ._element = self, ._attribute_name = "class", }); - self._class_list = cl; - break :blk cl; - }; + } + return gop.value_ptr.*; } pub fn getDataset(self: *Element, page: *Page) !*DOMStringMap { diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index b2aa8babf..4fd029552 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -83,5 +83,5 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const @"[]" = bridge.namedIndexed(_getProperty, _setProperty, _deleteProperty, .{.null_as_undefined = true}); + pub const @"[]" = bridge.namedIndexed(_getProperty, _setProperty, _deleteProperty, .{ .null_as_undefined = true }); }; From 6cf01631adda909c105e059c955ce6d750ffe8e1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 20:37:00 +0800 Subject: [PATCH 021/257] Document.activeElement, focus and blur --- src/browser/tests/document/focus.html | 81 +++++++++++++++++++++++++++ src/browser/webapi/Document.zig | 18 ++++++ src/browser/webapi/Element.zig | 32 +++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/browser/tests/document/focus.html diff --git a/src/browser/tests/document/focus.html b/src/browser/tests/document/focus.html new file mode 100644 index 000000000..5b7b7c078 --- /dev/null +++ b/src/browser/tests/document/focus.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index bbdd267c2..5d58d3aba 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -24,6 +24,7 @@ _location: ?*Location = null, _ready_state: ReadyState = .loading, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, +_active_element: ?*Element = null, pub const Type = union(enum) { generic, @@ -155,6 +156,22 @@ pub fn getReadyState(self: *const Document) []const u8 { return @tagName(self._ready_state); } +pub fn getActiveElement(self: *Document) ?*Element { + if (self._active_element) |el| { + return el; + } + + // Default to body if it exists + if (self.is(HTMLDocument)) |html_doc| { + if (html_doc.getBody()) |body| { + return body.asElement(); + } + } + + // Fallback to document element + return self.getDocumentElement(); +} + const ReadyState = enum { loading, interactive, @@ -182,6 +199,7 @@ pub const JsApi = struct { pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{}); pub const readyState = bridge.accessor(Document.getReadyState, null, .{}); pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); + pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const createElement = bridge.function(Document.createElement, .{}); pub const createElementNS = bridge.function(Document.createElementNS, .{}); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c29ad4c77..1c55e6f34 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -82,6 +82,10 @@ pub fn asNode(self: *Element) *Node { return self._proto; } +pub fn asEventTarget(self: *Element) *@import("EventTarget.zig") { + return self._proto.asEventTarget(); +} + pub fn asConstNode(self: *const Element) *const Node { return self._proto; } @@ -390,6 +394,32 @@ pub fn remove(self: *Element, page: *Page) void { page.removeNode(parent, node, .{ .will_be_reconnected = false }); } +pub fn focus(self: *Element, page: *Page) !void { + const Event = @import("Event.zig"); + + if (page.document._active_element) |old| { + if (old == self) return; + + const blur_event = try Event.init("blur", null, page); + try page._event_manager.dispatch(old.asEventTarget(), blur_event); + } + + page.document._active_element = self; + + const focus_event = try Event.init("focus", null, page); + try page._event_manager.dispatch(self.asEventTarget(), focus_event); +} + +pub fn blur(self: *Element, page: *Page) !void { + if (page.document._active_element != self) return; + + page.document._active_element = null; + + const Event = @import("Event.zig"); + const blur_event = try Event.init("blur", null, page); + try page._event_manager.dispatch(self.asEventTarget(), blur_event); +} + pub fn getChildren(self: *Element, page: *Page) !collections.NodeLive(.child_elements) { return collections.NodeLive(.child_elements).init(null, self.asNode(), {}, page); } @@ -831,6 +861,8 @@ pub const JsApi = struct { pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{}); pub const children = bridge.accessor(Element.getChildren, null, .{}); + pub const focus = bridge.function(Element.focus, .{}); + pub const blur = bridge.function(Element.blur, .{}); }; pub const Build = struct { From 6742646e89127cda58d42a2f99cfec8fa755e1c4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Nov 2025 20:57:17 +0800 Subject: [PATCH 022/257] DOMParser --- src/browser/js/bridge.zig | 1 + src/browser/tests/domparser.html | 121 +++++++++++++++++++++++++++++++ src/browser/webapi/DOMParser.zig | 57 +++++++++++++++ src/browser/webapi/Document.zig | 7 +- 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/domparser.html create mode 100644 src/browser/webapi/DOMParser.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4fb65b705..08bb93ca6 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -489,6 +489,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/DOMTreeWalker.zig"), @import("../webapi/DOMNodeIterator.zig"), @import("../webapi/DOMRect.zig"), + @import("../webapi/DOMParser.zig"), @import("../webapi/NodeFilter.zig"), @import("../webapi/Element.zig"), @import("../webapi/element/DOMStringMap.zig"), diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html new file mode 100644 index 000000000..390f7bfe7 --- /dev/null +++ b/src/browser/tests/domparser.html @@ -0,0 +1,121 @@ + + + + + + + + diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig new file mode 100644 index 000000000..df87f915d --- /dev/null +++ b/src/browser/webapi/DOMParser.zig @@ -0,0 +1,57 @@ +const std = @import("std"); + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Document = @import("Document.zig"); +const HTMLDocument = @import("HTMLDocument.zig"); + +const DOMParser = @This(); +// @ZIGDOM support empty structs +_: u8 = 0, + +pub fn init() DOMParser { + return .{}; +} + +pub fn parseFromString(self: *const DOMParser, html: []const u8, mime_type: []const u8, page: *Page) !*HTMLDocument { + _ = self; + + // For now, only support text/html + if (!std.mem.eql(u8, mime_type, "text/html")) { + return error.NotSupported; + } + + // Create a new HTMLDocument + const doc = try page._factory.document(HTMLDocument{ + ._proto = undefined, + }); + + // Parse HTML into the document + const Parser = @import("../parser/Parser.zig"); + var parser = Parser.init(page.arena, doc.asNode(), page); + parser.parse(html); + + if (parser.err) |pe| { + return pe.err; + } + + return doc; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMParser); + + pub const Meta = struct { + pub const name = "DOMParser"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DOMParser.init, .{}); + pub const parseFromString = bridge.function(DOMParser.parseFromString, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: DOMParser" { + try testing.htmlRunner("domparser.html", .{}); +} diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 5d58d3aba..767acf324 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -122,8 +122,11 @@ pub fn querySelectorAll(self: *Document, input: []const u8, page: *Page) !*Selec return Selector.querySelectorAll(self.asNode(), input, page); } -pub fn className(_: *const Document) []const u8 { - return "[object Document]"; +pub fn className(self: *const Document) []const u8 { + return switch (self._type) { + .generic => "[object Document]", + .html => "[object HTMLDocument]", + }; } pub fn getImplementation(_: *const Document) DOMImplementation { From 1164da5e7aa4fee56e36aa9b884b026b55872567 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Nov 2025 10:46:20 +0800 Subject: [PATCH 023/257] copyright notices --- src/App.zig | 18 ++++++++++++++ src/Notification.zig | 18 ++++++++++++++ src/Scheduler.zig | 18 ++++++++++++++ src/Server.zig | 2 +- src/TestHTTPServer.zig | 18 ++++++++++++++ src/browser/EventManager.zig | 18 ++++++++++++++ src/browser/Factory.zig | 18 ++++++++++++++ src/browser/Page.zig | 18 ++++++++++++++ src/browser/Scheduler.zig | 18 ++++++++++++++ src/browser/URL.zig | 18 ++++++++++++++ src/browser/dump.zig | 18 ++++++++++++++ src/browser/js/bridge.zig | 1 - src/browser/parser/Parser.zig | 19 +++++++++++++++ src/browser/parser/html5ever.zig | 19 +++++++++++++++ src/browser/reflect.zig | 18 ++++++++++++++ src/browser/webapi/AbortController.zig | 18 ++++++++++++++ src/browser/webapi/AbortSignal.zig | 18 ++++++++++++++ src/browser/webapi/CData.zig | 18 ++++++++++++++ src/browser/webapi/Console.zig | 18 ++++++++++++++ src/browser/webapi/Crypto.zig | 18 ++++++++++++++ src/browser/webapi/DOMException.zig | 18 ++++++++++++++ src/browser/webapi/DOMImplementation.zig | 18 ++++++++++++++ src/browser/webapi/DOMNodeIterator.zig | 18 ++++++++++++++ src/browser/webapi/DOMParser.zig | 18 ++++++++++++++ src/browser/webapi/DOMRect.zig | 18 ++++++++++++++ src/browser/webapi/DOMTreeWalker.zig | 18 ++++++++++++++ src/browser/webapi/Document.zig | 18 ++++++++++++++ src/browser/webapi/DocumentFragment.zig | 18 ++++++++++++++ src/browser/webapi/DocumentType.zig | 18 ++++++++++++++ src/browser/webapi/Element.zig | 18 ++++++++++++++ src/browser/webapi/Event.zig | 18 ++++++++++++++ src/browser/webapi/EventTarget.zig | 18 ++++++++++++++ src/browser/webapi/HTMLDocument.zig | 18 ++++++++++++++ src/browser/webapi/History.zig | 18 ++++++++++++++ src/browser/webapi/KeyValueList.zig | 18 ++++++++++++++ src/browser/webapi/Location.zig | 18 ++++++++++++++ src/browser/webapi/MutationObserver.zig | 18 ++++++++++++++ src/browser/webapi/Navigator.zig | 18 ++++++++++++++ src/browser/webapi/Node.zig | 18 ++++++++++++++ src/browser/webapi/NodeFilter.zig | 18 ++++++++++++++ src/browser/webapi/TreeWalker.zig | 18 ++++++++++++++ src/browser/webapi/URL.zig | 18 ++++++++++++++ src/browser/webapi/Window.zig | 24 ++++++++++++++----- src/browser/webapi/cdata/Comment.zig | 18 ++++++++++++++ src/browser/webapi/cdata/Text.zig | 18 ++++++++++++++ src/browser/webapi/collections.zig | 18 ++++++++++++++ src/browser/webapi/collections/ChildNodes.zig | 18 ++++++++++++++ .../webapi/collections/DOMTokenList.zig | 18 ++++++++++++++ .../webapi/collections/HTMLAllCollection.zig | 18 ++++++++++++++ .../webapi/collections/HTMLCollection.zig | 18 ++++++++++++++ src/browser/webapi/collections/NodeList.zig | 18 ++++++++++++++ src/browser/webapi/collections/iterator.zig | 18 ++++++++++++++ src/browser/webapi/collections/node_live.zig | 18 ++++++++++++++ src/browser/webapi/css.zig | 18 ++++++++++++++ .../webapi/css/CSSStyleDeclaration.zig | 18 ++++++++++++++ src/browser/webapi/css/CSSStyleProperties.zig | 18 ++++++++++++++ src/browser/webapi/css/MediaQueryList.zig | 18 ++++++++++++++ src/browser/webapi/element/Attribute.zig | 18 ++++++++++++++ src/browser/webapi/element/DOMStringMap.zig | 18 ++++++++++++++ src/browser/webapi/element/Html.zig | 18 ++++++++++++++ src/browser/webapi/element/Svg.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Anchor.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Body.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Button.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Custom.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Div.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Form.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Generic.zig | 18 ++++++++++++++ src/browser/webapi/element/html/HR.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Head.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Heading.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Html.zig | 18 ++++++++++++++ src/browser/webapi/element/html/IFrame.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Image.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Input.zig | 18 ++++++++++++++ src/browser/webapi/element/html/LI.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Link.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Meta.zig | 18 ++++++++++++++ src/browser/webapi/element/html/OL.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Option.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Paragraph.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Script.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Select.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Style.zig | 18 ++++++++++++++ src/browser/webapi/element/html/TextArea.zig | 18 ++++++++++++++ src/browser/webapi/element/html/UL.zig | 18 ++++++++++++++ src/browser/webapi/element/html/Unknown.zig | 18 ++++++++++++++ src/browser/webapi/element/svg/Generic.zig | 18 ++++++++++++++ src/browser/webapi/element/svg/Rect.zig | 18 ++++++++++++++ src/browser/webapi/encoding/TextDecoder.zig | 18 ++++++++++++++ src/browser/webapi/encoding/TextEncoder.zig | 18 ++++++++++++++ src/browser/webapi/event/ErrorEvent.zig | 18 ++++++++++++++ src/browser/webapi/event/ProgressEvent.zig | 18 ++++++++++++++ src/browser/webapi/intl/Intl.zig | 20 ---------------- src/browser/webapi/net/Fetch.zig | 18 ++++++++++++++ src/browser/webapi/net/FormData.zig | 18 ++++++++++++++ src/browser/webapi/net/Request.zig | 18 ++++++++++++++ src/browser/webapi/net/Response.zig | 18 ++++++++++++++ src/browser/webapi/net/URLSearchParams.zig | 18 ++++++++++++++ src/browser/webapi/net/XMLHttpRequest.zig | 18 ++++++++++++++ .../webapi/net/XMLHttpRequestEventTarget.zig | 18 ++++++++++++++ src/browser/webapi/selector/List.zig | 18 ++++++++++++++ src/browser/webapi/selector/Parser.zig | 18 ++++++++++++++ src/browser/webapi/selector/Selector.zig | 18 ++++++++++++++ src/browser/webapi/storage/cookie.zig | 18 ++++++++++++++ src/browser/webapi/storage/storage.zig | 18 ++++++++++++++ src/datetime.zig | 18 ++++++++++++++ src/html5ever/lib.rs | 18 ++++++++++++++ src/html5ever/sink.rs | 18 ++++++++++++++ src/html5ever/types.rs | 18 ++++++++++++++ src/id.zig | 18 ++++++++++++++ src/lightpanda.zig | 18 ++++++++++++++ src/log.zig | 2 +- src/main.zig | 2 +- src/main_wpt.zig | 2 +- src/string.zig | 18 ++++++++++++++ src/test_runner.zig | 18 ++++++++++++++ src/testing.zig | 2 +- 118 files changed, 2005 insertions(+), 32 deletions(-) delete mode 100644 src/browser/webapi/intl/Intl.zig diff --git a/src/App.zig b/src/App.zig index ef94486b1..24d015c01 100644 --- a/src/App.zig +++ b/src/App.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Allocator = std.mem.Allocator; diff --git a/src/Notification.zig b/src/Notification.zig index 89646cff0..f535abd92 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("log.zig"); diff --git a/src/Scheduler.zig b/src/Scheduler.zig index 0898d19b3..6ba8b6e18 100644 --- a/src/Scheduler.zig +++ b/src/Scheduler.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("log.zig"); diff --git a/src/Server.zig b/src/Server.zig index 4d42f0010..481b2cb32 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/TestHTTPServer.zig b/src/TestHTTPServer.zig index fdc51b904..fdf4e1247 100644 --- a/src/TestHTTPServer.zig +++ b/src/TestHTTPServer.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const TestHTTPServer = @This(); diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 5efa111be..aa5f023ad 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 7d75fb402..56d3eb985 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); const reflect = @import("reflect.zig"); diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 47d5f1238..0169e9d52 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const JS = @import("js/js.zig"); const builtin = @import("builtin"); diff --git a/src/browser/Scheduler.zig b/src/browser/Scheduler.zig index 4b4fa71b0..6ad048877 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/Scheduler.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); diff --git a/src/browser/URL.zig b/src/browser/URL.zig index d1b4d609c..cd56bbd83 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Allocator = std.mem.Allocator; diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 620ef470c..8efc8da49 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Node = @import("webapi/Node.zig"); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 08bb93ca6..7bf3cbc63 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -480,7 +480,6 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Document.zig"), @import("../webapi/HTMLDocument.zig"), @import("../webapi/History.zig"), - @import("../webapi/intl/Intl.zig"), @import("../webapi/KeyValueList.zig"), @import("../webapi/DocumentFragment.zig"), @import("../webapi/DocumentType.zig"), diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index a4db0aeb8..f4c6232fd 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -1,3 +1,22 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + const std = @import("std"); const h5e = @import("html5ever.zig"); diff --git a/src/browser/parser/html5ever.zig b/src/browser/parser/html5ever.zig index 1245f9f54..ea3e7668b 100644 --- a/src/browser/parser/html5ever.zig +++ b/src/browser/parser/html5ever.zig @@ -1,3 +1,22 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + const ParsedNode = @import("Parser.zig").ParsedNode; pub extern "c" fn html5ever_parse_document( diff --git a/src/browser/reflect.zig b/src/browser/reflect.zig index 66f096213..ad0c54be3 100644 --- a/src/browser/reflect.zig +++ b/src/browser/reflect.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); // Gets the Parent of child. diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index cd1325d40..13718b97f 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 4974a2aa2..40ac9e895 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 78bef052b..a0b569e8a 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index 43d603b77..e3e856ab9 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/Crypto.zig b/src/browser/webapi/Crypto.zig index 6c9e980d8..1b1e6f0fb 100644 --- a/src/browser/webapi/Crypto.zig +++ b/src/browser/webapi/Crypto.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 61ceac208..07c7137f1 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index 0b7aba793..e2a863571 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/DOMNodeIterator.zig b/src/browser/webapi/DOMNodeIterator.zig index 762f7bd73..3314416e5 100644 --- a/src/browser/webapi/DOMNodeIterator.zig +++ b/src/browser/webapi/DOMNodeIterator.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index df87f915d..358312955 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/DOMRect.zig b/src/browser/webapi/DOMRect.zig index 6309a20ea..4b3e36723 100644 --- a/src/browser/webapi/DOMRect.zig +++ b/src/browser/webapi/DOMRect.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const DOMRect = @This(); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/DOMTreeWalker.zig b/src/browser/webapi/DOMTreeWalker.zig index dd709c513..88ca271a1 100644 --- a/src/browser/webapi/DOMTreeWalker.zig +++ b/src/browser/webapi/DOMTreeWalker.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 767acf324..4f04f22f3 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const String = @import("../../string.zig").String; diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 38d15b538..9813d922f 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/DocumentType.zig b/src/browser/webapi/DocumentType.zig index c6ff06342..aab8052eb 100644 --- a/src/browser/webapi/DocumentType.zig +++ b/src/browser/webapi/DocumentType.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 1c55e6f34..9f0fdd5f5 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("../../log.zig"); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index e4b3ff6f2..9884ff855 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index a313bc731..23ecdf985 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index ed7e5b31f..5e22ecff1 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index ada62226d..3bc568662 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 3b105bd8f..c9eb70c8d 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const String = @import("../../string.zig").String; diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 25a4cab30..e7191a138 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../js/js.zig"); const URL = @import("URL.zig"); diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index a169f7194..e33f3223a 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../js/js.zig"); // @ZIGDOM (haha, bet you wish you hadn't opened this file) diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 26f7f609b..981fc2e1c 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const builtin = @import("builtin"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 7af7c4fad..d162ae81f 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("../../log.zig"); diff --git a/src/browser/webapi/NodeFilter.zig b/src/browser/webapi/NodeFilter.zig index 911e82dc2..c9fab4155 100644 --- a/src/browser/webapi/NodeFilter.zig +++ b/src/browser/webapi/NodeFilter.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); diff --git a/src/browser/webapi/TreeWalker.zig b/src/browser/webapi/TreeWalker.zig index cee99ff14..b6df32fd1 100644 --- a/src/browser/webapi/TreeWalker.zig +++ b/src/browser/webapi/TreeWalker.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const Node = @import("Node.zig"); const Element = @import("Element.zig"); diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 74eb3200f..15beb6c09 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 605b9fdc7..b3ac4414a 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../js/js.zig"); const builtin = @import("builtin"); @@ -6,7 +24,6 @@ const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); -const Intl = @import("intl/Intl.zig"); const Navigator = @import("Navigator.zig"); const Document = @import("Document.zig"); const Location = @import("Location.zig"); @@ -53,10 +70,6 @@ pub fn getNavigator(_: *const Window) Navigator { return .{}; } -pub fn getIntl(_: *const Window) Intl { - return .{}; -} - pub fn getLocalStorage(self: *const Window) *storage.Lookup { return &self._storage_bucket.local; } @@ -294,7 +307,6 @@ pub const JsApi = struct { pub const parent = bridge.accessor(Window.getWindow, null, .{ .cache = "parent" }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = "console" }); pub const navigator = bridge.accessor(Window.getNavigator, null, .{ .cache = "navigator" }); - pub const Intl = bridge.accessor(Window.getIntl, null, .{ .cache = "Intl" }); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{ .cache = "localStorage" }); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" }); pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" }); diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig index 39f84b192..f91faf895 100644 --- a/src/browser/webapi/cdata/Comment.zig +++ b/src/browser/webapi/cdata/Comment.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../js/js.zig"); const CData = @import("../CData.zig"); diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 83815f79d..ad440348c 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../js/js.zig"); const CData = @import("../CData.zig"); diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index cb6b2daad..0e091cbda 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + pub const NodeLive = @import("collections/node_live.zig").NodeLive; pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index f224b4374..1008d7e9d 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index a7c5525ab..67ba027f6 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig index 60aba31cf..f781986d3 100644 --- a/src/browser/webapi/collections/HTMLAllCollection.zig +++ b/src/browser/webapi/collections/HTMLAllCollection.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 34d2f0713..e3f42a904 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 5b672380b..b49a29b6b 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index ee7583f9d..2c16ed85b 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 9eef667d5..45eea51c9 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); diff --git a/src/browser/webapi/css.zig b/src/browser/webapi/css.zig index ea4b1e908..f285e8d2d 100644 --- a/src/browser/webapi/css.zig +++ b/src/browser/webapi/css.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); pub fn parseDimension(value: []const u8) ?f64 { diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 36569866d..887a8098d 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index 1de71ea4e..f595838e1 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig index f67e754c7..4e0da9710 100644 --- a/src/browser/webapi/css/MediaQueryList.zig +++ b/src/browser/webapi/css/MediaQueryList.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + // zlint-disable unused-decls const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 85d6bf6d5..66357754e 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index 4fd029552..518f996a8 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index c16b539d6..c418f1609 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../js/js.zig"); const reflect = @import("../../reflect.zig"); diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig index 561e2867a..71a7cab9c 100644 --- a/src/browser/webapi/element/Svg.zig +++ b/src/browser/webapi/element/Svg.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index d45d519bb..5a0f6b60d 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index cf04a9397..5be6d4fef 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index b3d44ddea..2e1a40165 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 6bfbfec4b..686389249 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); diff --git a/src/browser/webapi/element/html/Div.zig b/src/browser/webapi/element/html/Div.zig index 4789bf166..0fe21d950 100644 --- a/src/browser/webapi/element/html/Div.zig +++ b/src/browser/webapi/element/html/Div.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index f9e098034..66f23ddec 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Generic.zig b/src/browser/webapi/element/html/Generic.zig index a567a938f..15e7d1b13 100644 --- a/src/browser/webapi/element/html/Generic.zig +++ b/src/browser/webapi/element/html/Generic.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); diff --git a/src/browser/webapi/element/html/HR.zig b/src/browser/webapi/element/html/HR.zig index 262bc8016..231e0b1ae 100644 --- a/src/browser/webapi/element/html/HR.zig +++ b/src/browser/webapi/element/html/HR.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Head.zig b/src/browser/webapi/element/html/Head.zig index cd4afb4da..5bb081630 100644 --- a/src/browser/webapi/element/html/Head.zig +++ b/src/browser/webapi/element/html/Head.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Heading.zig b/src/browser/webapi/element/html/Heading.zig index 2a185ecbe..a700bdf02 100644 --- a/src/browser/webapi/element/html/Heading.zig +++ b/src/browser/webapi/element/html/Heading.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); diff --git a/src/browser/webapi/element/html/Html.zig b/src/browser/webapi/element/html/Html.zig index 12b69b821..94fa2c333 100644 --- a/src/browser/webapi/element/html/Html.zig +++ b/src/browser/webapi/element/html/Html.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index a92676662..b08300638 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 0d1ac1e4a..2cbd2634d 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 72d8b0e10..d805ba6fe 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/LI.zig b/src/browser/webapi/element/html/LI.zig index cf816d8b9..e02130208 100644 --- a/src/browser/webapi/element/html/LI.zig +++ b/src/browser/webapi/element/html/LI.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index a108a8e07..3fbfdaa06 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Meta.zig b/src/browser/webapi/element/html/Meta.zig index d9ed67469..900d49328 100644 --- a/src/browser/webapi/element/html/Meta.zig +++ b/src/browser/webapi/element/html/Meta.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/OL.zig b/src/browser/webapi/element/html/OL.zig index a19ebda11..844205d1f 100644 --- a/src/browser/webapi/element/html/OL.zig +++ b/src/browser/webapi/element/html/OL.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 311a00b8b..5123e088e 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Paragraph.zig b/src/browser/webapi/element/html/Paragraph.zig index bf4b13dea..0822703a6 100644 --- a/src/browser/webapi/element/html/Paragraph.zig +++ b/src/browser/webapi/element/html/Paragraph.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 6bf306d3c..1e548c4e3 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 23bf540b7..c521c3f42 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index efb7eaeeb..d774e93e9 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index fa6732aed..dcb282f05 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); diff --git a/src/browser/webapi/element/html/UL.zig b/src/browser/webapi/element/html/UL.zig index d4f5ac1a0..14bd69a20 100644 --- a/src/browser/webapi/element/html/UL.zig +++ b/src/browser/webapi/element/html/UL.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/html/Unknown.zig b/src/browser/webapi/element/html/Unknown.zig index 23e375852..0ea8f9473 100644 --- a/src/browser/webapi/element/html/Unknown.zig +++ b/src/browser/webapi/element/html/Unknown.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); diff --git a/src/browser/webapi/element/svg/Generic.zig b/src/browser/webapi/element/svg/Generic.zig index f5b3a2605..368370e56 100644 --- a/src/browser/webapi/element/svg/Generic.zig +++ b/src/browser/webapi/element/svg/Generic.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig index 7af604eed..0b79cc386 100644 --- a/src/browser/webapi/element/svg/Rect.zig +++ b/src/browser/webapi/element/svg/Rect.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 547319b5d..3148868b7 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/encoding/TextEncoder.zig b/src/browser/webapi/encoding/TextEncoder.zig index a1648c458..c7066d5ec 100644 --- a/src/browser/webapi/encoding/TextEncoder.zig +++ b/src/browser/webapi/encoding/TextEncoder.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 896679245..9c7f15700 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index 9406b2ebf..6e824a787 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); diff --git a/src/browser/webapi/intl/Intl.zig b/src/browser/webapi/intl/Intl.zig deleted file mode 100644 index 4015d478e..000000000 --- a/src/browser/webapi/intl/Intl.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const js = @import("../../js/js.zig"); - -const Intl = @This(); - -// Skeleton implementation with no actual functionality yet. -// This allows `if (Intl)` checks to pass, while property checks -// like `if (Intl.Locale)` will return undefined. -// We can add actual implementations as we encounter real-world use cases. - -pub const JsApi = struct { - pub const bridge = js.Bridge(Intl); - - pub const Meta = struct { - pub const name = "Intl"; - pub var class_id: bridge.ClassId = undefined; - pub const prototype_chain = bridge.prototypeChain(); - pub const empty_with_no_proto = true; - }; -}; diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 0d4853f98..7bbc2da94 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("../../../log.zig"); diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index c44d7e824..610c88bf3 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const log = @import("../../../log.zig"); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 5344403e8..d715c53b2 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 549e69c17..d072f7b6c 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index b00f93786..64bc80863 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 11a36f579..dfb848e66 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index cb1418bf1..c5568a9ae 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 0bedc46ca..449fc70b2 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Page = @import("../../Page.zig"); diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 335f22456..7d2a058fe 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Allocator = std.mem.Allocator; diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 8839b1b6f..3f72c442b 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Parser = @import("Parser.zig"); diff --git a/src/browser/webapi/storage/cookie.zig b/src/browser/webapi/storage/cookie.zig index 69d17abea..25d6f51dd 100644 --- a/src/browser/webapi/storage/cookie.zig +++ b/src/browser/webapi/storage/cookie.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Uri = std.Uri; const Allocator = std.mem.Allocator; diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 2e7e2609f..acaaa3fdf 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); diff --git a/src/datetime.zig b/src/datetime.zig index ec0740787..5be7d6047 100644 --- a/src/datetime.zig +++ b/src/datetime.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; diff --git a/src/html5ever/lib.rs b/src/html5ever/lib.rs index ee1b612b9..6128b58c6 100644 --- a/src/html5ever/lib.rs +++ b/src/html5ever/lib.rs @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + mod types; mod sink; diff --git a/src/html5ever/sink.rs b/src/html5ever/sink.rs index b468afa5f..21d3a47e4 100644 --- a/src/html5ever/sink.rs +++ b/src/html5ever/sink.rs @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + use std::ptr; use std::cell::Cell; use std::borrow::Cow; diff --git a/src/html5ever/types.rs b/src/html5ever/types.rs index a38f03a17..f87c8723b 100644 --- a/src/html5ever/types.rs +++ b/src/html5ever/types.rs @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + use std::ptr; use html5ever::{QualName, Attribute}; use std::os::raw::{c_uchar, c_void}; diff --git a/src/id.zig b/src/id.zig index 98594c0b5..8f43dbc66 100644 --- a/src/id.zig +++ b/src/id.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); // Generates incrementing prefixed integers, i.e. CTX-1, CTX-2, CTX-3. diff --git a/src/lightpanda.zig b/src/lightpanda.zig index f037ce3e0..57d277934 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); pub const App = @import("App.zig"); pub const Server = @import("Server.zig"); diff --git a/src/log.zig b/src/log.zig index f791e9f7b..e34329e8b 100644 --- a/src/log.zig +++ b/src/log.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/main.zig b/src/main.zig index 1da7af4bc..42ad8d0f6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/main_wpt.zig b/src/main_wpt.zig index ddda29c5e..99d7adc6b 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/string.zig b/src/string.zig index 13ac9d884..90966d881 100644 --- a/src/string.zig +++ b/src/string.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const js = @import("browser/js/js.zig"); const Allocator = std.mem.Allocator; diff --git a/src/test_runner.zig b/src/test_runner.zig index 2979fe0d6..c4e5d597d 100644 --- a/src/test_runner.zig +++ b/src/test_runner.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const builtin = @import("builtin"); diff --git a/src/testing.zig b/src/testing.zig index a4805f985..77cc1f00a 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire From 7ab88e9a711a36f7d0a19827f61e808d30d55634 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Nov 2025 15:55:02 +0800 Subject: [PATCH 024/257] add legacy tests, optimize empty types --- build.zig | 27 ++ src/browser/js/Context.zig | 44 ++- src/browser/js/Env.zig | 4 +- src/browser/tests/domparser.html | 7 +- src/browser/tests/legacy/browser.html | 10 + src/browser/tests/legacy/crypto.html | 26 ++ src/browser/tests/legacy/css.html | 6 + .../tests/legacy/cssom/css_rule_list.html | 8 + .../legacy/cssom/css_style_declaration.html | 102 ++++++ .../tests/legacy/cssom/css_stylesheet.html | 16 + src/browser/tests/legacy/dom/animation.html | 15 + src/browser/tests/legacy/dom/attribute.html | 33 ++ .../tests/legacy/dom/character_data.html | 48 +++ src/browser/tests/legacy/dom/comment.html | 9 + src/browser/tests/legacy/dom/document.html | 190 ++++++++++ .../tests/legacy/dom/document_fragment.html | 34 ++ .../tests/legacy/dom/document_type.html | 13 + src/browser/tests/legacy/dom/dom_parser.html | 7 + src/browser/tests/legacy/dom/element.html | 341 ++++++++++++++++++ .../tests/legacy/dom/event_target.html | 116 ++++++ src/browser/tests/legacy/dom/exceptions.html | 40 ++ .../tests/legacy/dom/html_collection.html | 67 ++++ .../tests/legacy/dom/implementation.html | 14 + .../legacy/dom/intersection_observer.html | 163 +++++++++ .../tests/legacy/dom/message_channel.html | 60 +++ .../tests/legacy/dom/mutation_observer.html | 76 ++++ .../tests/legacy/dom/named_node_map.html | 19 + src/browser/tests/legacy/dom/node.html | 266 ++++++++++++++ src/browser/tests/legacy/dom/node_filter.html | 219 +++++++++++ .../tests/legacy/dom/node_iterator.html | 62 ++++ src/browser/tests/legacy/dom/node_list.html | 19 + src/browser/tests/legacy/dom/node_owner.html | 34 ++ src/browser/tests/legacy/dom/performance.html | 16 + .../legacy/dom/performance_observer.html | 5 + .../legacy/dom/processing_instruction.html | 22 ++ src/browser/tests/legacy/dom/range.html | 41 +++ src/browser/tests/legacy/dom/shadow_root.html | 49 +++ src/browser/tests/legacy/dom/text.html | 19 + src/browser/tests/legacy/dom/token_list.html | 64 ++++ .../tests/legacy/encoding/decoder.html | 60 +++ .../tests/legacy/encoding/encoder.html | 14 + .../tests/legacy/events/composition.html | 36 ++ src/browser/tests/legacy/events/custom.html | 25 ++ src/browser/tests/legacy/events/event.html | 139 +++++++ src/browser/tests/legacy/events/keyboard.html | 88 +++++ src/browser/tests/legacy/events/mouse.html | 34 ++ src/browser/tests/legacy/fetch/fetch.html | 34 ++ src/browser/tests/legacy/fetch/headers.html | 102 ++++++ src/browser/tests/legacy/fetch/request.html | 22 ++ src/browser/tests/legacy/fetch/response.html | 50 +++ src/browser/tests/legacy/file/blob.html | 125 +++++++ src/browser/tests/legacy/file/file.html | 7 + .../tests/legacy/html/abort_controller.html | 41 +++ src/browser/tests/legacy/html/canvas.html | 29 ++ src/browser/tests/legacy/html/dataset.html | 30 ++ src/browser/tests/legacy/html/document.html | 85 +++++ src/browser/tests/legacy/html/element.html | 53 +++ .../tests/legacy/html/error_event.html | 25 ++ .../tests/legacy/html/history/history.html | 37 ++ .../tests/legacy/html/history/history2.html | 26 ++ .../html/history/history_after_nav.html | 6 + src/browser/tests/legacy/html/image.html | 32 ++ src/browser/tests/legacy/html/input.html | 111 ++++++ src/browser/tests/legacy/html/link.html | 60 +++ src/browser/tests/legacy/html/location.html | 33 ++ .../legacy/html/navigation/navigation.html | 18 + .../legacy/html/navigation/navigation2.html | 8 + .../navigation_currententrychange.html | 15 + src/browser/tests/legacy/html/navigator.html | 8 + src/browser/tests/legacy/html/screen.html | 21 ++ .../legacy/html/script/dynamic_import.html | 32 ++ .../tests/legacy/html/script/import.html | 15 + .../tests/legacy/html/script/import.js | 2 + .../tests/legacy/html/script/import2.js | 2 + .../tests/legacy/html/script/importmap.html | 24 ++ .../legacy/html/script/inline_defer.html | 28 ++ .../tests/legacy/html/script/inline_defer.js | 1 + .../tests/legacy/html/script/order.html | 35 ++ src/browser/tests/legacy/html/script/order.js | 2 + .../tests/legacy/html/script/order_async.js | 3 + .../tests/legacy/html/script/order_defer.js | 2 + .../tests/legacy/html/script/script.html | 21 ++ src/browser/tests/legacy/html/select.html | 80 ++++ src/browser/tests/legacy/html/slot.html | 179 +++++++++ src/browser/tests/legacy/html/style.html | 8 + src/browser/tests/legacy/html/svg.html | 38 ++ src/browser/tests/legacy/html/template.html | 38 ++ .../tests/legacy/polyfill/webcomponents.html | 23 ++ .../tests/legacy/storage/local_storage.html | 29 ++ .../tests/legacy/streams/readable_stream.html | 134 +++++++ src/browser/tests/legacy/testing.js | 206 +++++++++++ src/browser/tests/legacy/url/url.html | 109 ++++++ .../tests/legacy/url/url_search_params.html | 94 +++++ src/browser/tests/legacy/window/frames.html | 13 + src/browser/tests/legacy/window/window.html | 167 +++++++++ src/browser/tests/legacy/xhr/form_data.html | 130 +++++++ .../tests/legacy/xhr/progress_event.html | 17 + src/browser/tests/legacy/xhr/xhr.html | 110 ++++++ src/browser/tests/legacy/xmlserializer.html | 8 + src/browser/webapi/DOMParser.zig | 3 +- src/browser/webapi/NodeFilter.zig | 1 + src/lightpanda.zig | 5 +- src/main_legacy_test.zig | 238 ++++++++++++ tests/html/bug-html-parsing-4.html | 6 - 104 files changed, 5461 insertions(+), 27 deletions(-) create mode 100644 src/browser/tests/legacy/browser.html create mode 100644 src/browser/tests/legacy/crypto.html create mode 100644 src/browser/tests/legacy/css.html create mode 100644 src/browser/tests/legacy/cssom/css_rule_list.html create mode 100644 src/browser/tests/legacy/cssom/css_style_declaration.html create mode 100644 src/browser/tests/legacy/cssom/css_stylesheet.html create mode 100644 src/browser/tests/legacy/dom/animation.html create mode 100644 src/browser/tests/legacy/dom/attribute.html create mode 100644 src/browser/tests/legacy/dom/character_data.html create mode 100644 src/browser/tests/legacy/dom/comment.html create mode 100644 src/browser/tests/legacy/dom/document.html create mode 100644 src/browser/tests/legacy/dom/document_fragment.html create mode 100644 src/browser/tests/legacy/dom/document_type.html create mode 100644 src/browser/tests/legacy/dom/dom_parser.html create mode 100644 src/browser/tests/legacy/dom/element.html create mode 100644 src/browser/tests/legacy/dom/event_target.html create mode 100644 src/browser/tests/legacy/dom/exceptions.html create mode 100644 src/browser/tests/legacy/dom/html_collection.html create mode 100644 src/browser/tests/legacy/dom/implementation.html create mode 100644 src/browser/tests/legacy/dom/intersection_observer.html create mode 100644 src/browser/tests/legacy/dom/message_channel.html create mode 100644 src/browser/tests/legacy/dom/mutation_observer.html create mode 100644 src/browser/tests/legacy/dom/named_node_map.html create mode 100644 src/browser/tests/legacy/dom/node.html create mode 100644 src/browser/tests/legacy/dom/node_filter.html create mode 100644 src/browser/tests/legacy/dom/node_iterator.html create mode 100644 src/browser/tests/legacy/dom/node_list.html create mode 100644 src/browser/tests/legacy/dom/node_owner.html create mode 100644 src/browser/tests/legacy/dom/performance.html create mode 100644 src/browser/tests/legacy/dom/performance_observer.html create mode 100644 src/browser/tests/legacy/dom/processing_instruction.html create mode 100644 src/browser/tests/legacy/dom/range.html create mode 100644 src/browser/tests/legacy/dom/shadow_root.html create mode 100644 src/browser/tests/legacy/dom/text.html create mode 100644 src/browser/tests/legacy/dom/token_list.html create mode 100644 src/browser/tests/legacy/encoding/decoder.html create mode 100644 src/browser/tests/legacy/encoding/encoder.html create mode 100644 src/browser/tests/legacy/events/composition.html create mode 100644 src/browser/tests/legacy/events/custom.html create mode 100644 src/browser/tests/legacy/events/event.html create mode 100644 src/browser/tests/legacy/events/keyboard.html create mode 100644 src/browser/tests/legacy/events/mouse.html create mode 100644 src/browser/tests/legacy/fetch/fetch.html create mode 100644 src/browser/tests/legacy/fetch/headers.html create mode 100644 src/browser/tests/legacy/fetch/request.html create mode 100644 src/browser/tests/legacy/fetch/response.html create mode 100644 src/browser/tests/legacy/file/blob.html create mode 100644 src/browser/tests/legacy/file/file.html create mode 100644 src/browser/tests/legacy/html/abort_controller.html create mode 100644 src/browser/tests/legacy/html/canvas.html create mode 100644 src/browser/tests/legacy/html/dataset.html create mode 100644 src/browser/tests/legacy/html/document.html create mode 100644 src/browser/tests/legacy/html/element.html create mode 100644 src/browser/tests/legacy/html/error_event.html create mode 100644 src/browser/tests/legacy/html/history/history.html create mode 100644 src/browser/tests/legacy/html/history/history2.html create mode 100644 src/browser/tests/legacy/html/history/history_after_nav.html create mode 100644 src/browser/tests/legacy/html/image.html create mode 100644 src/browser/tests/legacy/html/input.html create mode 100644 src/browser/tests/legacy/html/link.html create mode 100644 src/browser/tests/legacy/html/location.html create mode 100644 src/browser/tests/legacy/html/navigation/navigation.html create mode 100644 src/browser/tests/legacy/html/navigation/navigation2.html create mode 100644 src/browser/tests/legacy/html/navigation/navigation_currententrychange.html create mode 100644 src/browser/tests/legacy/html/navigator.html create mode 100644 src/browser/tests/legacy/html/screen.html create mode 100644 src/browser/tests/legacy/html/script/dynamic_import.html create mode 100644 src/browser/tests/legacy/html/script/import.html create mode 100644 src/browser/tests/legacy/html/script/import.js create mode 100644 src/browser/tests/legacy/html/script/import2.js create mode 100644 src/browser/tests/legacy/html/script/importmap.html create mode 100644 src/browser/tests/legacy/html/script/inline_defer.html create mode 100644 src/browser/tests/legacy/html/script/inline_defer.js create mode 100644 src/browser/tests/legacy/html/script/order.html create mode 100644 src/browser/tests/legacy/html/script/order.js create mode 100644 src/browser/tests/legacy/html/script/order_async.js create mode 100644 src/browser/tests/legacy/html/script/order_defer.js create mode 100644 src/browser/tests/legacy/html/script/script.html create mode 100644 src/browser/tests/legacy/html/select.html create mode 100644 src/browser/tests/legacy/html/slot.html create mode 100644 src/browser/tests/legacy/html/style.html create mode 100644 src/browser/tests/legacy/html/svg.html create mode 100644 src/browser/tests/legacy/html/template.html create mode 100644 src/browser/tests/legacy/polyfill/webcomponents.html create mode 100644 src/browser/tests/legacy/storage/local_storage.html create mode 100644 src/browser/tests/legacy/streams/readable_stream.html create mode 100644 src/browser/tests/legacy/testing.js create mode 100644 src/browser/tests/legacy/url/url.html create mode 100644 src/browser/tests/legacy/url/url_search_params.html create mode 100644 src/browser/tests/legacy/window/frames.html create mode 100644 src/browser/tests/legacy/window/window.html create mode 100644 src/browser/tests/legacy/xhr/form_data.html create mode 100644 src/browser/tests/legacy/xhr/progress_event.html create mode 100644 src/browser/tests/legacy/xhr/xhr.html create mode 100644 src/browser/tests/legacy/xmlserializer.html create mode 100644 src/main_legacy_test.zig delete mode 100644 tests/html/bug-html-parsing-4.html diff --git a/build.zig b/build.zig index d7effb26b..9f6271699 100644 --- a/build.zig +++ b/build.zig @@ -112,6 +112,33 @@ pub fn build(b: *Build) !void { test_step.dependOn(&run_tests.step); } + { + // ZIGDOM + // browser + const exe = b.addExecutable(.{ + .name = "legacy_test", + .use_llvm = true, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_legacy_test.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = enable_csan, + .sanitize_thread = enable_tsan, + .imports = &.{ + .{.name = "lightpanda", .module = lightpanda_module}, + }, + }), + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + if (b.args) |args| { + run_cmd.addArgs(args); + } + const run_step = b.step("legacy_test", "Run the app"); + run_step.dependOn(&run_cmd.step); + } + { // wpt const exe = b.addExecutable(.{ diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 5b6b510bb..72ce1bef6 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -615,6 +615,8 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) ! } const isolate = self.isolate; + const JsApi = bridge.Struct(ptr.child).JsApi; + // Sometimes we're creating a new v8.Object, like when // we're returning a value from a function. In those cases // we have to get the object template, and we can get an object @@ -626,19 +628,26 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) ! const template = self.templates[resolved.class_id]; break :blk template.getInstanceTemplate().initInstance(v8_context); }; - const JsApi = bridge.Struct(ptr.child).JsApi; - // The TAO contains the pointer to our Zig instance as - // well as any meta data we'll need to use it later. - // See the TaggedAnyOpaque struct for more details. - const tao = try arena.create(TaggedAnyOpaque); - tao.* = .{ - .value = resolved.ptr, - .prototype_chain = resolved.prototype_chain.ptr, - .prototype_len = @intCast(resolved.prototype_chain.len), - .subtype = if (@hasDecl(JsApi.Meta, "subtype")) JsApi.Meta.subype else .node, - }; - js_obj.setInternalField(0, v8.External.init(isolate, tao)); + if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) { + // The TAO contains the pointer to our Zig instance as + // well as any meta data we'll need to use it later. + // See the TaggedAnyOpaque struct for more details. + const tao = try arena.create(TaggedAnyOpaque); + tao.* = .{ + .value = resolved.ptr, + .prototype_chain = resolved.prototype_chain.ptr, + .prototype_len = @intCast(resolved.prototype_chain.len), + .subtype = if (@hasDecl(JsApi.Meta, "subtype")) JsApi.Meta.subype else .node, + }; + js_obj.setInternalField(0, v8.External.init(isolate, tao)); + } else { + // If the struct is empty, we don't need to do all + // the TOA stuff and setting the internal data. + // When we try to map this from JS->Zig, in + // typeTaggedAnyOpaque, we'll also know there that + // the type is empty and can create an empty instance. + } const js_persistent = PersistentObject.init(isolate, js_obj); gop.value_ptr.* = js_persistent; @@ -1504,6 +1513,15 @@ pub fn typeTaggedAnyOpaque(comptime R: type, js_obj: v8.Object) !R { } const T = ti.pointer.child; + const JsApi = bridge.Struct(T).JsApi; + + if (@hasDecl(JsApi.Meta, "empty_with_no_proto")) { + // Empty structs aren't stored as TOAs and there's no data + // stored in the JSObject's IntenrnalField. Why bother when + // we can just return an empty struct here? + return @constCast(@as(*const T, &.{})); + } + // if it isn't an empty struct, then the v8.Object should have an // InternalFieldCount > 0, since our toa pointer should be embedded // at index 0 of the internal field count. @@ -1511,7 +1529,7 @@ pub fn typeTaggedAnyOpaque(comptime R: type, js_obj: v8.Object) !R { return error.InvalidArgument; } - const type_name = @typeName(bridge.Struct(T).JsApi); + const type_name = @typeName(JsApi); if (@hasField(bridge.JsApiLookup, type_name) == false) { @compileError("unknown Zig type: " ++ @typeName(R)); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 71bed313a..d75afd4bd 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -314,7 +314,9 @@ fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTem }; const template = v8.FunctionTemplate.initCallback(isolate, callback); - template.getInstanceTemplate().setInternalFieldCount(1); + if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) { + template.getInstanceTemplate().setInternalFieldCount(1); + } const class_name = v8.String.initUtf8(isolate, if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi)); template.setClassName(class_name); return template; diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html index 390f7bfe7..660143889 100644 --- a/src/browser/tests/domparser.html +++ b/src/browser/tests/domparser.html @@ -1,13 +1,13 @@ - + - diff --git a/src/browser/tests/legacy/browser.html b/src/browser/tests/legacy/browser.html new file mode 100644 index 000000000..1f60488bf --- /dev/null +++ b/src/browser/tests/legacy/browser.html @@ -0,0 +1,10 @@ + + + diff --git a/src/browser/tests/legacy/crypto.html b/src/browser/tests/legacy/crypto.html new file mode 100644 index 000000000..f1dc291a7 --- /dev/null +++ b/src/browser/tests/legacy/crypto.html @@ -0,0 +1,26 @@ + + + diff --git a/src/browser/tests/legacy/css.html b/src/browser/tests/legacy/css.html new file mode 100644 index 000000000..3f83e9348 --- /dev/null +++ b/src/browser/tests/legacy/css.html @@ -0,0 +1,6 @@ + + + diff --git a/src/browser/tests/legacy/cssom/css_rule_list.html b/src/browser/tests/legacy/cssom/css_rule_list.html new file mode 100644 index 000000000..577781e4f --- /dev/null +++ b/src/browser/tests/legacy/cssom/css_rule_list.html @@ -0,0 +1,8 @@ + + + diff --git a/src/browser/tests/legacy/cssom/css_style_declaration.html b/src/browser/tests/legacy/cssom/css_style_declaration.html new file mode 100644 index 000000000..ee4d3cd9e --- /dev/null +++ b/src/browser/tests/legacy/cssom/css_style_declaration.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/cssom/css_stylesheet.html b/src/browser/tests/legacy/cssom/css_stylesheet.html new file mode 100644 index 000000000..223ee2cdb --- /dev/null +++ b/src/browser/tests/legacy/cssom/css_stylesheet.html @@ -0,0 +1,16 @@ + + + diff --git a/src/browser/tests/legacy/dom/animation.html b/src/browser/tests/legacy/dom/animation.html new file mode 100644 index 000000000..27e562a0f --- /dev/null +++ b/src/browser/tests/legacy/dom/animation.html @@ -0,0 +1,15 @@ + + + + diff --git a/src/browser/tests/legacy/dom/attribute.html b/src/browser/tests/legacy/dom/attribute.html new file mode 100644 index 000000000..2e2088615 --- /dev/null +++ b/src/browser/tests/legacy/dom/attribute.html @@ -0,0 +1,33 @@ + + + +OK + + diff --git a/src/browser/tests/legacy/dom/character_data.html b/src/browser/tests/legacy/dom/character_data.html new file mode 100644 index 000000000..ff74da90c --- /dev/null +++ b/src/browser/tests/legacy/dom/character_data.html @@ -0,0 +1,48 @@ + + + +OK + + diff --git a/src/browser/tests/legacy/dom/comment.html b/src/browser/tests/legacy/dom/comment.html new file mode 100644 index 000000000..2f87846cb --- /dev/null +++ b/src/browser/tests/legacy/dom/comment.html @@ -0,0 +1,9 @@ + + + diff --git a/src/browser/tests/legacy/dom/document.html b/src/browser/tests/legacy/dom/document.html new file mode 100644 index 000000000..950daaab6 --- /dev/null +++ b/src/browser/tests/legacy/dom/document.html @@ -0,0 +1,190 @@ + + + +
+ OK +

+ +

+

And

+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/dom/document_fragment.html b/src/browser/tests/legacy/dom/document_fragment.html new file mode 100644 index 000000000..ff02b3a40 --- /dev/null +++ b/src/browser/tests/legacy/dom/document_fragment.html @@ -0,0 +1,34 @@ + + + + + diff --git a/src/browser/tests/legacy/dom/document_type.html b/src/browser/tests/legacy/dom/document_type.html new file mode 100644 index 000000000..ff7cdbc82 --- /dev/null +++ b/src/browser/tests/legacy/dom/document_type.html @@ -0,0 +1,13 @@ + + + diff --git a/src/browser/tests/legacy/dom/dom_parser.html b/src/browser/tests/legacy/dom/dom_parser.html new file mode 100644 index 000000000..bf9bec8aa --- /dev/null +++ b/src/browser/tests/legacy/dom/dom_parser.html @@ -0,0 +1,7 @@ + + + diff --git a/src/browser/tests/legacy/dom/element.html b/src/browser/tests/legacy/dom/element.html new file mode 100644 index 000000000..3255b7d2f --- /dev/null +++ b/src/browser/tests/legacy/dom/element.html @@ -0,0 +1,341 @@ + + + +
+ OK +

+ +

+

And

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

content

+
+
+ + + + diff --git a/src/browser/tests/legacy/dom/event_target.html b/src/browser/tests/legacy/dom/event_target.html new file mode 100644 index 000000000..68fb8c6b1 --- /dev/null +++ b/src/browser/tests/legacy/dom/event_target.html @@ -0,0 +1,116 @@ + + + +

+ + diff --git a/src/browser/tests/legacy/dom/exceptions.html b/src/browser/tests/legacy/dom/exceptions.html new file mode 100644 index 000000000..c6bb91f1c --- /dev/null +++ b/src/browser/tests/legacy/dom/exceptions.html @@ -0,0 +1,40 @@ + + + +
+ OK +
+ + + + diff --git a/src/browser/tests/legacy/dom/html_collection.html b/src/browser/tests/legacy/dom/html_collection.html new file mode 100644 index 000000000..22590e581 --- /dev/null +++ b/src/browser/tests/legacy/dom/html_collection.html @@ -0,0 +1,67 @@ + + +
+ OK +

+ +

+

And

+ +
+ + + + + + + + + + diff --git a/src/browser/tests/legacy/dom/implementation.html b/src/browser/tests/legacy/dom/implementation.html new file mode 100644 index 000000000..81cce8041 --- /dev/null +++ b/src/browser/tests/legacy/dom/implementation.html @@ -0,0 +1,14 @@ + + + diff --git a/src/browser/tests/legacy/dom/intersection_observer.html b/src/browser/tests/legacy/dom/intersection_observer.html new file mode 100644 index 000000000..4067edba2 --- /dev/null +++ b/src/browser/tests/legacy/dom/intersection_observer.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/dom/message_channel.html b/src/browser/tests/legacy/dom/message_channel.html new file mode 100644 index 000000000..2ab075e54 --- /dev/null +++ b/src/browser/tests/legacy/dom/message_channel.html @@ -0,0 +1,60 @@ + + + diff --git a/src/browser/tests/legacy/dom/mutation_observer.html b/src/browser/tests/legacy/dom/mutation_observer.html new file mode 100644 index 000000000..f67cb9247 --- /dev/null +++ b/src/browser/tests/legacy/dom/mutation_observer.html @@ -0,0 +1,76 @@ + +
+

And

+

And

+

And

+ + + + + diff --git a/src/browser/tests/legacy/dom/named_node_map.html b/src/browser/tests/legacy/dom/named_node_map.html new file mode 100644 index 000000000..7cdcf4b71 --- /dev/null +++ b/src/browser/tests/legacy/dom/named_node_map.html @@ -0,0 +1,19 @@ + +
+ + + diff --git a/src/browser/tests/legacy/dom/node.html b/src/browser/tests/legacy/dom/node.html new file mode 100644 index 000000000..ae9b8a3ec --- /dev/null +++ b/src/browser/tests/legacy/dom/node.html @@ -0,0 +1,266 @@ + +
+ OK +

+ +

+

And

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"puppeteer " +

Leto + + + Atreides

+ diff --git a/src/browser/tests/legacy/dom/node_filter.html b/src/browser/tests/legacy/dom/node_filter.html new file mode 100644 index 000000000..d5ac95f4a --- /dev/null +++ b/src/browser/tests/legacy/dom/node_filter.html @@ -0,0 +1,219 @@ + + + + +
+ +
+ + + + Text content + + + +
+ +
+ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/dom/node_iterator.html b/src/browser/tests/legacy/dom/node_iterator.html new file mode 100644 index 000000000..6225dea43 --- /dev/null +++ b/src/browser/tests/legacy/dom/node_iterator.html @@ -0,0 +1,62 @@ + + + + + +
+ OK +

+ +

+

And

+ +
+ diff --git a/src/browser/tests/legacy/dom/node_list.html b/src/browser/tests/legacy/dom/node_list.html new file mode 100644 index 000000000..911b8aa84 --- /dev/null +++ b/src/browser/tests/legacy/dom/node_list.html @@ -0,0 +1,19 @@ + +
+ OK +

+ +

+

And

+ +
+ + + diff --git a/src/browser/tests/legacy/dom/node_owner.html b/src/browser/tests/legacy/dom/node_owner.html new file mode 100644 index 000000000..0aec74c53 --- /dev/null +++ b/src/browser/tests/legacy/dom/node_owner.html @@ -0,0 +1,34 @@ + +
+

+ I am the original reference node. +

+
+ + + diff --git a/src/browser/tests/legacy/dom/performance.html b/src/browser/tests/legacy/dom/performance.html new file mode 100644 index 000000000..0fbfe6fd0 --- /dev/null +++ b/src/browser/tests/legacy/dom/performance.html @@ -0,0 +1,16 @@ + + + diff --git a/src/browser/tests/legacy/dom/performance_observer.html b/src/browser/tests/legacy/dom/performance_observer.html new file mode 100644 index 000000000..303fc15f0 --- /dev/null +++ b/src/browser/tests/legacy/dom/performance_observer.html @@ -0,0 +1,5 @@ + + + diff --git a/src/browser/tests/legacy/dom/processing_instruction.html b/src/browser/tests/legacy/dom/processing_instruction.html new file mode 100644 index 000000000..67bc8fc48 --- /dev/null +++ b/src/browser/tests/legacy/dom/processing_instruction.html @@ -0,0 +1,22 @@ + + + diff --git a/src/browser/tests/legacy/dom/range.html b/src/browser/tests/legacy/dom/range.html new file mode 100644 index 000000000..a60862ca6 --- /dev/null +++ b/src/browser/tests/legacy/dom/range.html @@ -0,0 +1,41 @@ + + + +

over 9000

+ + + + + + + + diff --git a/src/browser/tests/legacy/dom/shadow_root.html b/src/browser/tests/legacy/dom/shadow_root.html new file mode 100644 index 000000000..88a302db0 --- /dev/null +++ b/src/browser/tests/legacy/dom/shadow_root.html @@ -0,0 +1,49 @@ + +
node
+ + + diff --git a/src/browser/tests/legacy/dom/text.html b/src/browser/tests/legacy/dom/text.html new file mode 100644 index 000000000..d7ceba08e --- /dev/null +++ b/src/browser/tests/legacy/dom/text.html @@ -0,0 +1,19 @@ + +OK + + + diff --git a/src/browser/tests/legacy/dom/token_list.html b/src/browser/tests/legacy/dom/token_list.html new file mode 100644 index 000000000..b04d56586 --- /dev/null +++ b/src/browser/tests/legacy/dom/token_list.html @@ -0,0 +1,64 @@ + +

+ + + diff --git a/src/browser/tests/legacy/encoding/decoder.html b/src/browser/tests/legacy/encoding/decoder.html new file mode 100644 index 000000000..8a93dc46a --- /dev/null +++ b/src/browser/tests/legacy/encoding/decoder.html @@ -0,0 +1,60 @@ + + + + + + + + + diff --git a/src/browser/tests/legacy/encoding/encoder.html b/src/browser/tests/legacy/encoding/encoder.html new file mode 100644 index 000000000..affcd5750 --- /dev/null +++ b/src/browser/tests/legacy/encoding/encoder.html @@ -0,0 +1,14 @@ + + + diff --git a/src/browser/tests/legacy/events/composition.html b/src/browser/tests/legacy/events/composition.html new file mode 100644 index 000000000..b5a6a7100 --- /dev/null +++ b/src/browser/tests/legacy/events/composition.html @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/src/browser/tests/legacy/events/custom.html b/src/browser/tests/legacy/events/custom.html new file mode 100644 index 000000000..cb6ddd2b5 --- /dev/null +++ b/src/browser/tests/legacy/events/custom.html @@ -0,0 +1,25 @@ + + + diff --git a/src/browser/tests/legacy/events/event.html b/src/browser/tests/legacy/events/event.html new file mode 100644 index 000000000..752d64baa --- /dev/null +++ b/src/browser/tests/legacy/events/event.html @@ -0,0 +1,139 @@ + + + +

+

+
+ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/events/keyboard.html b/src/browser/tests/legacy/events/keyboard.html new file mode 100644 index 000000000..2b3dbefb7 --- /dev/null +++ b/src/browser/tests/legacy/events/keyboard.html @@ -0,0 +1,88 @@ + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/events/mouse.html b/src/browser/tests/legacy/events/mouse.html new file mode 100644 index 000000000..4c9b3f638 --- /dev/null +++ b/src/browser/tests/legacy/events/mouse.html @@ -0,0 +1,34 @@ + + + + + + + diff --git a/src/browser/tests/legacy/fetch/fetch.html b/src/browser/tests/legacy/fetch/fetch.html new file mode 100644 index 000000000..877f887b6 --- /dev/null +++ b/src/browser/tests/legacy/fetch/fetch.html @@ -0,0 +1,34 @@ + + + + diff --git a/src/browser/tests/legacy/fetch/headers.html b/src/browser/tests/legacy/fetch/headers.html new file mode 100644 index 000000000..57d6ce2ee --- /dev/null +++ b/src/browser/tests/legacy/fetch/headers.html @@ -0,0 +1,102 @@ + + + + + + + + + diff --git a/src/browser/tests/legacy/fetch/request.html b/src/browser/tests/legacy/fetch/request.html new file mode 100644 index 000000000..7bfdfe56e --- /dev/null +++ b/src/browser/tests/legacy/fetch/request.html @@ -0,0 +1,22 @@ + + + diff --git a/src/browser/tests/legacy/fetch/response.html b/src/browser/tests/legacy/fetch/response.html new file mode 100644 index 000000000..f65a2fea9 --- /dev/null +++ b/src/browser/tests/legacy/fetch/response.html @@ -0,0 +1,50 @@ + + + + + diff --git a/src/browser/tests/legacy/file/blob.html b/src/browser/tests/legacy/file/blob.html new file mode 100644 index 000000000..343fd32be --- /dev/null +++ b/src/browser/tests/legacy/file/blob.html @@ -0,0 +1,125 @@ + + + + + + + + + + + diff --git a/src/browser/tests/legacy/file/file.html b/src/browser/tests/legacy/file/file.html new file mode 100644 index 000000000..05f23ad78 --- /dev/null +++ b/src/browser/tests/legacy/file/file.html @@ -0,0 +1,7 @@ + + + + diff --git a/src/browser/tests/legacy/html/abort_controller.html b/src/browser/tests/legacy/html/abort_controller.html new file mode 100644 index 000000000..fc5a1cdfe --- /dev/null +++ b/src/browser/tests/legacy/html/abort_controller.html @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/src/browser/tests/legacy/html/canvas.html b/src/browser/tests/legacy/html/canvas.html new file mode 100644 index 000000000..ab076487c --- /dev/null +++ b/src/browser/tests/legacy/html/canvas.html @@ -0,0 +1,29 @@ + + + + + + diff --git a/src/browser/tests/legacy/html/dataset.html b/src/browser/tests/legacy/html/dataset.html new file mode 100644 index 000000000..8eff69271 --- /dev/null +++ b/src/browser/tests/legacy/html/dataset.html @@ -0,0 +1,30 @@ + + +
+ + + + diff --git a/src/browser/tests/legacy/html/document.html b/src/browser/tests/legacy/html/document.html new file mode 100644 index 000000000..cc02f7c64 --- /dev/null +++ b/src/browser/tests/legacy/html/document.html @@ -0,0 +1,85 @@ + + + +
+ + + + + + diff --git a/src/browser/tests/legacy/html/element.html b/src/browser/tests/legacy/html/element.html new file mode 100644 index 000000000..4de1f0581 --- /dev/null +++ b/src/browser/tests/legacy/html/element.html @@ -0,0 +1,53 @@ + + +
abcc
+ + + + + + + + + + + diff --git a/src/browser/tests/legacy/html/error_event.html b/src/browser/tests/legacy/html/error_event.html new file mode 100644 index 000000000..be2c56a4c --- /dev/null +++ b/src/browser/tests/legacy/html/error_event.html @@ -0,0 +1,25 @@ + + + + diff --git a/src/browser/tests/legacy/html/history/history.html b/src/browser/tests/legacy/html/history/history.html new file mode 100644 index 000000000..fbb7dd952 --- /dev/null +++ b/src/browser/tests/legacy/html/history/history.html @@ -0,0 +1,37 @@ + + + + diff --git a/src/browser/tests/legacy/html/history/history2.html b/src/browser/tests/legacy/html/history/history2.html new file mode 100644 index 000000000..83dd809a8 --- /dev/null +++ b/src/browser/tests/legacy/html/history/history2.html @@ -0,0 +1,26 @@ + + + + diff --git a/src/browser/tests/legacy/html/history/history_after_nav.html b/src/browser/tests/legacy/html/history/history_after_nav.html new file mode 100644 index 000000000..d9e4e66d1 --- /dev/null +++ b/src/browser/tests/legacy/html/history/history_after_nav.html @@ -0,0 +1,6 @@ + + + + diff --git a/src/browser/tests/legacy/html/image.html b/src/browser/tests/legacy/html/image.html new file mode 100644 index 000000000..1e3f6aff2 --- /dev/null +++ b/src/browser/tests/legacy/html/image.html @@ -0,0 +1,32 @@ + + + + diff --git a/src/browser/tests/legacy/html/input.html b/src/browser/tests/legacy/html/input.html new file mode 100644 index 000000000..4a7e991a2 --- /dev/null +++ b/src/browser/tests/legacy/html/input.html @@ -0,0 +1,111 @@ + + + +
+

+ +

+
+ + + + + + + + diff --git a/src/browser/tests/legacy/html/link.html b/src/browser/tests/legacy/html/link.html new file mode 100644 index 000000000..15da64611 --- /dev/null +++ b/src/browser/tests/legacy/html/link.html @@ -0,0 +1,60 @@ + + +OK + + diff --git a/src/browser/tests/legacy/html/location.html b/src/browser/tests/legacy/html/location.html new file mode 100644 index 000000000..a5de3ba86 --- /dev/null +++ b/src/browser/tests/legacy/html/location.html @@ -0,0 +1,33 @@ + + + + + + diff --git a/src/browser/tests/legacy/html/navigation/navigation.html b/src/browser/tests/legacy/html/navigation/navigation.html new file mode 100644 index 000000000..24efe6c75 --- /dev/null +++ b/src/browser/tests/legacy/html/navigation/navigation.html @@ -0,0 +1,18 @@ + + + + diff --git a/src/browser/tests/legacy/html/navigation/navigation2.html b/src/browser/tests/legacy/html/navigation/navigation2.html new file mode 100644 index 000000000..b16fa917d --- /dev/null +++ b/src/browser/tests/legacy/html/navigation/navigation2.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/browser/tests/legacy/html/navigation/navigation_currententrychange.html b/src/browser/tests/legacy/html/navigation/navigation_currententrychange.html new file mode 100644 index 000000000..c84bcbadd --- /dev/null +++ b/src/browser/tests/legacy/html/navigation/navigation_currententrychange.html @@ -0,0 +1,15 @@ + + + + diff --git a/src/browser/tests/legacy/html/navigator.html b/src/browser/tests/legacy/html/navigator.html new file mode 100644 index 000000000..fb2b3ffe3 --- /dev/null +++ b/src/browser/tests/legacy/html/navigator.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/browser/tests/legacy/html/screen.html b/src/browser/tests/legacy/html/screen.html new file mode 100644 index 000000000..82f4b71cc --- /dev/null +++ b/src/browser/tests/legacy/html/screen.html @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/browser/tests/legacy/html/script/dynamic_import.html b/src/browser/tests/legacy/html/script/dynamic_import.html new file mode 100644 index 000000000..ddaa19a22 --- /dev/null +++ b/src/browser/tests/legacy/html/script/dynamic_import.html @@ -0,0 +1,32 @@ + + + + + diff --git a/src/browser/tests/legacy/html/script/import.html b/src/browser/tests/legacy/html/script/import.html new file mode 100644 index 000000000..7a4037af7 --- /dev/null +++ b/src/browser/tests/legacy/html/script/import.html @@ -0,0 +1,15 @@ + + + + + + + diff --git a/src/browser/tests/legacy/html/script/import.js b/src/browser/tests/legacy/html/script/import.js new file mode 100644 index 000000000..fb140c03f --- /dev/null +++ b/src/browser/tests/legacy/html/script/import.js @@ -0,0 +1,2 @@ +let greeting = 'hello'; +export {greeting as 'greeting'}; diff --git a/src/browser/tests/legacy/html/script/import2.js b/src/browser/tests/legacy/html/script/import2.js new file mode 100644 index 000000000..328b8943d --- /dev/null +++ b/src/browser/tests/legacy/html/script/import2.js @@ -0,0 +1,2 @@ +let greeting = 'world'; +export {greeting as 'greeting'}; diff --git a/src/browser/tests/legacy/html/script/importmap.html b/src/browser/tests/legacy/html/script/importmap.html new file mode 100644 index 000000000..973d50806 --- /dev/null +++ b/src/browser/tests/legacy/html/script/importmap.html @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/src/browser/tests/legacy/html/script/inline_defer.html b/src/browser/tests/legacy/html/script/inline_defer.html new file mode 100644 index 000000000..ec5b44c64 --- /dev/null +++ b/src/browser/tests/legacy/html/script/inline_defer.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/html/script/inline_defer.js b/src/browser/tests/legacy/html/script/inline_defer.js new file mode 100644 index 000000000..1e0ee1a4f --- /dev/null +++ b/src/browser/tests/legacy/html/script/inline_defer.js @@ -0,0 +1 @@ +dyn1_loaded += 1; diff --git a/src/browser/tests/legacy/html/script/order.html b/src/browser/tests/legacy/html/script/order.html new file mode 100644 index 000000000..7efbbef32 --- /dev/null +++ b/src/browser/tests/legacy/html/script/order.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/html/script/order.js b/src/browser/tests/legacy/html/script/order.js new file mode 100644 index 000000000..31e602fc9 --- /dev/null +++ b/src/browser/tests/legacy/html/script/order.js @@ -0,0 +1,2 @@ +list += 'a'; +testing.expectEqual('a', list); diff --git a/src/browser/tests/legacy/html/script/order_async.js b/src/browser/tests/legacy/html/script/order_async.js new file mode 100644 index 000000000..97c9adac5 --- /dev/null +++ b/src/browser/tests/legacy/html/script/order_async.js @@ -0,0 +1,3 @@ +list += 'f'; +testing.expectEqual('abcdef', list); + diff --git a/src/browser/tests/legacy/html/script/order_defer.js b/src/browser/tests/legacy/html/script/order_defer.js new file mode 100644 index 000000000..3911b6445 --- /dev/null +++ b/src/browser/tests/legacy/html/script/order_defer.js @@ -0,0 +1,2 @@ +list += 'e'; +testing.expectEqual('abcde', list); diff --git a/src/browser/tests/legacy/html/script/script.html b/src/browser/tests/legacy/html/script/script.html new file mode 100644 index 000000000..5049e4bbb --- /dev/null +++ b/src/browser/tests/legacy/html/script/script.html @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/browser/tests/legacy/html/select.html b/src/browser/tests/legacy/html/select.html new file mode 100644 index 000000000..f18dfdab3 --- /dev/null +++ b/src/browser/tests/legacy/html/select.html @@ -0,0 +1,80 @@ + + + +
+ +
+ + + diff --git a/src/browser/tests/legacy/html/slot.html b/src/browser/tests/legacy/html/slot.html new file mode 100644 index 000000000..026e13e08 --- /dev/null +++ b/src/browser/tests/legacy/html/slot.html @@ -0,0 +1,179 @@ + + + + + + +default +

default

+

default

xx other
+More

default2

!!
+ + + + + + + +
hello
+ + +
hello
+ + + + + +
hello
+ diff --git a/src/browser/tests/legacy/html/style.html b/src/browser/tests/legacy/html/style.html new file mode 100644 index 000000000..6463cd815 --- /dev/null +++ b/src/browser/tests/legacy/html/style.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/browser/tests/legacy/html/svg.html b/src/browser/tests/legacy/html/svg.html new file mode 100644 index 000000000..368546493 --- /dev/null +++ b/src/browser/tests/legacy/html/svg.html @@ -0,0 +1,38 @@ + + + + + + OVER 9000!! + + + + + OVER 9000!!! + + + diff --git a/src/browser/tests/legacy/html/template.html b/src/browser/tests/legacy/html/template.html new file mode 100644 index 000000000..058c1dd32 --- /dev/null +++ b/src/browser/tests/legacy/html/template.html @@ -0,0 +1,38 @@ + + + +
+ + + + + + diff --git a/src/browser/tests/legacy/polyfill/webcomponents.html b/src/browser/tests/legacy/polyfill/webcomponents.html new file mode 100644 index 000000000..5854bc82c --- /dev/null +++ b/src/browser/tests/legacy/polyfill/webcomponents.html @@ -0,0 +1,23 @@ + + + +
+ + diff --git a/src/browser/tests/legacy/storage/local_storage.html b/src/browser/tests/legacy/storage/local_storage.html new file mode 100644 index 000000000..4ad0b14f9 --- /dev/null +++ b/src/browser/tests/legacy/storage/local_storage.html @@ -0,0 +1,29 @@ + + + + diff --git a/src/browser/tests/legacy/streams/readable_stream.html b/src/browser/tests/legacy/streams/readable_stream.html new file mode 100644 index 000000000..a8339cc50 --- /dev/null +++ b/src/browser/tests/legacy/streams/readable_stream.html @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/testing.js b/src/browser/tests/legacy/testing.js new file mode 100644 index 000000000..891d9cc2d --- /dev/null +++ b/src/browser/tests/legacy/testing.js @@ -0,0 +1,206 @@ +// Note: this code tries to make sure that we don't fail to execute a tags we have have had at least + // 1 assertion. This helps ensure that if a script tag fails to execute, + // we'll report an error, even if no assertions failed. + const scripts = document.getElementsByTagName('script'); + for (script of scripts) { + const id = script.id; + if (!id) { + continue; + } + + if (!testing._executed_scripts.has(id)) { + console.warn(`Failed to execute any expectations for `); + throw new Error('Failed'); + } + } + + if (testing._status != 'ok') { + throw new Error(testing._status); + } + } + + // Set expectations to happen at some point in the future. Necessary for + // testing callbacks which will only be executed after page.wait is called. + function eventually(fn) { + // capture the current state (script id, stack) so that, when we do run this + // we can display more meaningful details on failure. + testing._eventually.push([fn, { + script_id: document.currentScript.id, + stack: new Error().stack, + }]); + + _registerErrorCallback(); + } + + async function async(promise, cb) { + const script_id = document.currentScript ? document.currentScript.id : '.\n There should be a eval error printed above this.`, + ); + } + } + + function _equal(a, b) { + if (a === b) { + return true; + } + if (a === null || b === null) { + return false; + } + if (typeof a !== 'object' || typeof b !== 'object') { + return false; + } + + if (Object.keys(a).length != Object.keys(b).length) { + return false; + } + + for (property in a) { + if (b.hasOwnProperty(property) === false) { + return false; + } + if (_equal(a[property], b[property]) === false) { + return false; + } + } + + return true; + } + + window.testing = { + _status: 'empty', + _eventually: [], + _executed_scripts: new Set(), + _captured: null, + skip: skip, + async: async, + assertOk: assertOk, + eventually: eventually, + expectEqual: expectEqual, + expectError: expectError, + withError: withError, + }; + + // Helper, so you can do $(sel) in a test + window.$ = function(sel) { + return document.querySelector(sel); + } + + // Helper, so you can do $$(sel) in a test + window.$$ = function(sel) { + return document.querySelectorAll(sel); + } + + if (!console.lp) { + // make this work in the browser + console.lp = console.log; + } +})(); diff --git a/src/browser/tests/legacy/url/url.html b/src/browser/tests/legacy/url/url.html new file mode 100644 index 000000000..ef770e461 --- /dev/null +++ b/src/browser/tests/legacy/url/url.html @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/url/url_search_params.html b/src/browser/tests/legacy/url/url_search_params.html new file mode 100644 index 000000000..03f22bcda --- /dev/null +++ b/src/browser/tests/legacy/url/url_search_params.html @@ -0,0 +1,94 @@ + + + + + diff --git a/src/browser/tests/legacy/window/frames.html b/src/browser/tests/legacy/window/frames.html new file mode 100644 index 000000000..fc4b7abc4 --- /dev/null +++ b/src/browser/tests/legacy/window/frames.html @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/browser/tests/legacy/window/window.html b/src/browser/tests/legacy/window/window.html new file mode 100644 index 000000000..aac911718 --- /dev/null +++ b/src/browser/tests/legacy/window/window.html @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/xhr/form_data.html b/src/browser/tests/legacy/xhr/form_data.html new file mode 100644 index 000000000..94bf8a272 --- /dev/null +++ b/src/browser/tests/legacy/xhr/form_data.html @@ -0,0 +1,130 @@ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ diff --git a/src/browser/tests/legacy/xhr/progress_event.html b/src/browser/tests/legacy/xhr/progress_event.html new file mode 100644 index 000000000..4b7f5df4a --- /dev/null +++ b/src/browser/tests/legacy/xhr/progress_event.html @@ -0,0 +1,17 @@ + + + diff --git a/src/browser/tests/legacy/xhr/xhr.html b/src/browser/tests/legacy/xhr/xhr.html new file mode 100644 index 000000000..13ab6216e --- /dev/null +++ b/src/browser/tests/legacy/xhr/xhr.html @@ -0,0 +1,110 @@ + + + + + + + + + + + diff --git a/src/browser/tests/legacy/xmlserializer.html b/src/browser/tests/legacy/xmlserializer.html new file mode 100644 index 000000000..0d3d46284 --- /dev/null +++ b/src/browser/tests/legacy/xmlserializer.html @@ -0,0 +1,8 @@ + + +

And

+ diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index 358312955..051f004b3 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -24,8 +24,6 @@ const Document = @import("Document.zig"); const HTMLDocument = @import("HTMLDocument.zig"); const DOMParser = @This(); -// @ZIGDOM support empty structs -_: u8 = 0, pub fn init() DOMParser { return .{}; @@ -63,6 +61,7 @@ pub const JsApi = struct { pub const name = "DOMParser"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(DOMParser.init, .{}); diff --git a/src/browser/webapi/NodeFilter.zig b/src/browser/webapi/NodeFilter.zig index c9fab4155..232355dc5 100644 --- a/src/browser/webapi/NodeFilter.zig +++ b/src/browser/webapi/NodeFilter.zig @@ -85,6 +85,7 @@ pub const JsApi = struct { pub const name = "NodeFilter"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; }; pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 57d277934..9c15f7224 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -19,8 +19,12 @@ const std = @import("std"); pub const App = @import("App.zig"); pub const Server = @import("Server.zig"); +pub const Page = @import("browser/Page.zig"); +pub const Browser = @import("browser/Browser.zig"); +pub const Session = @import("browser/Session.zig"); pub const log = @import("log.zig"); +pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); pub const build_config = @import("build_config"); @@ -32,7 +36,6 @@ pub const FetchOpts = struct { writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { - const Browser = @import("browser/Browser.zig"); var browser = try Browser.init(app); defer browser.deinit(); diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig new file mode 100644 index 000000000..4dc93e7e2 --- /dev/null +++ b/src/main_legacy_test.zig @@ -0,0 +1,238 @@ +const std = @import("std"); +const lp = @import("lightpanda"); + +const Allocator = std.mem.Allocator; + +// used in custom panic handler +var current_test: ?[]const u8 = null; + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + + const allocator = gpa.allocator(); + + var http_server = try TestHTTPServer.init(); + defer http_server.deinit(); + + { + var wg: std.Thread.WaitGroup = .{}; + wg.startMany(1); + var thrd = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &http_server, &wg }); + thrd.detach(); + wg.wait(); + } + lp.log.opts.level = .warn; + + var app = try lp.App.init(allocator, .{ + .run_mode = .serve, + .tls_verify_host = false, + .user_agent = "User-Agent: Lightpanda/1.0 internal-tester", + }); + defer app.deinit(); + + var test_arena = std.heap.ArenaAllocator.init(allocator); + defer test_arena.deinit(); + + var browser = try lp.Browser.init(app); + defer browser.deinit(); + + const session = try browser.newSession(); + + var dir = try std.fs.cwd().openDir("src/browser/tests/legacy/", .{ .iterate = true, .no_follow = true }); + defer dir.close(); + var walker = try dir.walk(allocator); + defer walker.deinit(); + while (try walker.next()) |entry| { + _ = test_arena.reset(.retain_capacity); + if (entry.kind != .file) { + continue; + } + + if (!std.mem.endsWith(u8, entry.basename, ".html")) { + continue; + } + std.debug.print("\n===={s}====\n", .{entry.path}); + current_test = entry.path; + run(test_arena.allocator(), entry.path, session) catch |err| { + std.debug.print("Failure: {s} - {any}\n", .{ entry.path, err }); + }; + } +} + +pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { + const url = try std.fmt.allocPrintSentinel(allocator, "http://localhost:9589/{s}", .{file}, 0); + + const page = try session.createPage(); + defer session.removePage(); + + const js_context = page.js; + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(js_context); + defer try_catch.deinit(); + + try page.navigate(url, .{}); + session.fetchWait(2000); + + page._session.browser.runMicrotasks(); + page._session.browser.runMessageLoop(); + + js_context.eval("testing.assertOk()", "testing.assertOk()") catch |err| { + const msg = try_catch.err(allocator) catch @errorName(err) orelse "unknown"; + + std.debug.print("{s}: test failure\nError: {s}\n", .{ file, msg }); + return err; + }; +} + +const TestHTTPServer = struct { + shutdown: bool, + dir: std.fs.Dir, + listener: ?std.net.Server, + + pub fn init() !TestHTTPServer { + return .{ + .dir = try std.fs.cwd().openDir("src/browser/tests/legacy/", .{}), + .shutdown = true, + .listener = null, + }; + } + + pub fn deinit(self: *TestHTTPServer) void { + self.shutdown = true; + if (self.listener) |*listener| { + listener.deinit(); + } + self.dir.close(); + } + + pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void { + const address = try std.net.Address.parseIp("127.0.0.1", 9589); + + self.listener = try address.listen(.{ .reuse_address = true }); + var listener = &self.listener.?; + + wg.finish(); + + while (true) { + const conn = listener.accept() catch |err| { + if (self.shutdown) { + return; + } + return err; + }; + const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn }); + thrd.detach(); + } + } + + fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void { + defer conn.stream.close(); + + var req_buf: [2048]u8 = undefined; + var conn_reader = conn.stream.reader(&req_buf); + var conn_writer = conn.stream.writer(&req_buf); + + var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); + + while (true) { + var req = http_server.receiveHead() catch |err| switch (err) { + error.ReadFailed => continue, + error.HttpConnectionClosing => continue, + else => { + std.debug.print("Test HTTP Server error: {}\n", .{err}); + return err; + }, + }; + + self.handler(&req) catch |err| { + std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); + try req.respond("server error", .{ .status = .internal_server_error }); + return; + }; + } + } + + fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void { + const path = req.head.target; + + // strip out leading '/' to make the path relative + const file = try server.dir.openFile(path[1..], .{}); + defer file.close(); + + const stat = try file.stat(); + var send_buffer: [4096]u8 = undefined; + + var res = try req.respondStreaming(&send_buffer, .{ + .content_length = stat.size, + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = getContentType(path) }, + }, + }, + }); + + var read_buffer: [4096]u8 = undefined; + var reader = file.reader(&read_buffer); + _ = try res.writer.sendFileAll(&reader, .unlimited); + try res.writer.flush(); + try res.end(); + } + + pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void { + var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) { + error.FileNotFound => return req.respond("server error", .{ .status = .not_found }), + else => return err, + }; + defer file.close(); + + const stat = try file.stat(); + var send_buffer: [4096]u8 = undefined; + + var res = try req.respondStreaming(&send_buffer, .{ + .content_length = stat.size, + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = getContentType(file_path) }, + }, + }, + }); + + var read_buffer: [4096]u8 = undefined; + var reader = file.reader(&read_buffer); + _ = try res.writer.sendFileAll(&reader, .unlimited); + try res.writer.flush(); + try res.end(); + } + + fn getContentType(file_path: []const u8) []const u8 { + if (std.mem.endsWith(u8, file_path, ".js")) { + return "application/json"; + } + + if (std.mem.endsWith(u8, file_path, ".html")) { + return "text/html"; + } + + if (std.mem.endsWith(u8, file_path, ".htm")) { + return "text/html"; + } + + if (std.mem.endsWith(u8, file_path, ".xml")) { + // some wpt tests do this + return "text/xml"; + } + + std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); + return "text/html"; + } +}; + +pub const panic = std.debug.FullPanic(struct { + pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn { + if (current_test) |ct| { + std.debug.print("===panic running: {s}===\n", .{ct}); + } + std.debug.defaultPanic(msg, first_trace_addr); + } +}.panicFn); diff --git a/tests/html/bug-html-parsing-4.html b/tests/html/bug-html-parsing-4.html deleted file mode 100644 index 391ac0c7d..000000000 --- a/tests/html/bug-html-parsing-4.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - From 04f719c33c244da33ffa61f4193eeee88a13dc01 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Nov 2025 16:14:12 +0800 Subject: [PATCH 025/257] wpt runner --- src/browser/parser/Parser.zig | 1 - src/browser/parser/html5ever.zig | 1 - src/main_wpt.zig | 190 ++++++++++++++++++++++++++----- 3 files changed, 160 insertions(+), 32 deletions(-) diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index f4c6232fd..f7cd5c557 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - const std = @import("std"); const h5e = @import("html5ever.zig"); diff --git a/src/browser/parser/html5ever.zig b/src/browser/parser/html5ever.zig index ea3e7668b..529852902 100644 --- a/src/browser/parser/html5ever.zig +++ b/src/browser/parser/html5ever.zig @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - const ParsedNode = @import("Parser.zig").ParsedNode; pub extern "c" fn html5ever_parse_document( diff --git a/src/main_wpt.zig b/src/main_wpt.zig index 99d7adc6b..ff512e408 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -17,17 +17,11 @@ // along with this program. If not, see . const std = @import("std"); - -const log = @import("log.zig"); -const js = @import("browser/js/js.zig"); +const lp = @import("lightpanda"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const App = @import("app.zig").App; -const Browser = @import("browser/browser.zig").Browser; -const TestHTTPServer = @import("TestHTTPServer.zig"); - const WPT_DIR = "tests/wpt"; // use in custom panic handler @@ -38,9 +32,8 @@ pub fn main() !void { defer _ = gpa.deinit(); const allocator = gpa.allocator(); - log.opts.level = .err; - var http_server = TestHTTPServer.init(httpHandler); + var http_server = try TestHTTPServer.init(); defer http_server.deinit(); { @@ -64,19 +57,21 @@ pub fn main() !void { var writer = try Writer.init(allocator, cmd.format); defer writer.deinit(); - // An arena for running each tests. Is reset after every test. - var test_arena = ArenaAllocator.init(allocator); - defer test_arena.deinit(); - - var app = try App.init(allocator, .{ - .run_mode = .fetch, - .user_agent = "User-Agent: Lightpanda/1.0 Lightpanda/WPT", + lp.log.opts.level = .warn; + var app = try lp.App.init(allocator, .{ + .run_mode = .serve, + .tls_verify_host = false, + .user_agent = "User-Agent: Lightpanda/1.0 internal-tester", }); defer app.deinit(); - var browser = try Browser.init(app); + var browser = try lp.Browser.init(app); defer browser.deinit(); + // An arena for running each tests. Is reset after every test. + var test_arena = ArenaAllocator.init(allocator); + defer test_arena.deinit(); + var i: usize = 0; while (try it.next()) |test_file| { defer _ = test_arena.reset(.retain_capacity); @@ -108,7 +103,7 @@ pub fn main() !void { fn run( arena: Allocator, - browser: *Browser, + browser: *lp.Browser, test_file: []const u8, err_out: *?[]const u8, ) ![]const u8 { @@ -118,13 +113,13 @@ fn run( const page = try session.createPage(); defer session.removePage(); - const url = try std.fmt.allocPrint(arena, "http://localhost:9582/{s}", .{test_file}); + const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0); try page.navigate(url, .{}); _ = page.wait(2000); const js_context = page.js; - var try_catch: js.TryCatch = undefined; + var try_catch: lp.js.TryCatch = undefined; try_catch.init(js_context); defer try_catch.deinit(); @@ -442,19 +437,154 @@ const Test = struct { cases: []Case, }; -fn httpHandler(req: *std.http.Server.Request) !void { - const path = req.head.target; +const TestHTTPServer = struct { + shutdown: bool, + dir: std.fs.Dir, + listener: ?std.net.Server, - if (std.mem.eql(u8, path, "/")) { - // There's 1 test that does an XHR request to this, and it just seems - // to want a 200 success. - return req.respond("Hello!", .{}); + pub fn init() !TestHTTPServer { + return .{ + .dir = try std.fs.cwd().openDir(WPT_DIR, .{}), + .shutdown = true, + .listener = null, + }; } - var buf: [1024]u8 = undefined; - const file_path = try std.fmt.bufPrint(&buf, WPT_DIR ++ "{s}", .{path}); - return TestHTTPServer.sendFile(req, file_path); -} + pub fn deinit(self: *TestHTTPServer) void { + self.shutdown = true; + if (self.listener) |*listener| { + listener.deinit(); + } + self.dir.close(); + } + + pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void { + const address = try std.net.Address.parseIp("127.0.0.1", 9582); + + self.listener = try address.listen(.{ .reuse_address = true }); + var listener = &self.listener.?; + + wg.finish(); + + while (true) { + const conn = listener.accept() catch |err| { + if (self.shutdown) { + return; + } + return err; + }; + const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn }); + thrd.detach(); + } + } + + fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void { + defer conn.stream.close(); + + var req_buf: [2048]u8 = undefined; + var conn_reader = conn.stream.reader(&req_buf); + var conn_writer = conn.stream.writer(&req_buf); + + var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); + + while (true) { + var req = http_server.receiveHead() catch |err| switch (err) { + error.ReadFailed => continue, + error.HttpConnectionClosing => continue, + else => { + std.debug.print("Test HTTP Server error: {}\n", .{err}); + return err; + }, + }; + + self.handler(&req) catch |err| { + std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); + try req.respond("server error", .{ .status = .internal_server_error }); + return; + }; + } + } + + fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void { + const path = req.head.target; + + if (std.mem.eql(u8, path, "/")) { + // There's 1 test that does an XHR request to this, and it just seems + // to want a 200 success. + return req.respond("Hello!", .{}); + } + + // strip out leading '/' to make the path relative + const file = try server.dir.openFile(path[1..], .{}); + defer file.close(); + + const stat = try file.stat(); + var send_buffer: [4096]u8 = undefined; + + var res = try req.respondStreaming(&send_buffer, .{ + .content_length = stat.size, + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = getContentType(path) }, + }, + }, + }); + + var read_buffer: [4096]u8 = undefined; + var reader = file.reader(&read_buffer); + _ = try res.writer.sendFileAll(&reader, .unlimited); + try res.writer.flush(); + try res.end(); + } + + pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void { + var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) { + error.FileNotFound => return req.respond("server error", .{ .status = .not_found }), + else => return err, + }; + defer file.close(); + + const stat = try file.stat(); + var send_buffer: [4096]u8 = undefined; + + var res = try req.respondStreaming(&send_buffer, .{ + .content_length = stat.size, + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = getContentType(file_path) }, + }, + }, + }); + + var read_buffer: [4096]u8 = undefined; + var reader = file.reader(&read_buffer); + _ = try res.writer.sendFileAll(&reader, .unlimited); + try res.writer.flush(); + try res.end(); + } + + fn getContentType(file_path: []const u8) []const u8 { + if (std.mem.endsWith(u8, file_path, ".js")) { + return "application/json"; + } + + if (std.mem.endsWith(u8, file_path, ".html")) { + return "text/html"; + } + + if (std.mem.endsWith(u8, file_path, ".htm")) { + return "text/html"; + } + + if (std.mem.endsWith(u8, file_path, ".xml")) { + // some wpt tests do this + return "text/xml"; + } + + std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); + return "text/html"; + } +}; pub const panic = std.debug.FullPanic(struct { pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn { From 5ae74d6924ca549c5eb7934fffb42d9153731e4d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Nov 2025 17:56:09 +0800 Subject: [PATCH 026/257] improve form element support --- src/browser/tests/element/html/button.html | 73 +++++ src/browser/tests/element/html/input.html | 107 ++++++- src/browser/tests/element/html/option.html | 34 +++ src/browser/tests/element/html/select.html | 283 ++++++++++++++++++ src/browser/tests/element/html/textarea.html | 73 +++++ src/browser/webapi/Element.zig | 1 + src/browser/webapi/collections.zig | 2 + .../webapi/collections/HTMLCollection.zig | 10 + .../collections/HTMLOptionsCollection.zig | 106 +++++++ src/browser/webapi/collections/node_live.zig | 18 +- src/browser/webapi/element/Attribute.zig | 2 +- src/browser/webapi/element/html/Button.zig | 22 ++ src/browser/webapi/element/html/Input.zig | 54 +++- src/browser/webapi/element/html/Option.zig | 41 ++- src/browser/webapi/element/html/Select.zig | 177 ++++++++--- src/browser/webapi/element/html/TextArea.zig | 22 ++ 16 files changed, 975 insertions(+), 50 deletions(-) create mode 100644 src/browser/webapi/collections/HTMLOptionsCollection.zig diff --git a/src/browser/tests/element/html/button.html b/src/browser/tests/element/html/button.html index dc7d5855c..76e5be8bd 100644 --- a/src/browser/tests/element/html/button.html +++ b/src/browser/tests/element/html/button.html @@ -53,3 +53,76 @@ const buttonInvalidFormAttr = $('#button_invalid_form_attr') testing.expectEqual(null, buttonInvalidFormAttr.form) + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 9f762d421..dbd79aa3d 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -15,7 +15,7 @@ - + + + + + diff --git a/src/browser/tests/element/html/option.html b/src/browser/tests/element/html/option.html index 30d023780..6e7f72c8d 100644 --- a/src/browser/tests/element/html/option.html +++ b/src/browser/tests/element/html/option.html @@ -65,3 +65,37 @@ $('#opt4').disabled = false testing.expectEqual(false, $('#opt4').disabled) + + + + + + + + + diff --git a/src/browser/tests/element/html/select.html b/src/browser/tests/element/html/select.html index a6a835a64..ceb46c16b 100644 --- a/src/browser/tests/element/html/select.html +++ b/src/browser/tests/element/html/select.html @@ -81,3 +81,286 @@ const selectNoForm = $('#select_no_form') testing.expectEqual(null, selectNoForm.form) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/textarea.html b/src/browser/tests/element/html/textarea.html index f20e182e9..f820288eb 100644 --- a/src/browser/tests/element/html/textarea.html +++ b/src/browser/tests/element/html/textarea.html @@ -76,3 +76,76 @@ const textareaInvalidFormAttr = $('#textarea_invalid_form_attr') testing.expectEqual(null, textareaInvalidFormAttr.form) + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 9f0fdd5f5..0ea4783ea 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -407,6 +407,7 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Pa } pub fn remove(self: *Element, page: *Page) void { + page.domChanged(); const node = self.asNode(); const parent = node._parent orelse return; page.removeNode(parent, node, .{ .will_be_reconnected = false }); diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index 0e091cbda..eead81b92 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -20,6 +20,7 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive; pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); +pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); pub fn registerTypes() []const type { return &.{ @@ -31,6 +32,7 @@ pub fn registerTypes() []const type { @import("collections/NodeList.zig").EntryIterator, @import("collections/HTMLAllCollection.zig"), @import("collections/HTMLAllCollection.zig").Iterator, + HTMLOptionsCollection, DOMTokenList, DOMTokenList.Iterator, }; diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index e3f42a904..54c99bffa 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -29,6 +29,8 @@ const Mode = enum { tag_name, class_name, child_elements, + child_tag, + selected_options, }; const HTMLCollection = @This(); @@ -38,6 +40,8 @@ data: union(Mode) { tag_name: NodeLive(.tag_name), class_name: NodeLive(.class_name), child_elements: NodeLive(.child_elements), + child_tag: NodeLive(.child_tag), + selected_options: NodeLive(.selected_options), }, pub fn length(self: *HTMLCollection, page: *const Page) u32 { @@ -66,6 +70,8 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, + .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, + .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, }, }, page); } @@ -78,6 +84,8 @@ pub const Iterator = GenericIterator(struct { tag_name: TreeWalker.FullExcludeSelf, class_name: TreeWalker.FullExcludeSelf, child_elements: TreeWalker.Children, + child_tag: TreeWalker.Children, + selected_options: TreeWalker.Children, }, pub fn next(self: *@This(), _: *Page) ?*Element { @@ -86,6 +94,8 @@ pub const Iterator = GenericIterator(struct { .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .class_name => |*impl| impl.nextTw(&self.tw.class_name), .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), + .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), + .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), }; } }, null); diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig new file mode 100644 index 000000000..4c9d59c44 --- /dev/null +++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig @@ -0,0 +1,106 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const Element = @import("../Element.zig"); +const HTMLCollection = @import("HTMLCollection.zig"); +const NodeLive = @import("node_live.zig").NodeLive; + +const HTMLOptionsCollection = @This(); + +_proto: *HTMLCollection, +_select: *@import("../element/html/Select.zig"), + +pub fn deinit(self: *HTMLOptionsCollection) void { + const page = Page.current; + page._factory.destroy(self); +} + +// Forward length to HTMLCollection +pub fn length(self: *HTMLOptionsCollection, page: *Page) u32 { + return self._proto.length(page); +} + +// Forward indexed access to HTMLCollection +pub fn getAtIndex(self: *HTMLOptionsCollection, index: usize, page: *Page) ?*Element { + return self._proto.getAtIndex(index, page); +} + +pub fn getByName(self: *HTMLOptionsCollection, name: []const u8, page: *Page) ?*Element { + return self._proto.getByName(name, page); +} + +// Forward selectedIndex to the owning select element +pub fn getSelectedIndex(self: *const HTMLOptionsCollection) i32 { + return self._select.getSelectedIndex(); +} + +pub fn setSelectedIndex(self: *HTMLOptionsCollection, index: i32) !void { + return self._select.setSelectedIndex(index); +} + +const Option = @import("../element/html/Option.zig"); + +// Add a new option element +pub fn add(self: *HTMLOptionsCollection, element: *Option, before: ?*Option, page: *Page) !void { + const select_node = self._select.asNode(); + const element_node = element.asElement().asNode(); + + if (before) |before_option| { + const before_node = before_option.asElement().asNode(); + _ = try select_node.insertBefore(element_node, before_node, page); + } else { + _ = try select_node.appendChild(element_node, page); + } +} + +// Remove an option element by index +pub fn remove(self: *HTMLOptionsCollection, index: i32, page: *Page) void { + if (index < 0) { + return; + } + + if (self._proto.getAtIndex(@intCast(index), page)) |element| { + element.remove(page); + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(HTMLOptionsCollection); + + pub const Meta = struct { + pub const name = "HTMLOptionsCollection"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const finalizer = HTMLOptionsCollection.deinit; + pub const manage = false; + }; + + pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{}); + + // Indexed access + pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, .{ .null_as_undefined = true }); + + pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{}); + pub const add = bridge.function(HTMLOptionsCollection.add, .{}); + pub const remove = bridge.function(HTMLOptionsCollection.remove, .{}); +}; diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 45eea51c9..ee123b4c4 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -36,6 +36,8 @@ const Mode = enum { tag_name, class_name, child_elements, + child_tag, + selected_options, }; const Filters = union(Mode) { @@ -43,6 +45,8 @@ const Filters = union(Mode) { tag_name: String, class_name: []const u8, child_elements, + child_tag: Element.Tag, + selected_options, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -71,7 +75,7 @@ pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { .tag, .tag_name, .class_name => TreeWalker.FullExcludeSelf, - .child_elements => TreeWalker.Children, + .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { _tw: TW, @@ -213,6 +217,16 @@ pub fn NodeLive(comptime mode: Mode) type { return Selector.classAttributeContains(class_attr, self._filter); }, .child_elements => return node._type == .element, + .child_tag => { + const el = node.is(Element) orelse return false; + return el.getTag() == self._filter; + }, + .selected_options => { + const el = node.is(Element) orelse return false; + const Option = Element.Html.Option; + const opt = el.is(Option) orelse return false; + return opt.getSelected(); + }, } } @@ -236,6 +250,8 @@ pub fn NodeLive(comptime mode: Mode) type { .tag_name => HTMLCollection{ .data = .{ .tag_name = self } }, .class_name => HTMLCollection{ .data = .{ .class_name = self } }, .child_elements => HTMLCollection{ .data = .{ .child_elements = self } }, + .child_tag => HTMLCollection{ .data = .{ .child_tag = self } }, + .selected_options => HTMLCollection{ .data = .{ .selected_options = self } }, }; return page._factory.create(collection); } diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 66357754e..3d37f173c 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -174,7 +174,7 @@ pub const List = struct { if (is_id) { try page.document._elements_by_id.put(page.arena, entry._value.str(), element); } - page.attributeChange(element, result.normalized, value); + page.attributeChange(element, result.normalized, entry._value.str()); return entry; } diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 2e1a40165..acf076e61 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -50,6 +50,26 @@ pub fn setDisabled(self: *Button, disabled: bool, page: *Page) !void { } } +pub fn getName(self: *const Button) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Button, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +pub fn getRequired(self: *const Button) bool { + return self.asConstElement().getAttributeSafe("required") != null; +} + +pub fn setRequired(self: *Button, required: bool, page: *Page) !void { + if (required) { + try self.asElement().setAttributeSafe("required", "", page); + } else { + try self.asElement().removeAttribute("required", page); + } +} + pub fn getForm(self: *Button, page: *Page) ?*Form { const element = self.asElement(); @@ -84,6 +104,8 @@ pub const JsApi = struct { }; pub const disabled = bridge.accessor(Button.getDisabled, Button.setDisabled, .{}); + pub const name = bridge.accessor(Button.getName, Button.setName, .{}); + pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); }; diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index d805ba6fe..9c4593a92 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -109,6 +109,10 @@ pub fn getDefaultValue(self: *const Input) []const u8 { return self._default_value orelse ""; } +pub fn setDefaultValue(self: *Input, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("value", value, page); +} + pub fn getChecked(self: *const Input) bool { return self._checked; } @@ -126,6 +130,14 @@ pub fn getDefaultChecked(self: *const Input) bool { return self._default_checked; } +pub fn setDefaultChecked(self: *Input, checked: bool, page: *Page) !void { + if (checked) { + try self.asElement().setAttributeSafe("checked", "", page); + } else { + try self.asElement().removeAttribute("checked", page); + } +} + pub fn getDisabled(self: *const Input) bool { // TODO: Also check for disabled fieldset ancestors // (but not if we're inside a of that fieldset) @@ -140,6 +152,26 @@ pub fn setDisabled(self: *Input, disabled: bool, page: *Page) !void { } } +pub fn getName(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Input, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +pub fn getRequired(self: *const Input) bool { + return self.asConstElement().getAttributeSafe("required") != null; +} + +pub fn setRequired(self: *Input, required: bool, page: *Page) !void { + if (required) { + try self.asElement().setAttributeSafe("required", "", page); + } else { + try self.asElement().removeAttribute("required", page); + } +} + pub fn getForm(self: *Input, page: *Page) ?*Form { const element = self.asElement(); @@ -218,10 +250,12 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{}); pub const value = bridge.accessor(Input.getValue, Input.setValue, .{}); - pub const defaultValue = bridge.accessor(Input.getDefaultValue, null, .{}); + pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{}); pub const checked = bridge.accessor(Input.getChecked, Input.setChecked, .{}); - pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, null, .{}); + pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, Input.setDefaultChecked, .{}); pub const disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{}); + pub const name = bridge.accessor(Input.getName, Input.setName, .{}); + pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); }; @@ -249,13 +283,20 @@ pub const Build = struct { } } - pub fn attributeChange(element: *Element, name: []const u8, value: []const u8, _: *Page) !void { + pub fn attributeChange(element: *Element, name: []const u8, value: []const u8, page: *Page) !void { const attribute = std.meta.stringToEnum(enum { type, value, checked }, name) orelse return; const self = element.as(Input); switch (attribute) { .type => self._input_type = Type.fromString(value), .value => self._default_value = value, - .checked => self._default_checked = true, + .checked => { + self._default_checked = true; + self._checked = true; + // If setting a radio button to checked, uncheck others in the group + if (self._input_type == .radio) { + try self.uncheckRadioGroup(page); + } + }, } } @@ -265,7 +306,10 @@ pub const Build = struct { switch (attribute) { .type => self._input_type = .text, .value => self._default_value = null, - .checked => self._default_checked = false, + .checked => { + self._default_checked = false; + self._checked = false; + }, } } }; diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 5123e088e..b5718a1ec 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); @@ -35,6 +36,9 @@ _disabled: bool = false, pub fn asElement(self: *Option) *Element { return self._proto._proto; } +pub fn asConstElement(self: *const Option) *const Element { + return self._proto._proto; +} pub fn asNode(self: *Option) *Node { return self.asElement().asNode(); } @@ -45,7 +49,7 @@ pub fn getValue(self: *const Option) []const u8 { } pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { - const owned = try page.arena.dupe(u8, value); + const owned = try page.dupeString(value); try self.asElement().setAttributeSafe("value", owned, page); self._value = owned; } @@ -59,10 +63,10 @@ pub fn getSelected(self: *const Option) bool { } pub fn setSelected(self: *Option, selected: bool, page: *Page) !void { - _ = page; // TODO: When setting selected=true, may need to unselect other options // in the parent - - - - - + + diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 7174ecc20..481a3697a 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -500,6 +500,10 @@ fn attributeContainsWord(value: []const u8, word: []const u8) bool { fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass) bool { switch (pseudo) { .modal => return false, + .checked => { + const input = el.is(Node.Element.Html.Input) orelse return false; + return input.getChecked(); + }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), .only_child => return isFirstChild(el) and isLastChild(el), diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 0b465e1d2..498132910 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -395,6 +395,8 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla return .{ .not = selectors.items }; } + + return error.UnknownPseudoClass; } @@ -402,6 +404,9 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla 5 => { if (fastEql(name, "modal")) return .modal; }, + 7 => { + if (fastEql(name, "checked")) return .checked; + }, 10 => { if (fastEql(name, "only-child")) return .only_child; if (fastEql(name, "last-child")) return .last_child; diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index defc30148..15579b65b 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -132,6 +132,7 @@ pub const AttributeMatcher = union(enum) { pub const PseudoClass = union(enum) { modal, + checked, first_child, last_child, only_child, From 470f5b5029bccdf77494abb717f1a07ae150607a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 21 Nov 2025 20:22:24 +0800 Subject: [PATCH 062/257] Headers and improved Request --- src/browser/js/bridge.zig | 1 + src/browser/tests/net/headers.html | 31 +++++++++ src/browser/tests/net/request.html | 104 +++++++++++++++++++++++++++++ src/browser/webapi/net/Fetch.zig | 2 +- src/browser/webapi/net/Headers.zig | 63 +++++++++++++++++ src/browser/webapi/net/Request.zig | 78 +++++++++++++++++++++- 6 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 src/browser/tests/net/headers.html create mode 100644 src/browser/tests/net/request.html create mode 100644 src/browser/webapi/net/Headers.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index f9ba64f6e..7acb84739 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -550,6 +550,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), @import("../webapi/net/FormData.zig"), + @import("../webapi/net/Headers.zig"), @import("../webapi/net/Request.zig"), @import("../webapi/net/Response.zig"), @import("../webapi/net/URLSearchParams.zig"), diff --git a/src/browser/tests/net/headers.html b/src/browser/tests/net/headers.html new file mode 100644 index 000000000..d0d1c35ea --- /dev/null +++ b/src/browser/tests/net/headers.html @@ -0,0 +1,31 @@ + + + diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html new file mode 100644 index 000000000..c0028cf83 --- /dev/null +++ b/src/browser/tests/net/request.html @@ -0,0 +1,104 @@ + + + + + + + + + diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 7bbc2da94..547a6ab1c 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -39,7 +39,7 @@ pub const Input = Request.Input; // @ZIGDOM just enough to get campire demo working pub fn init(input: Input, page: *Page) !js.Promise { - const request = try Request.init(input, page); + const request = try Request.init(input, null, page); const fetch = try page.arena.create(Fetch); fetch.* = .{ diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig new file mode 100644 index 000000000..2f2fa68f2 --- /dev/null +++ b/src/browser/webapi/net/Headers.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const KeyValueList = @import("../KeyValueList.zig"); + +const Headers = @This(); + +_list: KeyValueList, + +pub fn init(page: *Page) !*Headers { + return page._factory.create(Headers{ + ._list = KeyValueList.init(), + }); +} + + +pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + try self._list.append(page.arena, name, value); +} + +pub fn delete(self: *Headers, name: []const u8) void { + self._list.delete(name, null); +} + +pub fn get(self: *const Headers, name: []const u8) ?[]const u8 { + return self._list.get(name); +} + +pub fn getAll(self: *const Headers, name: []const u8, page: *Page) ![]const []const u8 { + return self._list.getAll(name, page); +} + +pub fn has(self: *const Headers, name: []const u8) bool { + return self._list.has(name); +} + +pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + try self._list.set(page.arena, name, value); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Headers); + + pub const Meta = struct { + pub const name = "Headers"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(Headers.init, .{}); + pub const append = bridge.function(Headers.append, .{}); + pub const delete = bridge.function(Headers.delete, .{}); + pub const get = bridge.function(Headers.get, .{}); + pub const getAll = bridge.function(Headers.getAll, .{}); + pub const has = bridge.function(Headers.has, .{}); + pub const set = bridge.function(Headers.set, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: Headers" { + try testing.htmlRunner("net/headers.html", .{}); +} diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index d715c53b2..d1524afe1 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -22,28 +22,92 @@ const js = @import("../../js/js.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); +const Headers = @import("Headers.zig"); const Allocator = std.mem.Allocator; const Request = @This(); _url: [:0]const u8, +_method: std.http.Method, +_headers: ?*Headers, _arena: Allocator, pub const Input = union(enum) { + request: *Request, url: [:0]const u8, - // request: *Request, TODO }; -pub fn init(input: Input, page: *Page) !*Request { +pub const Options = struct { + method: ?[]const u8 = null, + headers: ?*Headers = null, +}; + +pub fn init(input: Input, opts_: ?Options, page: *Page) !*Request { const arena = page.arena; - const url = try URL.resolve(arena, page.url, input.url, .{ .always_dupe = true }); + const url = switch (input) { + .url => |u| try URL.resolve(arena, page.url, u, .{ .always_dupe = true }), + .request => |r| try arena.dupeZ(u8, r._url), + }; + + const opts = opts_ orelse Options{}; + const method = if (opts.method) |m| + try parseMethod(m, page) + else switch (input) { + .url => .GET, + .request => |r| r._method, + }; + + const headers = if (opts.headers) |h| + h + else switch (input) { + .url => null, + .request => |r| r._headers, + }; return page._factory.create(Request{ ._url = url, ._arena = arena, + ._method = method, + ._headers = headers, }); } +fn parseMethod(method: []const u8, page: *Page) !std.http.Method { + if (method.len > "options".len) { + return error.InvalidMethod; + } + + const lower = std.ascii.lowerString(&page.buf, method); + + if (std.mem.eql(u8, lower, "get")) return .GET; + if (std.mem.eql(u8, lower, "post")) return .POST; + if (std.mem.eql(u8, lower, "delete")) return .DELETE; + if (std.mem.eql(u8, lower, "put")) return .PUT; + if (std.mem.eql(u8, lower, "patch")) return .PATCH; + if (std.mem.eql(u8, lower, "head")) return .HEAD; + if (std.mem.eql(u8, lower, "options")) return .OPTIONS; + + return error.InvalidMethod; +} + +pub fn getUrl(self: *const Request) []const u8 { + return self._url; +} + +pub fn getMethod(self: *const Request) []const u8 { + return @tagName(self._method); +} + +pub fn getHeaders(self: *Request, page: *Page) !*Headers { + if (self._headers) |headers| { + return headers; + } + + const headers = try Headers.init(page); + self._headers = headers; + return headers; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Request); @@ -54,4 +118,12 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(Request.init, .{}); + pub const url = bridge.accessor(Request.getUrl, null, .{}); + pub const method = bridge.accessor(Request.getMethod, null, .{}); + pub const headers = bridge.accessor(Request.getHeaders, null, .{}); }; + +const testing = @import("../../../testing.zig"); +test "WebApi: Request" { + try testing.htmlRunner("net/request.html", .{}); +} From 357df22fabf37ccb9e91778c5f12d02427c1525f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 21 Nov 2025 22:23:34 +0800 Subject: [PATCH 063/257] more pseudoclass support --- src/browser/tests/element/pseudo_classes.html | 82 ++++++++ .../tests/element/selector_invalid.html | 62 ++++++ src/browser/webapi/selector/List.zig | 196 +++++++++++++++--- src/browser/webapi/selector/Parser.zig | 130 ++++++++++++ src/browser/webapi/selector/Selector.zig | 44 +++- 5 files changed, 481 insertions(+), 33 deletions(-) create mode 100644 src/browser/tests/element/pseudo_classes.html create mode 100644 src/browser/tests/element/selector_invalid.html diff --git a/src/browser/tests/element/pseudo_classes.html b/src/browser/tests/element/pseudo_classes.html new file mode 100644 index 000000000..8114cae0a --- /dev/null +++ b/src/browser/tests/element/pseudo_classes.html @@ -0,0 +1,82 @@ + + + +
+

First

+ + + Content +
+ + + + + + + + + + + + + + diff --git a/src/browser/tests/element/selector_invalid.html b/src/browser/tests/element/selector_invalid.html new file mode 100644 index 000000000..826ce7b7b --- /dev/null +++ b/src/browser/tests/element/selector_invalid.html @@ -0,0 +1,62 @@ + + + +
+

Test

+
+ + + + + + + + + + diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 481a3697a..8ce759a84 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -53,9 +53,8 @@ pub fn collect( _ = tw.next(); } - const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, result.selector, boundary)) { + if (matches(node, result.selector, page)) { try nodes.put(allocator, node, {}); } } @@ -71,9 +70,8 @@ pub fn initOne(root: *Node, selector: Selector.Selector, page: *Page) ?*Node { if (result.exclude_root) { _ = tw.next(); } - const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, optimized_selector, boundary)) { + if (matches(node, optimized_selector, page)) { return node; } } @@ -175,7 +173,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page .segments = selector.segments[0 .. seg_idx + 1], }; - if (!matches(id_node, prefix_selector, null)) { + if (!matches(id_node, prefix_selector, page)) { return null; } @@ -250,23 +248,23 @@ fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { return null; } -pub fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { +pub fn matches(node: *Node, selector: Selector.Selector, page: *Page) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { - return matchesCompound(el, selector.first); + return matchesCompound(el, selector.first, page); } const last_segment = selector.segments[selector.segments.len - 1]; - if (!matchesCompound(el, last_segment.compound)) { + if (!matchesCompound(el, last_segment.compound, page)) { return false; } - return matchSegments(node, selector, selector.segments.len - 1, root); + return matchSegments(node, selector, selector.segments.len - 1, null, page); } // Match segments backward, with support for backtracking on subsequent_sibling -fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node) bool { +fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node, page: *Page) bool { const segment = selector.segments[segment_index]; const target_compound = if (segment_index == 0) selector.first @@ -274,9 +272,9 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, selector.segments[segment_index - 1].compound; const matched: ?*Node = switch (segment.combinator) { - .descendant => matchDescendant(node, target_compound, root), - .child => matchChild(node, target_compound, root), - .next_sibling => matchNextSibling(node, target_compound), + .descendant => matchDescendant(node, target_compound, root, page), + .child => matchChild(node, target_compound, root, page), + .next_sibling => matchNextSibling(node, target_compound, page), .subsequent_sibling => { // For subsequent_sibling, try all matching siblings with backtracking var sibling = node.previousSibling(); @@ -286,13 +284,13 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, continue; }; - if (matchesCompound(sibling_el, target_compound)) { + if (matchesCompound(sibling_el, target_compound, page)) { // If we're at the first segment, we found a match if (segment_index == 0) { return true; } // Try to match remaining segments from this sibling - if (matchSegments(s, selector, segment_index - 1, root)) { + if (matchSegments(s, selector, segment_index - 1, root, page)) { return true; } // This sibling didn't work, try the next one @@ -309,7 +307,7 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, if (segment_index == 0) { return true; } - return matchSegments(current, selector, segment_index - 1, root); + return matchSegments(current, selector, segment_index - 1, root, page); } // subsequent_sibling already handled its recursion above @@ -317,12 +315,12 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, } // Find an ancestor that matches the compound (any distance up the tree) -fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { +fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { var current = node._parent; while (current) |ancestor| { if (ancestor.is(Node.Element)) |ancestor_el| { - if (matchesCompound(ancestor_el, compound)) { + if (matchesCompound(ancestor_el, compound, page)) { return ancestor; } } @@ -341,7 +339,7 @@ fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Nod } // Find the direct parent if it matches the compound -fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { +fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary @@ -354,7 +352,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { const parent_el = parent.is(Node.Element) orelse return null; - if (matchesCompound(parent_el, compound)) { + if (matchesCompound(parent_el, compound, page)) { return parent; } @@ -362,7 +360,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { } // Find the immediately preceding sibling if it matches the compound -fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { +fn matchNextSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { var sibling = node.previousSibling(); // For next_sibling (+), we need the immediately preceding element sibling @@ -374,7 +372,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { }; // Found an element - check if it matches - if (matchesCompound(sibling_el, compound)) { + if (matchesCompound(sibling_el, compound, page)) { return s; } // we found an element, it wasn't a match, we're done @@ -385,7 +383,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { } // Find any preceding sibling that matches the compound -fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { +fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { var sibling = node.previousSibling(); // For subsequent_sibling (~), check all preceding element siblings @@ -396,7 +394,7 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { continue; }; - if (matchesCompound(sibling_el, compound)) { + if (matchesCompound(sibling_el, compound, page)) { return s; } @@ -406,17 +404,17 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { return null; } -fn matchesCompound(el: *Node.Element, compound: Selector.Compound) bool { +fn matchesCompound(el: *Node.Element, compound: Selector.Compound, page: *Page) bool { // For compound selectors, ALL parts must match for (compound.parts) |part| { - if (!matchesPart(el, part)) { + if (!matchesPart(el, part, page)) { return false; } } return true; } -fn matchesPart(el: *Node.Element, part: Part) bool { +fn matchesPart(el: *Node.Element, part: Part, page: *Page) bool { switch (part) { .id => |id| { const element_id = el.getAttributeSafe("id") orelse return false; @@ -437,7 +435,7 @@ fn matchesPart(el: *Node.Element, part: Part) bool { return std.mem.eql(u8, element_tag, tag_name); }, .universal => return true, - .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo), + .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo, page), .attribute => |attr| return matchesAttribute(el, attr), } } @@ -497,13 +495,79 @@ fn attributeContainsWord(value: []const u8, word: []const u8) bool { return false; } -fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass) bool { +fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Page) bool { + const node = el.asNode(); switch (pseudo) { + // State pseudo-classes .modal => return false, .checked => { const input = el.is(Node.Element.Html.Input) orelse return false; return input.getChecked(); }, + .disabled => { + return el.getAttributeSafe("disabled") != null; + }, + .enabled => { + return el.getAttributeSafe("disabled") == null; + }, + .indeterminate => return false, + + // Form validation + .valid => return false, + .invalid => return false, + .required => { + return el.getAttributeSafe("required") != null; + }, + .optional => { + return el.getAttributeSafe("required") == null; + }, + .in_range => return false, + .out_of_range => return false, + .placeholder_shown => return false, + .read_only => { + return el.getAttributeSafe("readonly") != null; + }, + .read_write => { + return el.getAttributeSafe("readonly") == null; + }, + .default => return false, + + // User interaction + .hover => return false, + .active => return false, + .focus => { + const active = page.document._active_element orelse return false; + return active == el; + }, + .focus_within => { + const active = page.document._active_element orelse return false; + return node.contains(active.asNode()); + }, + .focus_visible => return false, + + // Link states + .link => return false, + .visited => return false, + .any_link => { + if (el.getTag() != .anchor) return false; + return el.getAttributeSafe("href") != null; + }, + .target => { + const element_id = el.getAttributeSafe("id") orelse return false; + const location = page.document._location orelse return false; + const hash = location.getHash(); + if (hash.len <= 1) return false; + return std.mem.eql(u8, element_id, hash[1..]); + }, + + // Tree structural + .root => { + const parent = node.parentNode() orelse return false; + return parent._type == .document; + }, + .empty => { + return node.firstChild() == null; + }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), .only_child => return isFirstChild(el) and isLastChild(el), @@ -514,19 +578,87 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass) bool { .nth_last_child => |pattern| return matchesNthLastChild(el, pattern), .nth_of_type => |pattern| return matchesNthOfType(el, pattern), .nth_last_of_type => |pattern| return matchesNthLastOfType(el, pattern), + + // Custom elements + .defined => { + const tag_name = el.getTagNameLower(); + if (std.mem.indexOfScalar(u8, tag_name, '-') == null) return true; + const registry = &page.window._custom_elements; + return registry.get(tag_name) != null; + }, + + // Functional + .lang => return false, .not => |selectors| { - // CSS Level 4: :not() matches if NONE of the selectors match - // Each selector in the list is evaluated independently for (selectors) |selector| { - if (matches(el.asNode(), selector, null)) { + if (matches(node, selector, page)) { return false; } } return true; }, + .is => |selectors| { + for (selectors) |selector| { + if (matches(node, selector, page)) { + return true; + } + } + return false; + }, + .where => |selectors| { + for (selectors) |selector| { + if (matches(node, selector, page)) { + return true; + } + } + return false; + }, + .has => |selectors| { + for (selectors) |selector| { + var child = node.firstChild(); + while (child) |c| { + const child_el = c.is(Node.Element) orelse { + child = c.nextSibling(); + continue; + }; + + if (matches(child_el.asNode(), selector, page)) { + return true; + } + + if (matchesHasDescendant(child_el, selector, page)) { + return true; + } + + child = c.nextSibling(); + } + } + return false; + }, } } +fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, page: *Page) bool { + var child = el.asNode().firstChild(); + while (child) |c| { + const child_el = c.is(Node.Element) orelse { + child = c.nextSibling(); + continue; + }; + + if (matches(child_el.asNode(), selector, page)) { + return true; + } + + if (matchesHasDescendant(child_el, selector, page)) { + return true; + } + + child = c.nextSibling(); + } + return false; +} + fn isFirstChild(el: *Node.Element) bool { const node = el.asNode(); var sibling = node.previousSibling(); diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 498132910..41075ca5e 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -395,21 +395,144 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla return .{ .not = selectors.items }; } + if (std.mem.eql(u8, name, "is")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .is = selectors.items }; + } + + if (std.mem.eql(u8, name, "where")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .where = selectors.items }; + } + + if (std.mem.eql(u8, name, "has")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .has = selectors.items }; + } + + if (std.mem.eql(u8, name, "lang")) { + _ = self.skipSpaces(); + const lang_start = self.input; + var lang_i: usize = 0; + while (lang_i < lang_start.len and lang_start[lang_i] != ')') : (lang_i += 1) {} + if (lang_i == 0 or self.peek() == 0) return error.InvalidPseudoClass; + + const lang = try arena.dupe(u8, std.mem.trim(u8, lang_start[0..lang_i], &std.ascii.whitespace)); + self.input = lang_start[lang_i..]; + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + return .{ .lang = lang }; + } return error.UnknownPseudoClass; } switch (name.len) { + 4 => { + if (fastEql(name, "root")) return .root; + if (fastEql(name, "link")) return .link; + }, 5 => { if (fastEql(name, "modal")) return .modal; + if (fastEql(name, "hover")) return .hover; + if (fastEql(name, "focus")) return .focus; + if (fastEql(name, "empty")) return .empty; + if (fastEql(name, "valid")) return .valid; + }, + 6 => { + if (fastEql(name, "active")) return .active; + if (fastEql(name, "target")) return .target; }, 7 => { if (fastEql(name, "checked")) return .checked; + if (fastEql(name, "visited")) return .visited; + if (fastEql(name, "enabled")) return .enabled; + if (fastEql(name, "invalid")) return .invalid; + if (fastEql(name, "default")) return .default; + if (fastEql(name, "defined")) return .defined; + }, + 8 => { + if (fastEql(name, "disabled")) return .disabled; + if (fastEql(name, "required")) return .required; + if (fastEql(name, "optional")) return .optional; + if (fastEql(name, "any-link")) return .any_link; + if (fastEql(name, "in-range")) return .in_range; + }, + 9 => { + if (fastEql(name, "read-only")) return .read_only; }, 10 => { if (fastEql(name, "only-child")) return .only_child; if (fastEql(name, "last-child")) return .last_child; + if (fastEql(name, "read-write")) return .read_write; }, 11 => { if (fastEql(name, "first-child")) return .first_child; @@ -417,9 +540,16 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla 12 => { if (fastEql(name, "only-of-type")) return .only_of_type; if (fastEql(name, "last-of-type")) return .last_of_type; + if (fastEql(name, "focus-within")) return .focus_within; + if (fastEql(name, "out-of-range")) return .out_of_range; }, 13 => { if (fastEql(name, "first-of-type")) return .first_of_type; + if (fastEql(name, "focus-visible")) return .focus_visible; + if (fastEql(name, "indeterminate")) return .indeterminate; + }, + 17 => { + if (fastEql(name, "placeholder-shown")) return .placeholder_shown; }, else => {}, } diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 15579b65b..5360cd3fe 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -82,7 +82,7 @@ pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { - if (List.matches(el.asNode(), selector, null)) { + if (List.matches(el.asNode(), selector, page)) { return true; } } @@ -131,8 +131,41 @@ pub const AttributeMatcher = union(enum) { }; pub const PseudoClass = union(enum) { + // State pseudo-classes modal, checked, + disabled, + enabled, + indeterminate, + + // Form validation + valid, + invalid, + required, + optional, + in_range, + out_of_range, + placeholder_shown, + read_only, + read_write, + default, + + // User interaction + hover, + active, + focus, + focus_within, + focus_visible, + + // Link states + link, + visited, + any_link, + target, + + // Tree structural + root, + empty, first_child, last_child, only_child, @@ -143,7 +176,16 @@ pub const PseudoClass = union(enum) { nth_last_child: NthPattern, nth_of_type: NthPattern, nth_last_of_type: NthPattern, + + // Custom elements + defined, + + // Functional + lang: []const u8, not: []const Selector, // :not() - CSS Level 4: supports full selectors and comma-separated lists + is: []const Selector, // :is() - matches any of the selectors + where: []const Selector, // :where() - like :is() but with zero specificity + has: []const Selector, // :has() - element containing descendants matching selector }; pub const NthPattern = struct { From 3c010f0e73a9503b4574b0b52b6dd444ae56f6b8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 22 Nov 2025 12:25:12 +0800 Subject: [PATCH 064/257] tweak custom element callbacks --- src/browser/Page.zig | 18 +++++++--- src/browser/webapi/CustomElementRegistry.zig | 4 +++ src/browser/webapi/element/html/Custom.zig | 36 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 8c75f2bc2..19dd7f782 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -107,6 +107,8 @@ _intersection_delivery_scheduled: bool = false, // Lookup for customized built-in elements. Maps element pointer to definition. _customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{}, +_customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{}, +_customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{}, // This is set when an element is being upgraded (constructor is called). // The constructor can access this to get the element being upgraded. @@ -223,6 +225,8 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._intersection_observers = .{}; self._intersection_delivery_scheduled = false; self._customized_builtin_definitions = .{}; + self._customized_builtin_connected_callback_invoked = .{}; + self._customized_builtin_disconnected_callback_invoked = .{}; self._undefined_custom_elements = .{}; try self.registerBackgroundTasks(); @@ -1380,13 +1384,14 @@ pub fn appendNode(self: *Page, parent: *Node, child: *Node, opts: InsertNodeOpts pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void { self.domChanged(); - const is_connected = parent.isConnected(); const dest_connected = target.isConnected(); var it = parent.childrenIterator(); while (it.next()) |child| { + // Check if child was connected BEFORE removing it from parent + const child_was_connected = child.isConnected(); self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected }); - try self.appendNode(target, child, .{ .child_already_connected = is_connected }); + try self.appendNode(target, child, .{ .child_already_connected = child_was_connected }); } } @@ -1500,14 +1505,19 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod // 1. A disconnected child became connected (parent.isConnected() == true) // 2. Child is being added to a shadow tree (parent_in_shadow == true) // In both cases, we need to update ID maps and invoke callbacks + + // Only invoke connectedCallback if the root child is transitioning from + // disconnected to connected. When that happens, all descendants should also + // get connectedCallback invoked (they're becoming connected as a group). + const should_invoke_connected = parent_is_connected and !opts.child_already_connected; + var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { if (el.getAttributeSafe("id")) |id| { try self.addElementId(el.asNode()._parent.?, el, id); } - // Only invoke connected callback if actually connected to document - if (parent_is_connected) { + if (should_invoke_connected) { Element.Html.Custom.invokeConnectedCallbackOnElement(el, self); } } diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 2028bda8a..c97fab0db 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -136,6 +136,10 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) fn upgradeCustomElement(custom: *@import("element/html/Custom.zig"), definition: *CustomElementDefinition, page: *Page) !void { custom._definition = definition; + // Reset callback flags since this is a fresh upgrade + custom._connected_callback_invoked = false; + custom._disconnected_callback_invoked = false; + const node = custom.asNode(); const prev_upgrading = page._upgrading_element; page._upgrading_element = node; diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 31e46bcf0..50e8518eb 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -32,6 +32,8 @@ const Custom = @This(); _proto: *HtmlElement, _tag_name: String, _definition: ?*CustomElementDefinition, +_connected_callback_invoked: bool = false, +_disconnected_callback_invoked: bool = false, pub fn asElement(self: *Custom) *Element { return self._proto._proto; @@ -41,10 +43,20 @@ pub fn asNode(self: *Custom) *Node { } pub fn invokeConnectedCallback(self: *Custom, page: *Page) void { + // Only invoke if we haven't already called it while connected + if (self._connected_callback_invoked) return; + + self._connected_callback_invoked = true; + self._disconnected_callback_invoked = false; self.invokeCallback("connectedCallback", .{}, page); } pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { + // Only invoke if we haven't already called it while disconnected + if (self._disconnected_callback_invoked) return; + + self._disconnected_callback_invoked = true; + self._connected_callback_invoked = false; self.invokeCallback("disconnectedCallback", .{}, page); } @@ -63,6 +75,16 @@ pub fn invokeConnectedCallbackOnElement(element: *Element, page: *Page) void { } // Customized built-in element + // Check if we've already invoked connectedCallback while connected + if (page._customized_builtin_connected_callback_invoked.contains(element)) return; + + page._customized_builtin_connected_callback_invoked.put( + page.arena, + element, + {}, + ) catch return; + _ = page._customized_builtin_disconnected_callback_invoked.remove(element); + invokeCallbackOnElement(element, "connectedCallback", .{}, page); } @@ -74,6 +96,16 @@ pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void } // Customized built-in element + // Check if we've already invoked disconnectedCallback while disconnected + if (page._customized_builtin_disconnected_callback_invoked.contains(element)) return; + + page._customized_builtin_disconnected_callback_invoked.put( + page.arena, + element, + {}, + ) catch return; + _ = page._customized_builtin_connected_callback_invoked.remove(element); + invokeCallbackOnElement(element, "disconnectedCallback", .{}, page); } @@ -119,6 +151,10 @@ pub fn checkAndAttachBuiltIn(element: *Element, page: *Page) !void { // Attach the definition try page.setCustomizedBuiltInDefinition(element, definition); + // Reset callback flags since this is a fresh upgrade + _ = page._customized_builtin_connected_callback_invoked.remove(element); + _ = page._customized_builtin_disconnected_callback_invoked.remove(element); + // Invoke constructor const prev_upgrading = page._upgrading_element; const node = element.asNode(); From 6b990f8f123f5f9a3c9ee887f01502db9e075e38 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 22 Nov 2025 12:33:29 +0800 Subject: [PATCH 065/257] CustomEvent and document.createEvent --- src/browser/js/bridge.zig | 1 + src/browser/tests/event/custom_event.html | 95 +++++++++++++++++++++++ src/browser/webapi/Document.zig | 21 +++++ src/browser/webapi/Event.zig | 1 + src/browser/webapi/event/CustomEvent.zig | 78 +++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 src/browser/tests/event/custom_event.html create mode 100644 src/browser/webapi/event/CustomEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7acb84739..bc380d1a0 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -544,6 +544,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/encoding/TextDecoder.zig"), @import("../webapi/encoding/TextEncoder.zig"), @import("../webapi/Event.zig"), + @import("../webapi/event/CustomEvent.zig"), @import("../webapi/event/ErrorEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/EventTarget.zig"), diff --git a/src/browser/tests/event/custom_event.html b/src/browser/tests/event/custom_event.html new file mode 100644 index 000000000..97f114d88 --- /dev/null +++ b/src/browser/tests/event/custom_event.html @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index eaa9b6e0f..e895bfd40 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -175,6 +175,26 @@ pub fn createTextNode(_: *const Document, data: []const u8, page: *Page) !*Node return page.createTextNode(data); } +pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") { + const Event = @import("Event.zig"); + + if (std.ascii.eqlIgnoreCase(event_type, "event") or std.ascii.eqlIgnoreCase(event_type, "events") or std.ascii.eqlIgnoreCase(event_type, "htmlevents")) { + return Event.init("", null, page); + } + + if (std.ascii.eqlIgnoreCase(event_type, "customevent") or std.ascii.eqlIgnoreCase(event_type, "customevents")) { + const CustomEvent = @import("event/CustomEvent.zig"); + const custom_event = try CustomEvent.init("", null, page); + return custom_event.asEvent(); + } + + if (std.ascii.eqlIgnoreCase(event_type, "messageevent")) { + return error.NotSupported; + } + + return error.NotSupported; +} + pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?u32, filter: ?DOMTreeWalker.FilterOpts, page: *Page) !*DOMTreeWalker { const show = what_to_show orelse NodeFilter.SHOW_ALL; return DOMTreeWalker.init(root, show, filter, page); @@ -239,6 +259,7 @@ pub const JsApi = struct { pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); pub const createComment = bridge.function(Document.createComment, .{}); pub const createTextNode = bridge.function(Document.createTextNode, .{}); + pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true }); pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{}); pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{}); pub const getElementById = bridge.function(Document.getElementById, .{}); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 9884ff855..70de6e078 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -48,6 +48,7 @@ pub const Type = union(enum) { generic, progress_event: *@import("event/ProgressEvent.zig"), error_event: *@import("event/ErrorEvent.zig"), + custom_event: *@import("event/CustomEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig new file mode 100644 index 000000000..1c36fc33e --- /dev/null +++ b/src/browser/webapi/event/CustomEvent.zig @@ -0,0 +1,78 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const Event = @import("../Event.zig"); +const Allocator = std.mem.Allocator; + +const CustomEvent = @This(); + +_proto: *Event, +_detail: ?js.Object = null, +_arena: Allocator, + +pub const InitOptions = struct { + detail: ?js.Object = null, + bubbles: bool = false, + cancelable: bool = false, +}; + +pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*CustomEvent { + const arena = page.arena; + const opts = opts_ orelse InitOptions{}; + + const event = try page._factory.event(typ, CustomEvent{ + ._arena = arena, + ._proto = undefined, + ._detail = if (opts.detail) |detail| try detail.persist() else null, + }); + + event._proto._bubbles = opts.bubbles; + event._proto._cancelable = opts.cancelable; + + return event; +} + +pub fn asEvent(self: *CustomEvent) *Event { + return self._proto; +} + +pub fn getDetail(self: *const CustomEvent) ?js.Object { + return self._detail; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CustomEvent); + + pub const Meta = struct { + pub const name = "CustomEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(CustomEvent.init, .{}); + pub const detail = bridge.accessor(CustomEvent.getDetail, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CustomEvent" { + try testing.htmlRunner("event/custom_event.html", .{}); +} From d3c00cdd527e9273c1a4a838c97665c2310fa77b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 22 Nov 2025 22:56:58 +0800 Subject: [PATCH 066/257] Link get/set href --- src/browser/webapi/element/html/Link.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 3fbfdaa06..65e879179 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -17,6 +17,9 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); + +const URL = @import("../../URL.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -31,6 +34,15 @@ pub fn asNode(self: *Link) *Node { return self.asElement().asNode(); } +pub fn getHref(self: *Link, page: *Page) ![]const u8 { + const href = self.asElement().getAttributeSafe("href"); + return URL.resolve(page.call_arena, page.url, href orelse "", .{}); +} + +pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("href", value, page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Link); @@ -39,4 +51,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const href = bridge.accessor(Link.getHref, Link.setHref, .{}); }; From f536f169266ae84592362ef341329bf439752431 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 22 Nov 2025 23:04:17 +0800 Subject: [PATCH 067/257] Correct exception on custom element re-definition --- src/browser/webapi/Crypto.zig | 4 ++-- src/browser/webapi/CustomElementRegistry.zig | 3 ++- src/browser/webapi/DOMException.zig | 2 +- src/browser/webapi/IntersectionObserver.zig | 9 ++------- src/browser/webapi/net/Headers.zig | 1 - 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/Crypto.zig b/src/browser/webapi/Crypto.zig index 2c54689cd..069715b86 100644 --- a/src/browser/webapi/Crypto.zig +++ b/src/browser/webapi/Crypto.zig @@ -73,8 +73,8 @@ pub const JsApi = struct { pub const empty_with_no_proto = true; }; - pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{ }); - pub const randomUUID = bridge.function(Crypto.randomUUID, .{ }); + pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{}); + pub const randomUUID = bridge.function(Crypto.randomUUID, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index c97fab0db..361aced55 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -51,7 +51,8 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu const gop = try self._definitions.getOrPut(page.arena, name); if (gop.found_existing) { - return error.AlreadyDefined; + // Yes, this is the correct error to return when trying to redefine a name + return error.NotSupported; } const owned_name = try page.dupeString(name); diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 07c7137f1..2f1cc789f 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -47,7 +47,7 @@ pub fn getName(self: *const DOMException) []const u8 { .invalid_character_error => "InvalidCharacterError", .syntax_error => "SyntaxError", .not_found => "NotFoundError", - .not_supported => "NotSupported", + .not_supported => "NotSupportedError", .hierarchy_error => "HierarchyError", }; } diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index d9722be76..c6940899e 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -53,7 +53,7 @@ var zero_rect: DOMRect = .{ pub const ObserverInit = struct { root: ?*Element = null, rootMargin: ?[]const u8 = null, - threshold: Threshold = .{.scalar = 0.0}, + threshold: Threshold = .{ .scalar = 0.0 }, const Threshold = union(enum) { scalar: f64, @@ -74,12 +74,7 @@ pub fn init(callback: js.Function, options: ?ObserverInit, page: *Page) !*Inters .array => |arr| try page.arena.dupe(f64, arr), }; - return page._factory.create(IntersectionObserver{ - ._callback = callback, - ._root = opts.root, - ._root_margin = root_margin, - ._threshold = threshold - }); + return page._factory.create(IntersectionObserver{ ._callback = callback, ._root = opts.root, ._root_margin = root_margin, ._threshold = threshold }); } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 2f2fa68f2..9dea0b958 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -14,7 +14,6 @@ pub fn init(page: *Page) !*Headers { }); } - pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { try self._list.append(page.arena, name, value); } From 871fd46c892ece94997aa51f46870635bcd7c957 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 24 Nov 2025 15:11:16 +0800 Subject: [PATCH 068/257] fix 0-size structs all having the same identity (the same pointer --- src/browser/Page.zig | 2 ++ src/browser/tests/navigator.html | 29 --------------------- src/browser/webapi/Console.zig | 1 + src/browser/webapi/Crypto.zig | 3 +++ src/browser/webapi/Navigator.zig | 5 +--- src/browser/webapi/Window.zig | 22 +++++++++++----- src/browser/webapi/encoding/TextEncoder.zig | 1 + 7 files changed, 24 insertions(+), 39 deletions(-) delete mode 100644 src/browser/tests/navigator.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 19dd7f782..e9eda3f23 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -176,6 +176,8 @@ pub fn deinit(self: *Page) void { log.debug(.page, "page.deinit", .{ .url = self.url }); } self.js.deinit(); + self._script_manager.shutdown = true; + self._session.browser.http_client.abort(); self._script_manager.deinit(); } diff --git a/src/browser/tests/navigator.html b/src/browser/tests/navigator.html deleted file mode 100644 index 7547b0168..000000000 --- a/src/browser/tests/navigator.html +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index e3e856ab9..3563f1c57 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -22,6 +22,7 @@ const js = @import("../js/js.zig"); const logger = @import("../../log.zig"); const Console = @This(); +_pad: bool = false, pub const init: Console = .{}; diff --git a/src/browser/webapi/Crypto.zig b/src/browser/webapi/Crypto.zig index 069715b86..e8f987b55 100644 --- a/src/browser/webapi/Crypto.zig +++ b/src/browser/webapi/Crypto.zig @@ -20,6 +20,9 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Crypto = @This(); +_pad: bool = false, + +pub const init: Crypto = .{}; // We take a js.Value, because we want to return the same instance, not a new // TypedArray diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 981fc2e1c..63b4cfc9a 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -20,6 +20,7 @@ const builtin = @import("builtin"); const js = @import("../js/js.zig"); const Navigator = @This(); +_pad: bool = false, pub const init: Navigator = .{}; @@ -120,7 +121,3 @@ pub const JsApi = struct { pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); }; -const testing = @import("../../testing.zig"); -test "WebApi: Navigator" { - try testing.htmlRunner("navigator.html", .{}); -} diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 1379f4ad3..ecf3793d2 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -34,12 +34,15 @@ const EventTarget = @import("EventTarget.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); +const Element = @import("Element.zig"); +const CSSStyleDeclaration = @import("css/CSSStyleDeclaration.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); const Window = @This(); _proto: *EventTarget, _document: *Document, +_crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, _performance: Performance, @@ -67,16 +70,17 @@ pub fn getDocument(self: *Window) *Document { return self._document; } -pub fn getConsole(_: *const Window) Console { - return .{}; +pub fn getConsole(self: *Window) *Console { + std.debug.print("getConsole\n", .{}); + return &self._console; } -pub fn getNavigator(_: *const Window) Navigator { - return .{}; +pub fn getNavigator(self: *Window) *Navigator { + return &self._navigator; } -pub fn getCrypto(_: *const Window) Crypto { - return .{}; +pub fn getCrypto(self: *Window) *Crypto { + return &self._crypto; } pub fn getPerformance(self: *Window) *Performance { @@ -210,6 +214,10 @@ pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQuery }); } +pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !@import("css/CSSStyleDeclaration.zig") { + return CSSStyleDeclaration.init(null, page); +} + pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { const encoded_len = std.base64.standard.Encoder.calcSize(input.len); const encoded = try page.call_arena.alloc(u8, encoded_len); @@ -223,6 +231,7 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } + const ScheduleOpts = struct { repeat: bool, params: []js.Object, @@ -384,6 +393,7 @@ pub const JsApi = struct { return 1080; } }.wrap, null, .{ .cache = "innerHeight" }); + pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/encoding/TextEncoder.zig b/src/browser/webapi/encoding/TextEncoder.zig index c7066d5ec..614187cd0 100644 --- a/src/browser/webapi/encoding/TextEncoder.zig +++ b/src/browser/webapi/encoding/TextEncoder.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const TextEncoder = @This(); +_pad: bool = false, pub fn init() TextEncoder { return .{}; From e336c67857b92d6a5ec5fcbfad7f159f8937b3eb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 24 Nov 2025 20:12:43 +0800 Subject: [PATCH 069/257] various small api fixes/tweaks --- src/browser/Factory.zig | 6 +- src/browser/Page.zig | 14 +- src/browser/ScriptManager.zig | 2 +- src/browser/dump.zig | 73 ++++- src/browser/js/Function.zig | 1 + src/browser/tests/element/pseudo_classes.html | 8 + src/browser/webapi/History.zig | 4 +- src/browser/webapi/Window.zig | 17 +- src/browser/webapi/css/MediaQueryList.zig | 5 + src/browser/webapi/element/html/Script.zig | 10 + src/browser/webapi/net/XMLHttpRequest.zig | 2 +- src/browser/webapi/selector/Parser.zig | 261 ++++++++++++------ src/browser/webapi/storage/cookie.zig | 4 +- src/cdp/domains/log.zig | 4 +- src/lightpanda.zig | 4 +- src/log.zig | 13 +- src/main.zig | 22 +- 17 files changed, 323 insertions(+), 127 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 696ae98c3..8c9c3b58c 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -63,7 +63,7 @@ _size_144_8: MemoryPoolAligned([144]u8, .@"8"), _size_152_8: MemoryPoolAligned([152]u8, .@"8"), _size_160_8: MemoryPoolAligned([160]u8, .@"8"), _size_184_8: MemoryPoolAligned([184]u8, .@"8"), -_size_192_8: MemoryPoolAligned([192]u8, .@"8"), +_size_232_8: MemoryPoolAligned([232]u8, .@"8"), _size_648_8: MemoryPoolAligned([648]u8, .@"8"), pub fn init(page: *Page) Factory { @@ -86,7 +86,7 @@ pub fn init(page: *Page) Factory { ._size_152_8 = MemoryPoolAligned([152]u8, .@"8").init(page.arena), ._size_160_8 = MemoryPoolAligned([160]u8, .@"8").init(page.arena), ._size_184_8 = MemoryPoolAligned([184]u8, .@"8").init(page.arena), - ._size_192_8 = MemoryPoolAligned([192]u8, .@"8").init(page.arena), + ._size_232_8 = MemoryPoolAligned([232]u8, .@"8").init(page.arena), ._size_648_8 = MemoryPoolAligned([648]u8, .@"8").init(page.arena), }; } @@ -265,7 +265,7 @@ pub fn createT(self: *Factory, comptime T: type) !*T { if (comptime SO == 152) return @ptrCast(try self._size_152_8.create()); if (comptime SO == 160) return @ptrCast(try self._size_160_8.create()); if (comptime SO == 184) return @ptrCast(try self._size_184_8.create()); - if (comptime SO == 192) return @ptrCast(try self._size_192_8.create()); + if (comptime SO == 232) return @ptrCast(try self._size_232_8.create()); if (comptime SO == 648) return @ptrCast(try self._size_648_8.create()); @compileError(std.fmt.comptimePrint("No pool configured for @sizeOf({d}), @alignOf({d}): ({s})", .{ SO, @alignOf(T), @typeName(T) })); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index e9eda3f23..b93a9f946 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -266,7 +266,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi try self.reset(false); } - log.info(.http, "navigate", .{ + log.info(.page, "navigate", .{ .url = request_url, .method = opts.method, .reason = opts.reason, @@ -329,7 +329,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .done_callback = pageDoneCallback, .error_callback = pageErrorCallback, }) catch |err| { - log.err(.http, "navigate request", .{ .url = self.url, .err = err }); + log.err(.page, "navigate request", .{ .url = self.url, .err = err }); return err; }; } @@ -412,7 +412,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !void { self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; - log.debug(.http, "navigate header", .{ + log.debug(.page, "navigate header", .{ .url = self.url, .status = header.status, .content_type = header.contentType(), @@ -433,7 +433,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } orelse .unknown; if (comptime IS_DEBUG) { - log.debug(.http, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len }); + log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len }); } switch (mime.content_type) { @@ -475,7 +475,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { fn pageDoneCallback(ctx: *anyopaque) !void { if (comptime IS_DEBUG) { - log.debug(.http, "navigate done", .{}); + log.debug(.page, "navigate done", .{}); } var self: *Page = @ptrCast(@alignCast(ctx)); @@ -522,7 +522,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { - log.err(.http, "navigate failed", .{ .err = err }); + log.err(.page, "navigate failed", .{ .err = err }); var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); @@ -624,7 +624,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { if (try_catch.hasCaught()) { const msg = (try try_catch.err(self.arena)) orelse "unknown"; - log.warn(.user_script, "page wait", .{ .err = msg, .src = "scheduler" }); + log.warn(.js, "page wait", .{ .err = msg, .src = "scheduler" }); return error.JsError; } diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0d8740c80..632be5f2e 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -757,7 +757,7 @@ const Script = struct { // } const msg = try_catch.err(page.arena) catch |err| @errorName(err) orelse "unknown"; - log.warn(.user_script, "eval script", .{ + log.warn(.js, "eval script", .{ .url = url, .err = msg, .cacheable = cacheable, diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 8efc8da49..2e00ba39e 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -17,24 +17,45 @@ // along with this program. If not, see . const std = @import("std"); +const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); -pub const Opts = struct { - // @ZIGDOM (none of these do anything) +pub const RootOpts = struct { with_base: bool = false, - strip_mode: StripMode = .{}, + strip: Opts.Strip = .{}, +}; - pub const StripMode = struct { +pub const Opts = struct { + strip: Strip = .{}, + pub const Strip = struct { js: bool = false, ui: bool = false, css: bool = false, }; }; +pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { + const doc = page.document; + if (opts.with_base) { + if (doc.is(Node.Document.HTMLDocument)) |html_doc| { + const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode(); + const base = try doc.createElement("base", null, page); + try base.setAttributeSafe("base", page.url, page); + _ = try parent.insertBefore(base.asNode(), parent.firstChild(), page); + } + } + + return deep(doc.asNode(), .{.strip = opts.strip}, writer); +} + pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}!void { switch (node._type) { .cdata => |cd| try writer.writeAll(cd.getData()), .element => |el| { + if (shouldStripElement(el, opts)) { + return; + } + try el.format(writer); try children(node, opts, writer); if (!isVoidElement(el)) { @@ -106,3 +127,47 @@ fn isVoidElement(el: *const Node.Element) bool { .svg => false, }; } + +fn shouldStripElement(el: *const Node.Element, opts: Opts) bool { + const tag_name = el.getTagNameDump(); + + if (opts.strip.js) { + if (std.mem.eql(u8, tag_name, "script")) return true; + if (std.mem.eql(u8, tag_name, "noscript")) return true; + + if (std.mem.eql(u8, tag_name, "link")) { + if (el.getAttributeSafe("as")) |as| { + if (std.mem.eql(u8, as, "script")) return true; + } + if (el.getAttributeSafe("rel")) |rel| { + if (std.mem.eql(u8, rel, "modulepreload") or std.mem.eql(u8, rel, "preload")) { + if (el.getAttributeSafe("as")) |as| { + if (std.mem.eql(u8, as, "script")) return true; + } + } + } + } + } + + if (opts.strip.css or opts.strip.ui) { + if (std.mem.eql(u8, tag_name, "style")) return true; + + if (std.mem.eql(u8, tag_name, "link")) { + if (el.getAttributeSafe("rel")) |rel| { + if (std.mem.eql(u8, rel, "stylesheet")) return true; + } + } + } + + if (opts.strip.ui) { + if (std.mem.eql(u8, tag_name, "img")) return true; + if (std.mem.eql(u8, tag_name, "picture")) return true; + if (std.mem.eql(u8, tag_name, "video")) return true; + if (std.mem.eql(u8, tag_name, "audio")) return true; + if (std.mem.eql(u8, tag_name, "svg")) return true; + if (std.mem.eql(u8, tag_name, "canvas")) return true; + if (std.mem.eql(u8, tag_name, "iframe")) return true; + } + + return false; +} diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 73c029117..41d8fa2ca 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -144,6 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args const result = self.func.castToFunction().call(context.v8_context, js_this, js_args); if (result == null) { + std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); return error.JSExecCallback; } diff --git a/src/browser/tests/element/pseudo_classes.html b/src/browser/tests/element/pseudo_classes.html index 8114cae0a..fe75ab844 100644 --- a/src/browser/tests/element/pseudo_classes.html +++ b/src/browser/tests/element/pseudo_classes.html @@ -80,3 +80,11 @@ testing.expectTrue(whereResult.length >= 3); } + +
+ diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index 3bc568662..d80fe3ba7 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -52,7 +52,7 @@ pub fn pushState(self: *History, state: js.Object, _title: []const u8, url: ?[]c _ = url; // For minimal implementation, we don't actually navigate _ = page; - self._state = state; + self._state = try state.persist(); self._length += 1; } @@ -60,7 +60,7 @@ pub fn replaceState(self: *History, state: js.Object, _title: []const u8, url: ? _ = _title; _ = url; _ = page; - self._state = state; + self._state = try state.persist(); // Note: replaceState doesn't change length } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ecf3793d2..b65359e44 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -49,6 +49,7 @@ _performance: Performance, _history: History, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, +_on_error: ?js.Function = null, // TODO: invoke on error? _location: *Location, _timer_id: u30 = 0, _timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, @@ -71,7 +72,6 @@ pub fn getDocument(self: *Window) *Document { } pub fn getConsole(self: *Window) *Console { - std.debug.print("getConsole\n", .{}); return &self._console; } @@ -119,6 +119,18 @@ pub fn setOnLoad(self: *Window, cb_: ?js.Function) !void { } } +pub fn getOnError(self: *const Window) ?js.Function { + return self._on_error; +} + +pub fn setOnError(self: *Window, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_error = cb; + } else { + self._on_error = null; + } +} + pub fn fetch(_: *const Window, input: Fetch.Input, page: *Page) !js.Promise { return Fetch.init(input, page); } @@ -214,7 +226,7 @@ pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQuery }); } -pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !@import("css/CSSStyleDeclaration.zig") { +pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !*CSSStyleDeclaration { return CSSStyleDeclaration.init(null, page); } @@ -362,6 +374,7 @@ pub const JsApi = struct { pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); + pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); pub const fetch = bridge.function(Window.fetch, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); pub const setTimeout = bridge.function(Window.setTimeout, .{}); diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig index 4e0da9710..46304ccc5 100644 --- a/src/browser/webapi/css/MediaQueryList.zig +++ b/src/browser/webapi/css/MediaQueryList.zig @@ -43,6 +43,9 @@ pub fn getMatches(_: *const MediaQueryList) bool { return false; } +pub fn addListener(_: *const MediaQueryList, _: js.Function) void {} +pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {} + pub const JsApi = struct { pub const bridge = js.Bridge(MediaQueryList); @@ -54,6 +57,8 @@ pub const JsApi = struct { pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{}); pub const matches = bridge.accessor(MediaQueryList.getMatches, null, .{}); + pub const addListener = bridge.function(MediaQueryList.addListener, .{}); + pub const removeListener = bridge.function(MediaQueryList.removeListener, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 1e548c4e3..c12038a64 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -35,6 +35,11 @@ _executed: bool = false, pub fn asElement(self: *Script) *Element { return self._proto._proto; } + +pub fn asConstElement(self: *const Script) *const Element { + return self._proto._proto; +} + pub fn asNode(self: *Script) *Node { return self.asElement().asNode(); } @@ -76,6 +81,10 @@ pub fn setOnError(self: *Script, cb_: ?js.Function) !void { } } +pub fn getNoModule(self: *const Script) bool { + return self.asConstElement().getAttributeSafe("nomodule") != null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Script); @@ -88,6 +97,7 @@ pub const JsApi = struct { pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{}); pub const onerorr = bridge.accessor(Script.getOnError, Script.setOnError, .{}); + pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); }; pub const Build = struct { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index dfb848e66..6239ddc42 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -125,7 +125,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { if (comptime IS_DEBUG) { - log.debug(.xhr, "XMLHttpRequest.send", .{ .url = self._url }); + log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url }); } if (body_) |b| { diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 41075ca5e..b97f7c004 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -226,8 +226,8 @@ pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Select fn parsePart(self: *Parser, arena: Allocator, page: *Page) !Part { return switch (self.peek()) { - '#' => .{ .id = try self.id() }, - '.' => .{ .class = try self.class() }, + '#' => .{ .id = try self.id(arena) }, + '.' => .{ .class = try self.class(arena) }, '*' => blk: { self.input = self.input[1..]; break :blk .universal; @@ -655,7 +655,7 @@ fn parseNthPattern(self: *Parser) !Selector.NthPattern { return .{ .a = a, .b = b }; } -pub fn id(self: *Parser) ![]const u8 { +pub fn id(self: *Parser, arena: Allocator) ![]const u8 { // Must be called when we're at a '#' std.debug.assert(self.peek() == '#'); @@ -667,26 +667,46 @@ pub fn id(self: *Parser) ![]const u8 { return error.InvalidIDSelector; } - // First character: must be letter, underscore, or non-ASCII (>= 0x80) - // Can also be hyphen if not followed by digit or another hyphen - const first = input[0]; - if (first == '-') { - if (input.len < 2) { - @branchHint(.cold); - return error.InvalidIDSelector; + // First pass: find the end of the id and check if there are escape sequences + var i: usize = 0; + var has_escape = false; + var first_char_validated = false; + + while (i < input.len) { + const b = input[i]; + + if (b == '\\') { + // Escape sequence + if (i + 1 >= input.len) { + @branchHint(.cold); + return error.InvalidIDSelector; + } + has_escape = true; + i += 2; // Skip backslash and escaped char + first_char_validated = true; + continue; } - const second = input[1]; - if (second == '-' or std.ascii.isDigit(second)) { - @branchHint(.cold); - return error.InvalidIDSelector; + + // Validate first character if not yet validated + if (!first_char_validated) { + if (b == '-') { + if (i + 1 >= input.len) { + @branchHint(.cold); + return error.InvalidIDSelector; + } + const second = input[i + 1]; + if (second == '-' or std.ascii.isDigit(second)) { + @branchHint(.cold); + return error.InvalidIDSelector; + } + } else if (!std.ascii.isAlphabetic(b) and b != '_' and b < 0x80) { + @branchHint(.cold); + return error.InvalidIDSelector; + } + first_char_validated = true; } - } else if (!std.ascii.isAlphabetic(first) and first != '_' and first < 0x80) { - @branchHint(.cold); - return error.InvalidIDSelector; - } - var i: usize = 1; - for (input[1..]) |b| { + // Check if this is a valid id character switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, // non-ASCII characters @@ -701,11 +721,39 @@ pub fn id(self: *Parser) ![]const u8 { i += 1; } + if (i == 0) { + @branchHint(.cold); + return error.InvalidIDSelector; + } + + const raw = input[0..i]; self.input = input[i..]; - return input[0..i]; + + // If no escape sequences, return the slice as-is + if (!has_escape) { + return raw; + } + + // Build unescaped string + var result = try std.ArrayList(u8).initCapacity(arena, raw.len); + var j: usize = 0; + while (j < raw.len) { + if (raw[j] == '\\') { + j += 1; // Skip backslash + if (j < raw.len) { + try result.append(arena, raw[j]); // Add escaped char + j += 1; + } + } else { + try result.append(arena, raw[j]); + j += 1; + } + } + + return result.items; } -fn class(self: *Parser) ![]const u8 { +fn class(self: *Parser, arena: Allocator) ![]const u8 { // Must be called when we're at a '.' std.debug.assert(self.peek() == '.'); @@ -717,26 +765,46 @@ fn class(self: *Parser) ![]const u8 { return error.InvalidClassSelector; } - // First character: must be letter, underscore, or non-ASCII (>= 0x80) - // Can also be hyphen if not followed by digit or another hyphen - const first = input[0]; - if (first == '-') { - if (input.len < 2) { - @branchHint(.cold); - return error.InvalidClassSelector; + // First pass: find the end of the class name and check if there are escape sequences + var i: usize = 0; + var has_escape = false; + var first_char_validated = false; + + while (i < input.len) { + const b = input[i]; + + if (b == '\\') { + // Escape sequence + if (i + 1 >= input.len) { + @branchHint(.cold); + return error.InvalidClassSelector; + } + has_escape = true; + i += 2; // Skip backslash and escaped char + first_char_validated = true; + continue; } - const second = input[1]; - if (second == '-' or std.ascii.isDigit(second)) { - @branchHint(.cold); - return error.InvalidClassSelector; + + // Validate first character if not yet validated + if (!first_char_validated) { + if (b == '-') { + if (i + 1 >= input.len) { + @branchHint(.cold); + return error.InvalidClassSelector; + } + const second = input[i + 1]; + if (second == '-' or std.ascii.isDigit(second)) { + @branchHint(.cold); + return error.InvalidClassSelector; + } + } else if (!std.ascii.isAlphabetic(b) and b != '_' and b < 0x80) { + @branchHint(.cold); + return error.InvalidClassSelector; + } + first_char_validated = true; } - } else if (!std.ascii.isAlphabetic(first) and first != '_' and first < 0x80) { - @branchHint(.cold); - return error.InvalidClassSelector; - } - var i: usize = 1; - for (input[1..]) |b| { + // Check if this is a valid class name character switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, // non-ASCII characters @@ -751,8 +819,36 @@ fn class(self: *Parser) ![]const u8 { i += 1; } + if (i == 0) { + @branchHint(.cold); + return error.InvalidClassSelector; + } + + const raw = input[0..i]; self.input = input[i..]; - return input[0..i]; + + // If no escape sequences, return the slice as-is + if (!has_escape) { + return raw; + } + + // Build unescaped string + var result = try std.ArrayList(u8).initCapacity(arena, raw.len); + var j: usize = 0; + while (j < raw.len) { + if (raw[j] == '\\') { + j += 1; // Skip backslash + if (j < raw.len) { + try result.append(arena, raw[j]); // Add escaped char + j += 1; + } + } else { + try result.append(arena, raw[j]); + j += 1; + } + } + + return result.items; } fn tag(self: *Parser) ![]const u8 { @@ -941,227 +1037,231 @@ fn fastEql(a: []const u8, comptime b: []const u8) bool { const testing = @import("../../../testing.zig"); test "Selector: Parser.ID" { + const arena = testing.allocator; + { var parser = Parser{ .input = "#" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "# " }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#1" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#9abc" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#-1" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#-5abc" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#--" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#--test" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#-" }; - try testing.expectError(error.InvalidIDSelector, parser.id()); + try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#over" }; - try testing.expectEqual("over", try parser.id()); + try testing.expectEqual("over", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#myID123" }; - try testing.expectEqual("myID123", try parser.id()); + try testing.expectEqual("myID123", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#_test" }; - try testing.expectEqual("_test", try parser.id()); + try testing.expectEqual("_test", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#test_123" }; - try testing.expectEqual("test_123", try parser.id()); + try testing.expectEqual("test_123", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#-test" }; - try testing.expectEqual("-test", try parser.id()); + try testing.expectEqual("-test", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#my-id" }; - try testing.expectEqual("my-id", try parser.id()); + try testing.expectEqual("my-id", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#test other" }; - try testing.expectEqual("test", try parser.id()); + try testing.expectEqual("test", try parser.id(arena)); try testing.expectEqual(" other", parser.input); } { var parser = Parser{ .input = "#id.class" }; - try testing.expectEqual("id", try parser.id()); + try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(".class", parser.input); } { var parser = Parser{ .input = "#id:hover" }; - try testing.expectEqual("id", try parser.id()); + try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(":hover", parser.input); } { var parser = Parser{ .input = "#id>child" }; - try testing.expectEqual("id", try parser.id()); + try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(">child", parser.input); } { var parser = Parser{ .input = "#id[attr]" }; - try testing.expectEqual("id", try parser.id()); + try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual("[attr]", parser.input); } } test "Selector: Parser.class" { + const arena = testing.allocator; + { var parser = Parser{ .input = "." }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ". " }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".1" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".9abc" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".-1" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".-5abc" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".--" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".--test" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".-" }; - try testing.expectError(error.InvalidClassSelector, parser.class()); + try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".active" }; - try testing.expectEqual("active", try parser.class()); + try testing.expectEqual("active", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".myClass123" }; - try testing.expectEqual("myClass123", try parser.class()); + try testing.expectEqual("myClass123", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "._test" }; - try testing.expectEqual("_test", try parser.class()); + try testing.expectEqual("_test", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".test_123" }; - try testing.expectEqual("test_123", try parser.class()); + try testing.expectEqual("test_123", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".-test" }; - try testing.expectEqual("-test", try parser.class()); + try testing.expectEqual("-test", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".my-class" }; - try testing.expectEqual("my-class", try parser.class()); + try testing.expectEqual("my-class", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".test other" }; - try testing.expectEqual("test", try parser.class()); + try testing.expectEqual("test", try parser.class(arena)); try testing.expectEqual(" other", parser.input); } { var parser = Parser{ .input = ".class1.class2" }; - try testing.expectEqual("class1", try parser.class()); + try testing.expectEqual("class1", try parser.class(arena)); try testing.expectEqual(".class2", parser.input); } { var parser = Parser{ .input = ".class:hover" }; - try testing.expectEqual("class", try parser.class()); + try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual(":hover", parser.input); } { var parser = Parser{ .input = ".class>child" }; - try testing.expectEqual("class", try parser.class()); + try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual(">child", parser.input); } { var parser = Parser{ .input = ".class[attr]" }; - try testing.expectEqual("class", try parser.class()); + try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual("[attr]", parser.input); } } @@ -1354,3 +1454,4 @@ test "Selector: Parser.parseNthPattern" { try testing.expectEqual(" )", parser.input); } } + diff --git a/src/browser/webapi/storage/cookie.zig b/src/browser/webapi/storage/cookie.zig index 25d6f51dd..436d258b2 100644 --- a/src/browser/webapi/storage/cookie.zig +++ b/src/browser/webapi/storage/cookie.zig @@ -129,7 +129,7 @@ pub const Jar = struct { pub fn populateFromResponse(self: *Jar, uri: *const Uri, set_cookie: []const u8) !void { const c = Cookie.parse(self.allocator, uri, set_cookie) catch |err| { - log.warn(.web_api, "cookie parse failed", .{ .raw = set_cookie, .err = err }); + log.warn(.page, "cookie parse failed", .{ .raw = set_cookie, .err = err }); return; }; @@ -312,7 +312,7 @@ pub const Cookie = struct { // Algolia, for example, will call document.setCookie with // an expired value which is literally 'Invalid Date' // (it's trying to do something like: `new Date() + undefined`). - log.debug(.web_api, "cookie expires date", .{ .date = expires_ }); + log.debug(.page, "cookie expires date", .{ .date = expires_ }); } } } diff --git a/src/cdp/domains/log.zig b/src/cdp/domains/log.zig index 07d3c6d65..66b8b79f1 100644 --- a/src/cdp/domains/log.zig +++ b/src/cdp/domains/log.zig @@ -88,8 +88,8 @@ pub fn LogInterceptor(comptime BC: type) type { self.bc.cdp.sendEvent("Log.entryAdded", .{ .entry = .{ .source = switch (scope) { - .js, .user_script, .console, .web_api, .script_event => "javascript", - .http, .fetch, .xhr => "network", + .js, .console => "javascript", + .http => "network", .telemetry, .unknown_prop, .interceptor => unreachable, // filtered out in writer above else => "other", }, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 9c15f7224..ddc815fdd 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -32,7 +32,7 @@ const Allocator = std.mem.Allocator; pub const FetchOpts = struct { wait_ms: u32 = 5000, - dump: dump.Opts, + dump: dump.RootOpts, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { @@ -64,7 +64,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = session.fetchWait(opts.wait_ms); const writer = opts.writer orelse return; - try dump.deep(page.document.asNode(), opts.dump, writer); + try dump.root(opts.dump, writer, page); try writer.flush(); } diff --git a/src/log.zig b/src/log.zig index e34329e8b..d3ab7c760 100644 --- a/src/log.zig +++ b/src/log.zig @@ -33,19 +33,12 @@ pub const Scope = enum { http, page, js, - loop, event, scheduler, not_implemented, - script_event, telemetry, - user_script, - unknown_prop, - web_api, - xhr, - fetch, - polyfill, interceptor, + unknown_prop, }; const Opts = struct { @@ -394,7 +387,7 @@ test "log: data" { const string = try testing.allocator.dupe(u8, "spice_must_flow"); defer testing.allocator.free(string); - try logTo(.http, .warn, "a msg", .{ + try logTo(.page, .warn, "a msg", .{ .cint = 5, .cfloat = 3.43, .int = @as(i16, -49), @@ -409,7 +402,7 @@ test "log: data" { .level = Level.warn, }, &aw.writer); - try testing.expectEqual("$time=1739795092929 $scope=http $level=warn $msg=\"a msg\" " ++ + try testing.expectEqual("$time=1739795092929 $scope=page $level=warn $msg=\"a msg\" " ++ "cint=5 cfloat=3.43 int=-49 float=0.0003232 bt=true bf=false " ++ "nn=33 n=null lit=over9000! slice=spice_must_flow " ++ "err=Nope level=warn\n", aw.written()); diff --git a/src/main.zig b/src/main.zig index 42ad8d0f6..1f7bd57e3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -125,8 +125,8 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, .dump = .{ + .strip = opts.strip, .with_base = opts.withbase, - .strip_mode = opts.strip_mode, }, }; @@ -245,7 +245,7 @@ const Command = struct { dump: bool = false, common: Common, withbase: bool = false, - strip_mode: lp.dump.Opts.StripMode = .{}, + strip: lp.dump.Opts.Strip = .{}, }; const Common = struct { @@ -511,7 +511,7 @@ fn parseFetchArgs( var withbase: bool = false; var url: ?[:0]const u8 = null; var common: Command.Common = .{}; - var strip_mode: lp.dump.Opts.StripMode = .{}; + var strip: lp.dump.Opts.Strip = .{}; while (args.next()) |opt| { if (std.mem.eql(u8, "--dump", opt)) { @@ -524,7 +524,7 @@ fn parseFetchArgs( .feature = "--noscript argument", .hint = "use '--strip_mode js' instead", }); - strip_mode.js = true; + strip.js = true; continue; } @@ -543,15 +543,15 @@ fn parseFetchArgs( while (it.next()) |part| { const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); if (std.mem.eql(u8, trimmed, "js")) { - strip_mode.js = true; + strip.js = true; } else if (std.mem.eql(u8, trimmed, "ui")) { - strip_mode.ui = true; + strip.ui = true; } else if (std.mem.eql(u8, trimmed, "css")) { - strip_mode.css = true; + strip.css = true; } else if (std.mem.eql(u8, trimmed, "full")) { - strip_mode.js = true; - strip_mode.ui = true; - strip_mode.css = true; + strip.js = true; + strip.ui = true; + strip.css = true; } else { log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed }); } @@ -583,9 +583,9 @@ fn parseFetchArgs( return .{ .url = url.?, .dump = dump, + .strip = strip, .common = common, .withbase = withbase, - .strip_mode = strip_mode, }; } From aa1742db639a88559a1e5522dc796d4cc639c477 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 24 Nov 2025 10:43:08 -0800 Subject: [PATCH 070/257] use SlabAllocator --- src/browser/Factory.zig | 96 +----- src/slab.zig | 651 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 661 insertions(+), 86 deletions(-) create mode 100644 src/slab.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 8c9c3b58c..6013a2ff6 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -24,6 +24,8 @@ const IS_DEBUG = builtin.mode == .Debug; const log = @import("../log.zig"); const String = @import("../string.zig").String; +const SlabAllocator = @import("../slab.zig").SlabAllocator(16); + const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); @@ -46,48 +48,12 @@ const MemoryPoolAligned = std.heap.MemoryPoolAligned; // (and alignment) based pools. const Factory = @This(); _page: *Page, -_size_8_8: MemoryPoolAligned([8]u8, .@"8"), -_size_16_8: MemoryPoolAligned([16]u8, .@"8"), -_size_24_8: MemoryPoolAligned([24]u8, .@"8"), -_size_32_8: MemoryPoolAligned([32]u8, .@"8"), -_size_32_16: MemoryPoolAligned([32]u8, .@"16"), -_size_40_8: MemoryPoolAligned([40]u8, .@"8"), -_size_48_16: MemoryPoolAligned([48]u8, .@"16"), -_size_56_8: MemoryPoolAligned([56]u8, .@"8"), -_size_64_16: MemoryPoolAligned([64]u8, .@"16"), -_size_80_16: MemoryPoolAligned([80]u8, .@"16"), -_size_88_8: MemoryPoolAligned([88]u8, .@"8"), -_size_96_16: MemoryPoolAligned([96]u8, .@"16"), -_size_128_8: MemoryPoolAligned([128]u8, .@"8"), -_size_144_8: MemoryPoolAligned([144]u8, .@"8"), -_size_152_8: MemoryPoolAligned([152]u8, .@"8"), -_size_160_8: MemoryPoolAligned([160]u8, .@"8"), -_size_184_8: MemoryPoolAligned([184]u8, .@"8"), -_size_232_8: MemoryPoolAligned([232]u8, .@"8"), -_size_648_8: MemoryPoolAligned([648]u8, .@"8"), +_slab: SlabAllocator, pub fn init(page: *Page) Factory { return .{ ._page = page, - ._size_8_8 = MemoryPoolAligned([8]u8, .@"8").init(page.arena), - ._size_16_8 = MemoryPoolAligned([16]u8, .@"8").init(page.arena), - ._size_24_8 = MemoryPoolAligned([24]u8, .@"8").init(page.arena), - ._size_32_8 = MemoryPoolAligned([32]u8, .@"8").init(page.arena), - ._size_32_16 = MemoryPoolAligned([32]u8, .@"16").init(page.arena), - ._size_40_8 = MemoryPoolAligned([40]u8, .@"8").init(page.arena), - ._size_48_16 = MemoryPoolAligned([48]u8, .@"16").init(page.arena), - ._size_56_8 = MemoryPoolAligned([56]u8, .@"8").init(page.arena), - ._size_64_16 = MemoryPoolAligned([64]u8, .@"16").init(page.arena), - ._size_80_16 = MemoryPoolAligned([80]u8, .@"16").init(page.arena), - ._size_88_8 = MemoryPoolAligned([88]u8, .@"8").init(page.arena), - ._size_96_16 = MemoryPoolAligned([96]u8, .@"16").init(page.arena), - ._size_128_8 = MemoryPoolAligned([128]u8, .@"8").init(page.arena), - ._size_144_8 = MemoryPoolAligned([144]u8, .@"8").init(page.arena), - ._size_152_8 = MemoryPoolAligned([152]u8, .@"8").init(page.arena), - ._size_160_8 = MemoryPoolAligned([160]u8, .@"8").init(page.arena), - ._size_184_8 = MemoryPoolAligned([184]u8, .@"8").init(page.arena), - ._size_232_8 = MemoryPoolAligned([232]u8, .@"8").init(page.arena), - ._size_648_8 = MemoryPoolAligned([648]u8, .@"8").init(page.arena), + ._slab = SlabAllocator.init(page.arena), }; } @@ -246,28 +212,8 @@ pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) { } pub fn createT(self: *Factory, comptime T: type) !*T { - const SO = @sizeOf(T); - if (comptime SO == 8) return @ptrCast(try self._size_8_8.create()); - if (comptime SO == 16) return @ptrCast(try self._size_16_8.create()); - if (comptime SO == 24) return @ptrCast(try self._size_24_8.create()); - if (comptime SO == 32) { - if (comptime @alignOf(T) == 8) return @ptrCast(try self._size_32_8.create()); - if (comptime @alignOf(T) == 16) return @ptrCast(try self._size_32_16.create()); - } - if (comptime SO == 40) return @ptrCast(try self._size_40_8.create()); - if (comptime SO == 48) return @ptrCast(try self._size_48_16.create()); - if (comptime SO == 56) return @ptrCast(try self._size_56_8.create()); - if (comptime SO == 64) return @ptrCast(try self._size_64_16.create()); - if (comptime SO == 80) return @ptrCast(try self._size_80_16.create()); - if (comptime SO == 88) return @ptrCast(try self._size_88_8.create()); - if (comptime SO == 96) return @ptrCast(try self._size_96_16.create()); - if (comptime SO == 128) return @ptrCast(try self._size_128_8.create()); - if (comptime SO == 152) return @ptrCast(try self._size_152_8.create()); - if (comptime SO == 160) return @ptrCast(try self._size_160_8.create()); - if (comptime SO == 184) return @ptrCast(try self._size_184_8.create()); - if (comptime SO == 232) return @ptrCast(try self._size_232_8.create()); - if (comptime SO == 648) return @ptrCast(try self._size_648_8.create()); - @compileError(std.fmt.comptimePrint("No pool configured for @sizeOf({d}), @alignOf({d}): ({s})", .{ SO, @alignOf(T), @typeName(T) })); + const allocator = self._slab.allocator(); + return try allocator.create(T); } pub fn destroy(self: *Factory, value: anytype) void { @@ -291,6 +237,8 @@ pub fn destroy(self: *Factory, value: anytype) void { fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { const S = reflect.Struct(@TypeOf(value)); + const allocator = self._slab.allocator(); + // This is initially called from a deinit. We don't want to call that // same deinit. So when this is the first time destroyChain is called // we don't call deinit (because we're in that deinit) @@ -311,7 +259,7 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { } else if (@hasDecl(S, "JsApi")) { // Doesn't have a _proto, but has a JsApi. if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| { - self._size_24_8.destroy(@ptrCast(tagged)); + allocator.destroy(tagged); } } @@ -319,31 +267,7 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { // (which makes sense when the @sizeOf(Leaf) == 8). These don't need to // be (cannot be) freed. But we'll still free the chain. if (comptime wasAllocated(S)) { - switch (@sizeOf(S)) { - 8 => self._size_8_8.destroy(@ptrCast(@alignCast(value))), - 16 => self._size_16_8.destroy(@ptrCast(value)), - 24 => self._size_24_8.destroy(@ptrCast(value)), - 32 => { - if (comptime @alignOf(S) == 8) { - self._size_32_8.destroy(@ptrCast(value)); - } else if (comptime @alignOf(S) == 16) { - self._size_32_16.destroy(@ptrCast(value)); - } - }, - 40 => self._size_40_8.destroy(@ptrCast(value)), - 48 => self._size_48_16.destroy(@ptrCast(@alignCast(value))), - 56 => self._size_56_8.destroy(@ptrCast(value)), - 64 => self._size_64_16.destroy(@ptrCast(@alignCast(value))), - 80 => self._size_80_16.destroy(@ptrCast(@alignCast(value))), - 88 => self._size_88_8.destroy(@ptrCast(@alignCast(value))), - 96 => self._size_96_16.destroy(@ptrCast(@alignCast(value))), - 128 => self._size_128_8.destroy(@ptrCast(value)), - 144 => self._size_144_8.destroy(@ptrCast(value)), - 152 => self._size_152_8.destroy(@ptrCast(value)), - 160 => self._size_160_8.destroy(@ptrCast(value)), - 648 => self._size_648_8.destroy(@ptrCast(value)), - else => |SO| @compileError(std.fmt.comptimePrint("Don't know what I'm being asked to destroy @sizeOf({d}), @alignOf({d}): ({s})", .{ SO, @alignOf(S), @typeName(S) })), - } + allocator.destroy(value); } } diff --git a/src/slab.zig b/src/slab.zig new file mode 100644 index 000000000..0af4f6168 --- /dev/null +++ b/src/slab.zig @@ -0,0 +1,651 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const Allocator = std.mem.Allocator; +const Alignment = std.mem.Alignment; + +pub fn SlabAllocator(comptime slot_count: usize) type { + comptime assert(std.math.isPowerOfTwo(slot_count)); + + const Slab = struct { + const Slab = @This(); + const chunk_shift = std.math.log2_int(usize, slot_count); + const chunk_mask = slot_count - 1; + + alignment: Alignment, + item_size: usize, + + bitset: std.bit_set.DynamicBitSetUnmanaged, + chunks: std.ArrayListUnmanaged([]u8), + + pub fn init( + allocator: Allocator, + alignment: Alignment, + item_size: usize, + ) !Slab { + return .{ + .alignment = alignment, + .item_size = item_size, + .bitset = try .initFull(allocator, 0), + .chunks = .empty, + }; + } + + pub fn deinit(self: *Slab, allocator: Allocator) void { + self.bitset.deinit(allocator); + + for (self.chunks.items) |chunk| { + allocator.rawFree(chunk, self.alignment, @returnAddress()); + } + + self.chunks.deinit(allocator); + } + + inline fn toBitsetIndex(chunk_index: usize, slot_index: usize) usize { + return chunk_index * slot_count + slot_index; + } + + inline fn chunkIndex(bitset_index: usize) usize { + return bitset_index >> chunk_shift; + } + + inline fn slotIndex(bitset_index: usize) usize { + return bitset_index & chunk_mask; + } + + fn alloc(self: *Slab, allocator: Allocator) ![]u8 { + if (self.bitset.findFirstSet()) |index| { + // if we have a free slot + const chunk_index = chunkIndex(index); + const slot_index = slotIndex(index); + self.bitset.unset(index); + + const chunk = self.chunks.items[chunk_index]; + const offset = slot_index * self.item_size; + return chunk.ptr[offset..][0..self.item_size]; + } else { + const old_capacity = self.bitset.bit_length; + + // if we have don't have a free slot + try self.allocateChunk(allocator); + + const first_slot_index = old_capacity; + self.bitset.unset(first_slot_index); + + const new_chunk = self.chunks.items[self.chunks.items.len - 1]; + return new_chunk.ptr[0..self.item_size]; + } + } + + fn free(self: *Slab, ptr: [*]u8) void { + const addr = @intFromPtr(ptr); + + for (self.chunks.items, 0..) |chunk, i| { + const chunk_start = @intFromPtr(chunk.ptr); + const chunk_end = chunk_start + (slot_count * self.item_size); + + if (addr >= chunk_start and addr < chunk_end) { + const offset = addr - chunk_start; + const slot_index = offset / self.item_size; + + const bitset_index = toBitsetIndex(i, slot_index); + assert(!self.bitset.isSet(bitset_index)); + + self.bitset.set(bitset_index); + return; + } + } + + unreachable; + } + + fn allocateChunk(self: *Slab, allocator: Allocator) !void { + const chunk_len = self.item_size * slot_count; + + const chunk_ptr = allocator.rawAlloc( + chunk_len, + self.alignment, + @returnAddress(), + ) orelse return error.FailedChildAllocation; + + const chunk = chunk_ptr[0..chunk_len]; + try self.chunks.append(allocator, chunk); + + const new_capacity = self.chunks.items.len * slot_count; + try self.bitset.resize(allocator, new_capacity, true); + } + }; + + const SlabKey = struct { + size: usize, + alignment: Alignment, + }; + + return struct { + const Self = @This(); + + child_allocator: Allocator, + slabs: std.ArrayHashMapUnmanaged(SlabKey, Slab, struct { + const Context = @This(); + + pub fn hash(_: Context, key: SlabKey) u32 { + var hasher = std.hash.Wyhash.init(0); + std.hash.autoHash(&hasher, key.size); + std.hash.autoHash(&hasher, key.alignment); + return @truncate(hasher.final()); + } + + pub fn eql(_: Context, a: SlabKey, b: SlabKey, _: usize) bool { + return a.size == b.size and a.alignment == b.alignment; + } + }, false) = .empty, + + pub fn init(child_allocator: Allocator) Self { + return .{ + .child_allocator = child_allocator, + .slabs = .empty, + }; + } + + pub fn deinit(self: *Self) void { + for (self.slabs.values()) |*slab| { + slab.deinit(self.child_allocator); + } + + self.slabs.deinit(self.child_allocator); + } + + pub const ResetKind = enum { + /// Free all chunks and release all memory. + clear, + /// Keep all chunks, reset trees to reuse memory. + retain_capacity, + }; + + /// This clears all of the stored memory, freeing the currently used chunks. + pub fn reset(self: *Self, kind: ResetKind) void { + switch (kind) { + .clear => { + for (self.slabs.values()) |*slab| { + for (slab.chunks.items) |chunk| { + self.child_allocator.free(chunk); + } + + slab.chunks.clearAndFree(self.child_allocator); + slab.bitset.deinit(self.child_allocator); + } + + self.slabs.clearAndFree(self.child_allocator); + }, + .retain_capacity => { + for (self.slabs.values()) |*slab| { + slab.bitset.setAll(); + } + }, + } + } + + pub const vtable = Allocator.VTable{ + .alloc = alloc, + .free = free, + .remap = Allocator.noRemap, + .resize = Allocator.noResize, + }; + + pub fn allocator(self: *Self) Allocator { + return .{ + .ptr = self, + .vtable = &vtable, + }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { + const self: *Self = @ptrCast(@alignCast(ctx)); + _ = ret_addr; + + const list_gop = self.slabs.getOrPut( + self.child_allocator, + SlabKey{ .size = len, .alignment = alignment }, + ) catch return null; + + if (!list_gop.found_existing) { + list_gop.value_ptr.* = Slab.init( + self.child_allocator, + alignment, + len, + ) catch return null; + } + + const list = list_gop.value_ptr; + const buf = list.alloc(self.child_allocator) catch return null; + return buf.ptr; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + _ = ret_addr; + + const ptr = memory.ptr; + const len = memory.len; + + const list = self.slabs.getPtr(.{ .size = len, .alignment = alignment }).?; + list.free(ptr); + } + }; +} + +const testing = std.testing; + +const TestSlabAllocator = SlabAllocator(32); + +test "slab allocator - basic allocation and free" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate some memory + const ptr1 = try allocator.alloc(u8, 100); + try testing.expect(ptr1.len == 100); + + // Write to it to ensure it's valid + @memset(ptr1, 42); + try testing.expectEqual(@as(u8, 42), ptr1[50]); + + // Free it + allocator.free(ptr1); +} + +test "slab allocator - multiple allocations" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + const ptr1 = try allocator.alloc(u8, 64); + const ptr2 = try allocator.alloc(u8, 128); + const ptr3 = try allocator.alloc(u8, 256); + + // Ensure they don't overlap + const addr1 = @intFromPtr(ptr1.ptr); + const addr2 = @intFromPtr(ptr2.ptr); + const addr3 = @intFromPtr(ptr3.ptr); + + try testing.expect(addr1 + 64 <= addr2 or addr2 + 128 <= addr1); + try testing.expect(addr2 + 128 <= addr3 or addr3 + 256 <= addr2); + + allocator.free(ptr1); + allocator.free(ptr2); + allocator.free(ptr3); +} + +test "slab allocator - no coalescing (different size classes)" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate two blocks of same size + const ptr1 = try allocator.alloc(u8, 128); + const ptr2 = try allocator.alloc(u8, 128); + + // Free them (no coalescing in slab allocator) + allocator.free(ptr1); + allocator.free(ptr2); + + // Can't allocate larger block from these freed 128-byte blocks + const ptr3 = try allocator.alloc(u8, 256); + + // ptr3 will be from a different size class, not coalesced from ptr1+ptr2 + const addr1 = @intFromPtr(ptr1.ptr); + const addr3 = @intFromPtr(ptr3.ptr); + + // They should NOT be adjacent (different size classes) + try testing.expect(addr3 < addr1 or addr3 >= addr1 + 256); + + allocator.free(ptr3); +} + +test "slab allocator - reuse freed memory" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + const ptr1 = try allocator.alloc(u8, 64); + const addr1 = @intFromPtr(ptr1.ptr); + allocator.free(ptr1); + + // Allocate same size, should reuse from same slab + const ptr2 = try allocator.alloc(u8, 64); + const addr2 = @intFromPtr(ptr2.ptr); + + try testing.expectEqual(addr1, addr2); + allocator.free(ptr2); +} + +test "slab allocator - multiple size classes" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate various sizes - each creates a new slab + var ptrs: [10][]u8 = undefined; + const sizes = [_]usize{ 24, 40, 64, 88, 128, 144, 200, 256, 512, 1000 }; + + for (&ptrs, sizes) |*ptr, size| { + ptr.* = try allocator.alloc(u8, size); + @memset(ptr.*, 0xFF); + } + + // Should have created multiple slabs + try testing.expect(seg.slabs.count() >= 10); + + // Free all + for (ptrs) |ptr| { + allocator.free(ptr); + } +} + +test "slab allocator - various sizes" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Test different sizes (not limited to powers of 2!) + const sizes = [_]usize{ 8, 16, 24, 32, 40, 64, 88, 128, 144, 256 }; + + for (sizes) |size| { + const ptr = try allocator.alloc(u8, size); + try testing.expect(ptr.len == size); + @memset(ptr, @intCast(size & 0xFF)); + allocator.free(ptr); + } +} + +test "slab allocator - exact sizes (no rounding)" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Odd sizes stay exact (unlike buddy which rounds to power of 2) + const ptr1 = try allocator.alloc(u8, 100); + const ptr2 = try allocator.alloc(u8, 200); + const ptr3 = try allocator.alloc(u8, 50); + + // Exact sizes! + try testing.expect(ptr1.len == 100); + try testing.expect(ptr2.len == 200); + try testing.expect(ptr3.len == 50); + + allocator.free(ptr1); + allocator.free(ptr2); + allocator.free(ptr3); +} + +test "slab allocator - chunk allocation" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate many items of same size to force multiple chunks + var ptrs: [100][]u8 = undefined; + for (&ptrs) |*ptr| { + ptr.* = try allocator.alloc(u8, 64); + } + + // Should have allocated multiple chunks (32 items per chunk) + const slab = seg.slabs.getPtr(.{ .size = 64, .alignment = Alignment.@"1" }).?; + try testing.expect(slab.chunks.items.len > 1); + + // Free all + for (ptrs) |ptr| { + allocator.free(ptr); + } +} + +test "slab allocator - reset with retain_capacity" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate some memory + const ptr1 = try allocator.alloc(u8, 128); + const ptr2 = try allocator.alloc(u8, 256); + _ = ptr1; + _ = ptr2; + + const slabs_before = seg.slabs.count(); + const slab_128 = seg.slabs.getPtr(.{ .size = 128, .alignment = Alignment.@"1" }).?; + const chunks_before = slab_128.chunks.items.len; + + // Reset but keep chunks + seg.reset(.retain_capacity); + + try testing.expectEqual(slabs_before, seg.slabs.count()); + try testing.expectEqual(chunks_before, slab_128.chunks.items.len); + + // Should be able to allocate again + const ptr3 = try allocator.alloc(u8, 512); + allocator.free(ptr3); +} + +test "slab allocator - reset with clear" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate some memory + const ptr1 = try allocator.alloc(u8, 128); + _ = ptr1; + + try testing.expect(seg.slabs.count() > 0); + + // Reset and free everything + seg.reset(.clear); + + try testing.expectEqual(@as(usize, 0), seg.slabs.count()); + + // Should still work after reset + const ptr2 = try allocator.alloc(u8, 256); + allocator.free(ptr2); +} + +test "slab allocator - stress test" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + + var ptrs: std.ArrayList([]u8) = .empty; + + defer { + for (ptrs.items) |ptr| { + allocator.free(ptr); + } + ptrs.deinit(allocator); + } + + // Random allocations and frees + var i: usize = 0; + while (i < 100) : (i += 1) { + if (random.boolean() and ptrs.items.len > 0) { + // Free a random allocation + const index = random.uintLessThan(usize, ptrs.items.len); + allocator.free(ptrs.swapRemove(index)); + } else { + // Allocate random size (8 to 512) + const size = random.uintAtMost(usize, 504) + 8; + const ptr = try allocator.alloc(u8, size); + try ptrs.append(allocator, ptr); + + // Write to ensure it's valid + @memset(ptr, @intCast(i & 0xFF)); + } + } +} + +test "slab allocator - alignment" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + const ptr1 = try allocator.create(u64); + const ptr2 = try allocator.create(u32); + const ptr3 = try allocator.create([100]u8); + + allocator.destroy(ptr1); + allocator.destroy(ptr2); + allocator.destroy(ptr3); +} + +test "slab allocator - no resize support" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + const slice = try allocator.alloc(u8, 100); + @memset(slice, 42); + + // Resize should fail (not supported) + try testing.expect(!allocator.resize(slice, 90)); + try testing.expect(!allocator.resize(slice, 200)); + + allocator.free(slice); +} + +test "slab allocator - fragmentation pattern" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate 10 items + var items: [10][]u8 = undefined; + for (&items) |*item| { + item.* = try allocator.alloc(u8, 64); + @memset(item.*, 0xFF); + } + + // Free every other one + allocator.free(items[0]); + allocator.free(items[2]); + allocator.free(items[4]); + allocator.free(items[6]); + allocator.free(items[8]); + + // Allocate new items - should reuse freed slots + const new1 = try allocator.alloc(u8, 64); + const new2 = try allocator.alloc(u8, 64); + const new3 = try allocator.alloc(u8, 64); + + // Should get some of the freed slots back + const addrs = [_]usize{ + @intFromPtr(items[0].ptr), + @intFromPtr(items[2].ptr), + @intFromPtr(items[4].ptr), + @intFromPtr(items[6].ptr), + @intFromPtr(items[8].ptr), + }; + + const new1_addr = @intFromPtr(new1.ptr); + var found = false; + for (addrs) |addr| { + if (new1_addr == addr) found = true; + } + try testing.expect(found); + + // Cleanup + allocator.free(items[1]); + allocator.free(items[3]); + allocator.free(items[5]); + allocator.free(items[7]); + allocator.free(items[9]); + allocator.free(new1); + allocator.free(new2); + allocator.free(new3); +} + +test "slab allocator - many small allocations" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate 1000 small items + var ptrs: std.ArrayList([]u8) = .empty; + defer { + for (ptrs.items) |ptr| { + allocator.free(ptr); + } + ptrs.deinit(allocator); + } + + var i: usize = 0; + while (i < 1000) : (i += 1) { + const ptr = try allocator.alloc(u8, 24); + try ptrs.append(allocator, ptr); + } + + // Should have created multiple chunks + const slab = seg.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; + try testing.expect(slab.chunks.items.len > 10); +} + +test "slab allocator - zero waste for exact sizes" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // These sizes have zero internal fragmentation (unlike buddy) + const sizes = [_]usize{ 24, 40, 56, 88, 144, 152, 184, 232, 648 }; + + for (sizes) |size| { + const ptr = try allocator.alloc(u8, size); + + // Exact size returned! + try testing.expectEqual(size, ptr.len); + + @memset(ptr, 0xFF); + allocator.free(ptr); + } +} + +test "slab allocator - different size classes don't interfere" { + var seg = TestSlabAllocator.init(testing.allocator); + defer seg.deinit(); + + const allocator = seg.allocator(); + + // Allocate size 64 + const ptr_64 = try allocator.alloc(u8, 64); + const addr_64 = @intFromPtr(ptr_64.ptr); + allocator.free(ptr_64); + + // Allocate size 128 - should NOT reuse size-64 slot + const ptr_128 = try allocator.alloc(u8, 128); + const addr_128 = @intFromPtr(ptr_128.ptr); + + try testing.expect(addr_64 != addr_128); + + // Allocate size 64 again - SHOULD reuse original slot + const ptr_64_again = try allocator.alloc(u8, 64); + const addr_64_again = @intFromPtr(ptr_64_again.ptr); + + try testing.expectEqual(addr_64, addr_64_again); + + allocator.free(ptr_128); + allocator.free(ptr_64_again); +} From 219245be9534f71043a79d7f352d74711c25bd6e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 24 Nov 2025 20:36:15 -0800 Subject: [PATCH 071/257] standardize slab testing names --- src/slab.zig | 122 +++++++++++++++++++++++++-------------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/src/slab.zig b/src/slab.zig index 0af4f6168..52d63c825 100644 --- a/src/slab.zig +++ b/src/slab.zig @@ -239,10 +239,10 @@ const testing = std.testing; const TestSlabAllocator = SlabAllocator(32); test "slab allocator - basic allocation and free" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 100); @@ -257,10 +257,10 @@ test "slab allocator - basic allocation and free" { } test "slab allocator - multiple allocations" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); const ptr1 = try allocator.alloc(u8, 64); const ptr2 = try allocator.alloc(u8, 128); @@ -280,10 +280,10 @@ test "slab allocator - multiple allocations" { } test "slab allocator - no coalescing (different size classes)" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate two blocks of same size const ptr1 = try allocator.alloc(u8, 128); @@ -307,10 +307,10 @@ test "slab allocator - no coalescing (different size classes)" { } test "slab allocator - reuse freed memory" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); const ptr1 = try allocator.alloc(u8, 64); const addr1 = @intFromPtr(ptr1.ptr); @@ -325,10 +325,10 @@ test "slab allocator - reuse freed memory" { } test "slab allocator - multiple size classes" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate various sizes - each creates a new slab var ptrs: [10][]u8 = undefined; @@ -340,7 +340,7 @@ test "slab allocator - multiple size classes" { } // Should have created multiple slabs - try testing.expect(seg.slabs.count() >= 10); + try testing.expect(slab_alloc.slabs.count() >= 10); // Free all for (ptrs) |ptr| { @@ -349,10 +349,10 @@ test "slab allocator - multiple size classes" { } test "slab allocator - various sizes" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Test different sizes (not limited to powers of 2!) const sizes = [_]usize{ 8, 16, 24, 32, 40, 64, 88, 128, 144, 256 }; @@ -366,10 +366,10 @@ test "slab allocator - various sizes" { } test "slab allocator - exact sizes (no rounding)" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Odd sizes stay exact (unlike buddy which rounds to power of 2) const ptr1 = try allocator.alloc(u8, 100); @@ -387,10 +387,10 @@ test "slab allocator - exact sizes (no rounding)" { } test "slab allocator - chunk allocation" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate many items of same size to force multiple chunks var ptrs: [100][]u8 = undefined; @@ -399,7 +399,7 @@ test "slab allocator - chunk allocation" { } // Should have allocated multiple chunks (32 items per chunk) - const slab = seg.slabs.getPtr(.{ .size = 64, .alignment = Alignment.@"1" }).?; + const slab = slab_alloc.slabs.getPtr(.{ .size = 64, .alignment = Alignment.@"1" }).?; try testing.expect(slab.chunks.items.len > 1); // Free all @@ -409,10 +409,10 @@ test "slab allocator - chunk allocation" { } test "slab allocator - reset with retain_capacity" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 128); @@ -420,14 +420,14 @@ test "slab allocator - reset with retain_capacity" { _ = ptr1; _ = ptr2; - const slabs_before = seg.slabs.count(); - const slab_128 = seg.slabs.getPtr(.{ .size = 128, .alignment = Alignment.@"1" }).?; + const slabs_before = slab_alloc.slabs.count(); + const slab_128 = slab_alloc.slabs.getPtr(.{ .size = 128, .alignment = Alignment.@"1" }).?; const chunks_before = slab_128.chunks.items.len; // Reset but keep chunks - seg.reset(.retain_capacity); + slab_alloc.reset(.retain_capacity); - try testing.expectEqual(slabs_before, seg.slabs.count()); + try testing.expectEqual(slabs_before, slab_alloc.slabs.count()); try testing.expectEqual(chunks_before, slab_128.chunks.items.len); // Should be able to allocate again @@ -436,21 +436,21 @@ test "slab allocator - reset with retain_capacity" { } test "slab allocator - reset with clear" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 128); _ = ptr1; - try testing.expect(seg.slabs.count() > 0); + try testing.expect(slab_alloc.slabs.count() > 0); // Reset and free everything - seg.reset(.clear); + slab_alloc.reset(.clear); - try testing.expectEqual(@as(usize, 0), seg.slabs.count()); + try testing.expectEqual(@as(usize, 0), slab_alloc.slabs.count()); // Should still work after reset const ptr2 = try allocator.alloc(u8, 256); @@ -458,10 +458,10 @@ test "slab allocator - reset with clear" { } test "slab allocator - stress test" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); var prng = std.Random.DefaultPrng.init(0); const random = prng.random(); @@ -495,10 +495,10 @@ test "slab allocator - stress test" { } test "slab allocator - alignment" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); const ptr1 = try allocator.create(u64); const ptr2 = try allocator.create(u32); @@ -510,10 +510,10 @@ test "slab allocator - alignment" { } test "slab allocator - no resize support" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); const slice = try allocator.alloc(u8, 100); @memset(slice, 42); @@ -526,10 +526,10 @@ test "slab allocator - no resize support" { } test "slab allocator - fragmentation pattern" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate 10 items var items: [10][]u8 = undefined; @@ -578,10 +578,10 @@ test "slab allocator - fragmentation pattern" { } test "slab allocator - many small allocations" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate 1000 small items var ptrs: std.ArrayList([]u8) = .empty; @@ -599,15 +599,15 @@ test "slab allocator - many small allocations" { } // Should have created multiple chunks - const slab = seg.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; + const slab = slab_alloc.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; try testing.expect(slab.chunks.items.len > 10); } test "slab allocator - zero waste for exact sizes" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // These sizes have zero internal fragmentation (unlike buddy) const sizes = [_]usize{ 24, 40, 56, 88, 144, 152, 184, 232, 648 }; @@ -624,10 +624,10 @@ test "slab allocator - zero waste for exact sizes" { } test "slab allocator - different size classes don't interfere" { - var seg = TestSlabAllocator.init(testing.allocator); - defer seg.deinit(); + var slab_alloc = TestSlabAllocator.init(testing.allocator); + defer slab_alloc.deinit(); - const allocator = seg.allocator(); + const allocator = slab_alloc.allocator(); // Allocate size 64 const ptr_64 = try allocator.alloc(u8, 64); From 218d08b1f68ab03111950800602cd9bd7b867290 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 25 Nov 2025 13:00:32 +0800 Subject: [PATCH 072/257] add some skeleton implementations for various CSS WebAPIs --- src/browser/dump.zig | 2 +- src/browser/js/bridge.zig | 5 + src/browser/tests/css/stylesheet.html | 207 ++++++++++++++++++ src/browser/webapi/Document.zig | 13 +- src/browser/webapi/Navigator.zig | 1 - src/browser/webapi/Window.zig | 1 - src/browser/webapi/css/CSSRule.zig | 90 ++++++++ src/browser/webapi/css/CSSRuleList.zig | 36 +++ .../webapi/css/CSSStyleDeclaration.zig | 44 ++-- src/browser/webapi/css/CSSStyleProperties.zig | 4 +- src/browser/webapi/css/CSSStyleRule.zig | 48 ++++ src/browser/webapi/css/CSSStyleSheet.zig | 87 ++++++++ src/browser/webapi/css/StyleSheetList.zig | 34 +++ src/browser/webapi/selector/Parser.zig | 1 - src/cdp/domains/log.zig | 2 +- src/html5ever/lib.rs | 1 + 16 files changed, 547 insertions(+), 29 deletions(-) create mode 100644 src/browser/tests/css/stylesheet.html create mode 100644 src/browser/webapi/css/CSSRule.zig create mode 100644 src/browser/webapi/css/CSSRuleList.zig create mode 100644 src/browser/webapi/css/CSSStyleRule.zig create mode 100644 src/browser/webapi/css/CSSStyleSheet.zig create mode 100644 src/browser/webapi/css/StyleSheetList.zig diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 2e00ba39e..0617b4880 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -45,7 +45,7 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { } } - return deep(doc.asNode(), .{.strip = opts.strip}, writer); + return deep(doc.asNode(), .{ .strip = opts.strip }, writer); } pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}!void { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index bc380d1a0..d4b6b6fed 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -488,9 +488,14 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), + @import("../webapi/css/CSSRule.zig"), + @import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSStyleDeclaration.zig"), + @import("../webapi/css/CSSStyleRule.zig"), + @import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleProperties.zig"), @import("../webapi/css/MediaQueryList.zig"), + @import("../webapi/css/StyleSheetList.zig"), @import("../webapi/Document.zig"), @import("../webapi/HTMLDocument.zig"), @import("../webapi/History.zig"), diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html new file mode 100644 index 000000000..abc1ed92f --- /dev/null +++ b/src/browser/tests/css/stylesheet.html @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e895bfd40..05223fdec 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -31,6 +31,7 @@ const NodeFilter = @import("NodeFilter.zig"); const DOMTreeWalker = @import("DOMTreeWalker.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); +const StyleSheetList = @import("css/StyleSheetList.zig"); pub const HTMLDocument = @import("HTMLDocument.zig"); @@ -43,6 +44,7 @@ _ready_state: ReadyState = .loading, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, _active_element: ?*Element = null, +_style_sheets: ?*StyleSheetList = null, pub const Type = union(enum) { generic, @@ -225,6 +227,15 @@ pub fn getActiveElement(self: *Document) ?*Element { return self.getDocumentElement(); } +pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList { + if (self._style_sheets) |sheets| { + return sheets; + } + const sheets = try StyleSheetList.init(page); + self._style_sheets = sheets; + return sheets; +} + const ReadyState = enum { loading, interactive, @@ -253,7 +264,7 @@ pub const JsApi = struct { pub const readyState = bridge.accessor(Document.getReadyState, null, .{}); pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); - + pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); pub const createElement = bridge.function(Document.createElement, .{}); pub const createElementNS = bridge.function(Document.createElementNS, .{}); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 63b4cfc9a..3fa8154f1 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -120,4 +120,3 @@ pub const JsApi = struct { // Methods pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); }; - diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index b65359e44..1607bb79c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -243,7 +243,6 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } - const ScheduleOpts = struct { repeat: bool, params: []js.Object, diff --git a/src/browser/webapi/css/CSSRule.zig b/src/browser/webapi/css/CSSRule.zig new file mode 100644 index 000000000..dcf41db9e --- /dev/null +++ b/src/browser/webapi/css/CSSRule.zig @@ -0,0 +1,90 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const CSSRule = @This(); + +pub const Type = enum(u16) { + style = 1, + charset = 2, + import = 3, + media = 4, + font_face = 5, + page = 6, + keyframes = 7, + keyframe = 8, + margin = 9, + namespace = 10, + counter_style = 11, + supports = 12, + document = 13, + font_feature_values = 14, + viewport = 15, + region_style = 16, +}; + +_type: Type, + +pub fn init(rule_type: Type, page: *Page) !*CSSRule { + return page._factory.create(CSSRule{ + ._type = rule_type, + }); +} + +pub fn getType(self: *const CSSRule) u16 { + return @intFromEnum(self._type); +} + +pub fn getCssText(self: *const CSSRule, page: *Page) []const u8 { + _ = self; + _ = page; + return ""; +} + +pub fn setCssText(self: *CSSRule, text: []const u8, page: *Page) !void { + _ = self; + _ = text; + _ = page; +} + +pub fn getParentRule(self: *const CSSRule) ?*CSSRule { + _ = self; + return null; +} + +pub fn getParentStyleSheet(self: *const CSSRule) ?*CSSRule { + _ = self; + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CSSRule); + + pub const Meta = struct { + pub const name = "CSSRule"; + pub var class_id: bridge.ClassId = undefined; + pub const prototype_chain = bridge.prototypeChain(); + }; + + pub const STYLE_RULE = 1; + pub const CHARSET_RULE = 2; + pub const IMPORT_RULE = 3; + pub const MEDIA_RULE = 4; + pub const FONT_FACE_RULE = 5; + pub const PAGE_RULE = 6; + pub const KEYFRAMES_RULE = 7; + pub const KEYFRAME_RULE = 8; + pub const MARGIN_RULE = 9; + pub const NAMESPACE_RULE = 10; + pub const COUNTER_STYLE_RULE = 11; + pub const SUPPORTS_RULE = 12; + pub const DOCUMENT_RULE = 13; + pub const FONT_FEATURE_VALUES_RULE = 14; + pub const VIEWPORT_RULE = 15; + pub const REGION_STYLE_RULE = 16; + + pub const @"type" = bridge.accessor(CSSRule.getType, null, .{}); + pub const cssText = bridge.accessor(CSSRule.getCssText, CSSRule.setCssText, .{}); + pub const parentRule = bridge.accessor(CSSRule.getParentRule, null, .{}); + pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{}); +}; diff --git a/src/browser/webapi/css/CSSRuleList.zig b/src/browser/webapi/css/CSSRuleList.zig new file mode 100644 index 000000000..4a700237c --- /dev/null +++ b/src/browser/webapi/css/CSSRuleList.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const CSSRule = @import("CSSRule.zig"); + +const CSSRuleList = @This(); + +_rules: []*CSSRule = &.{}, + +pub fn init(page: *Page) !*CSSRuleList { + return page._factory.create(CSSRuleList{}); +} + +pub fn length(self: *const CSSRuleList) u32 { + return @intCast(self._rules.len); +} + +pub fn item(self: *const CSSRuleList, index: usize) ?*CSSRule { + if (index >= self._rules.len) { + return null; + } + return self._rules[index]; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CSSRuleList); + + pub const Meta = struct { + pub const name = "CSSRuleList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(CSSRuleList.length, null, .{}); + pub const @"[]" = bridge.indexed(CSSRuleList.item, .{ .null_as_undefined = true }); +}; diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 887a8098d..536fa7376 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -29,28 +29,6 @@ const CSSStyleDeclaration = @This(); _element: ?*Element = null, _properties: std.DoublyLinkedList = .{}, -pub const Property = struct { - _name: String, - _value: String, - _important: bool = false, - _node: std.DoublyLinkedList.Node, - - fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property { - return @alignCast(@fieldParentPtr("_node", n)); - } - - pub fn format(self: *const Property, writer: *std.Io.Writer) !void { - try self._name.format(writer); - try writer.writeAll(": "); - try self._value.format(writer); - - if (self._important) { - try writer.writeAll(" !important"); - } - try writer.writeByte(';'); - } -}; - pub fn init(element: ?*Element, page: *Page) !*CSSStyleDeclaration { return page._factory.create(CSSStyleDeclaration{ ._element = element, @@ -214,6 +192,28 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 { return std.ascii.lowerString(buf, name); } +pub const Property = struct { + _name: String, + _value: String, + _important: bool = false, + _node: std.DoublyLinkedList.Node, + + fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property { + return @alignCast(@fieldParentPtr("_node", n)); + } + + pub fn format(self: *const Property, writer: *std.Io.Writer) !void { + try self._name.format(writer); + try writer.writeAll(": "); + try self._value.format(writer); + + if (self._important) { + try writer.writeAll(" !important"); + } + try writer.writeByte(';'); + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleDeclaration); diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index f595838e1..199d12140 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -72,7 +72,9 @@ fn isKnownCSSProperty(dash_case: []const u8) bool { } fn camelCaseToDashCase(name: []const u8, buf: []u8) []const u8 { - if (name.len == 0) return name; + if (name.len == 0) { + return name; + } // Special case: cssFloat -> float const lower_name = std.ascii.lowerString(buf, name); diff --git a/src/browser/webapi/css/CSSStyleRule.zig b/src/browser/webapi/css/CSSStyleRule.zig new file mode 100644 index 000000000..c477621c7 --- /dev/null +++ b/src/browser/webapi/css/CSSStyleRule.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const CSSRule = @import("CSSRule.zig"); +const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig"); + +const CSSStyleRule = @This(); + +_proto: *CSSRule, +_selector_text: []const u8 = "", +_style: ?*CSSStyleDeclaration = null, + +pub fn init(page: *Page) !*CSSStyleRule { + const rule = try CSSRule.init(.style, page); + return page._factory.create(CSSStyleRule{ + ._proto = rule, + }); +} + +pub fn getSelectorText(self: *const CSSStyleRule) []const u8 { + return self._selector_text; +} + +pub fn setSelectorText(self: *CSSStyleRule, text: []const u8, page: *Page) !void { + self._selector_text = try page.dupeString(text); +} + +pub fn getStyle(self: *CSSStyleRule, page: *Page) !*CSSStyleDeclaration { + if (self._style) |style| { + return style; + } + const style = try CSSStyleDeclaration.init(null, page); + self._style = style; + return style; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CSSStyleRule); + + pub const Meta = struct { + pub const name = "CSSStyleRule"; + pub const prototype_chain = bridge.prototypeChain(CSSRule); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{}); + pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{}); +}; diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig new file mode 100644 index 000000000..a377618d5 --- /dev/null +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -0,0 +1,87 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const CSSRuleList = @import("CSSRuleList.zig"); +const CSSRule = @import("CSSRule.zig"); + +const CSSStyleSheet = @This(); + +_href: ?[]const u8 = null, +_title: []const u8 = "", +_disabled: bool = false, +_css_rules: ?*CSSRuleList = null, +_owner_rule: ?*CSSRule = null, + +pub fn init(page: *Page) !*CSSStyleSheet { + return page._factory.create(CSSStyleSheet{}); +} + +pub fn getOwnerNode(self: *const CSSStyleSheet) ?*CSSStyleSheet { + _ = self; + return null; +} + +pub fn getHref(self: *const CSSStyleSheet) ?[]const u8 { + return self._href; +} + +pub fn getTitle(self: *const CSSStyleSheet) []const u8 { + return self._title; +} + +pub fn getDisabled(self: *const CSSStyleSheet) bool { + return self._disabled; +} + +pub fn setDisabled(self: *CSSStyleSheet, disabled: bool) void { + self._disabled = disabled; +} + +pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList { + if (self._css_rules) |rules| return rules; + const rules = try CSSRuleList.init(page); + self._css_rules = rules; + return rules; +} + +pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule { + return self._owner_rule; +} + +pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, index: u32, page: *Page) !u32 { + _ = self; + _ = rule; + _ = index; + _ = page; + return 0; +} + +pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void { + _ = self; + _ = index; + _ = page; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CSSStyleSheet); + + pub const Meta = struct { + pub const name = "CSSStyleSheet"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const ownerNode = bridge.accessor(CSSStyleSheet.getOwnerNode, null, .{ .null_as_undefined = true }); + pub const href = bridge.accessor(CSSStyleSheet.getHref, null, .{ .null_as_undefined = true }); + pub const title = bridge.accessor(CSSStyleSheet.getTitle, null, .{}); + pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{}); + pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{}); + pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{ .null_as_undefined = true }); + pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{}); + pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CSSStyleSheet" { + try testing.htmlRunner("css/stylesheet.html", .{}); +} diff --git a/src/browser/webapi/css/StyleSheetList.zig b/src/browser/webapi/css/StyleSheetList.zig new file mode 100644 index 000000000..8a019a183 --- /dev/null +++ b/src/browser/webapi/css/StyleSheetList.zig @@ -0,0 +1,34 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const CSSStyleSheet = @import("CSSStyleSheet.zig"); + +const StyleSheetList = @This(); + +_sheets: []*CSSStyleSheet = &.{}, + +pub fn init(page: *Page) !*StyleSheetList { + return page._factory.create(StyleSheetList{}); +} + +pub fn length(self: *const StyleSheetList) u32 { + return @intCast(self._sheets.len); +} + +pub fn item(self: *const StyleSheetList, index: usize) ?*CSSStyleSheet { + if (index >= self._sheets.len) return null; + return self._sheets[index]; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(StyleSheetList); + + pub const Meta = struct { + pub const name = "StyleSheetList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(StyleSheetList.length, null, .{}); + pub const @"[]" = bridge.indexed(StyleSheetList.item, .{ .null_as_undefined = true }); +}; diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index b97f7c004..a793e7c82 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -1454,4 +1454,3 @@ test "Selector: Parser.parseNthPattern" { try testing.expectEqual(" )", parser.input); } } - diff --git a/src/cdp/domains/log.zig b/src/cdp/domains/log.zig index 66b8b79f1..2eca6847a 100644 --- a/src/cdp/domains/log.zig +++ b/src/cdp/domains/log.zig @@ -88,7 +88,7 @@ pub fn LogInterceptor(comptime BC: type) type { self.bc.cdp.sendEvent("Log.entryAdded", .{ .entry = .{ .source = switch (scope) { - .js, .console => "javascript", + .js, .console => "javascript", .http => "network", .telemetry, .unknown_prop, .interceptor => unreachable, // filtered out in writer above else => "other", diff --git a/src/html5ever/lib.rs b/src/html5ever/lib.rs index 992b00fd3..69f6b399d 100644 --- a/src/html5ever/lib.rs +++ b/src/html5ever/lib.rs @@ -184,6 +184,7 @@ pub extern "C" fn html5ever_get_memory_usage() -> Memory { // Streaming parser API // The Parser type from html5ever implements TendrilSink and supports streaming pub struct StreamingParser { + #[allow(dead_code)] arena: Box>, parser: Box, } From 35a728e69f18fe9f2c02ce71693d9828c7304d97 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 25 Nov 2025 15:54:25 +0800 Subject: [PATCH 073/257] explicitly run microtasks --- src/browser/EventManager.zig | 39 ++++++++++++++++++++++++----------- src/browser/Page.zig | 14 +++---------- src/browser/js/Context.zig | 7 +++++++ src/browser/js/js.zig | 2 ++ src/browser/webapi/Window.zig | 10 +++++---- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index aa5f023ad..e6d1ec0b3 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -107,12 +107,19 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); } + event._target = target; + var was_handled = false; + + defer if (was_handled) { + self.page.js.runMicrotasks(); + }; + switch (target._type) { - .node => |node| try self.dispatchNode(node, event), + .node => |node| try self.dispatchNode(node, event, &was_handled), .xhr, .window, .abort_signal, .media_query_list => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; - try self.dispatchAll(list, target, event); + try self.dispatchAll(list, target, event, &was_handled); }, } } @@ -135,19 +142,26 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E event._target = target; } + var was_dispatched = false; + defer if (was_dispatched) { + self.page.js.runMicrotasks(); + }; + if (function_) |func| { event._current_target = target; - func.call(void, .{event}) catch |err| { + if (func.call(void, .{event})) { + was_dispatched = true; + } else |err| { // a non-JS error log.warn(.event, opts.context, .{ .err = err }); - }; + } } const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; - try self.dispatchAll(list, target, event); + try self.dispatchAll(list, target, event, &was_dispatched); } -fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { +fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { var path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; @@ -175,7 +189,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { i -= 1; const current_target = path[i]; if (self.lookup.getPtr(@intFromPtr(current_target))) |list| { - try self.dispatchPhase(list, current_target, event, true); + try self.dispatchPhase(list, current_target, event, was_handled, true); if (event._stop_propagation) { event._event_phase = .none; return; @@ -187,7 +201,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { event._event_phase = .at_target; const target_et = target.asEventTarget(); if (self.lookup.getPtr(@intFromPtr(target_et))) |list| { - try self.dispatchPhase(list, target_et, event, null); + try self.dispatchPhase(list, target_et, event, was_handled, null); if (event._stop_propagation) { event._event_phase = .none; return; @@ -200,7 +214,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { event._event_phase = .bubbling_phase; for (path[1..]) |current_target| { if (self.lookup.getPtr(@intFromPtr(current_target))) |list| { - try self.dispatchPhase(list, current_target, event, false); + try self.dispatchPhase(list, current_target, event, was_handled, false); if (event._stop_propagation) { break; } @@ -211,7 +225,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void { event._event_phase = .none; } -fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, comptime capture_only: ?bool) !void { +fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void { const page = self.page; const typ = event._type_string; @@ -240,6 +254,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe } } + was_handled.* = true; event._current_target = current_target; switch (listener.function) { @@ -261,8 +276,8 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe } // Non-Node dispatching (XHR, Window without propagation) -fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event) !void { - return self.dispatchPhase(list, current_target, event, null); +fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void { + return self.dispatchPhase(list, current_target, event, was_handled, null); } fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b93a9f946..28c1fcac8 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -242,21 +242,13 @@ fn registerBackgroundTasks(self: *Page) !void { const Browser = @import("Browser.zig"); - try self.scheduler.add(self._session.browser, struct { - fn runMicrotasks(ctx: *anyopaque) !?u32 { - const b: *Browser = @ptrCast(@alignCast(ctx)); - b.runMicrotasks(); - return 5; - } - }.runMicrotasks, 5, .{ .name = "page.microtasks" }); - try self.scheduler.add(self._session.browser, struct { fn runMessageLoop(ctx: *anyopaque) !?u32 { const b: *Browser = @ptrCast(@alignCast(ctx)); b.runMessageLoop(); - return 100; + return 250; } - }.runMessageLoop, 5, .{ .name = "page.messageLoop" }); + }.runMessageLoop, 250, .{ .name = "page.messageLoop" }); } pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { @@ -705,10 +697,10 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { } pub fn tick(self: *Page) void { - self._session.browser.runMicrotasks(); _ = self.scheduler.run() catch |err| { log.err(.page, "tick", .{ .err = err }); }; + self.js.runMicrotasks(); } pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 8c37b47eb..fddcdfa48 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1162,6 +1162,10 @@ pub fn resolvePromise(self: *Context, value: anytype) !js.Promise { return resolver.getPromise(); } +pub fn runMicrotasks(self: *Context) void { + self.isolate.performMicrotasksCheckpoint(); +} + // creates a PersistentPromiseResolver, taking in a lifetime parameter. // If the lifetime is page, the page will clean up the PersistentPromiseResolver. // If the lifetime is self, you will be expected to deinitalize the PersistentPromiseResolver. @@ -1444,6 +1448,7 @@ fn dynamicModuleSourceCallback(ctx: *anyopaque, fetch_result_: anyerror!ScriptMa } fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, module_entry: ModuleEntry) void { + defer self.runMicrotasks(); const ctx = self.v8_context; const isolate = self.isolate; const external = v8.External.init(self.isolate, @ptrCast(state)); @@ -1479,6 +1484,7 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul return; } + defer caller.context.runMicrotasks(); const namespace = s.module.?.getModuleNamespace(); _ = s.resolver.castToPromiseResolver().resolve(caller.context.v8_context, namespace); } @@ -1494,6 +1500,7 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul if (s.context_id != caller.context.id) { return; } + defer caller.context.runMicrotasks(); _ = s.resolver.castToPromiseResolver().reject(caller.context.v8_context, info.getData()); } }.callback, external); diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 6a50576c5..71e192865 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -107,6 +107,7 @@ pub const PersistentPromiseResolver = struct { pub fn resolve(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value, .{}); + defer context.runMicrotasks(); // resolver.resolve will return null if the promise isn't pending const ok = self.resolver.castToPromiseResolver().resolve(context.v8_context, js_value) orelse return; @@ -118,6 +119,7 @@ pub const PersistentPromiseResolver = struct { pub fn reject(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value, .{}); + defer context.runMicrotasks(); // resolver.reject will return null if the promise isn't pending const ok = self.resolver.castToPromiseResolver().reject(context.v8_context, js_value) orelse return; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 1607bb79c..2ebae996c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -321,14 +321,15 @@ const ScheduleCallback = struct { fn run(ctx: *anyopaque) !?u32 { const self: *ScheduleCallback = @ptrCast(@alignCast(ctx)); + const page = self.page; if (self.removed) { - _ = self.page.window._timers.remove(self.timer_id); + _ = page.window._timers.remove(self.timer_id); self.deinit(); return null; } if (self.animation_frame) { - self.cb.call(void, .{self.page.window._performance.now()}) catch |err| { + self.cb.call(void, .{page.window._performance.now()}) catch |err| { // a non-JS error log.warn(.js, "window.RAF", .{ .name = self.name, .err = err }); }; @@ -342,9 +343,10 @@ const ScheduleCallback = struct { if (self.repeat_ms) |ms| { return ms; } + defer self.deinit(); - _ = self.page.window._timers.remove(self.timer_id); - self.deinit(); + _ = page.window._timers.remove(self.timer_id); + page.js.runMicrotasks(); return null; } }; From 6d6f1340af3553c34708c25f236565b7c82e2d5a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 25 Nov 2025 15:58:34 +0800 Subject: [PATCH 074/257] window.screen --- src/browser/js/bridge.zig | 1 + src/browser/tests/window/window.html | 10 ++++ src/browser/webapi/Screen.zig | 73 ++++++++++++++++++++++++++++ src/browser/webapi/Window.zig | 7 +++ 4 files changed, 91 insertions(+) create mode 100644 src/browser/webapi/Screen.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index d4b6b6fed..7ac4f0ad0 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -573,4 +573,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/ResizeObserver.zig"), @import("../webapi/Blob.zig"), @import("../webapi/File.zig"), + @import("../webapi/Screen.zig"), }); diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index c482ae289..c378c130c 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -93,3 +93,13 @@ } + + diff --git a/src/browser/webapi/Screen.zig b/src/browser/webapi/Screen.zig new file mode 100644 index 000000000..1ed5b1396 --- /dev/null +++ b/src/browser/webapi/Screen.zig @@ -0,0 +1,73 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const Screen = @This(); +_pad: bool = false, + +pub const init: Screen = .{}; + +/// Total width of the screen in pixels +pub fn getWidth(_: *const Screen) u32 { + return 1920; +} + +/// Total height of the screen in pixels +pub fn getHeight(_: *const Screen) u32 { + return 1080; +} + +/// Available width (excluding OS UI elements like taskbar) +pub fn getAvailWidth(_: *const Screen) u32 { + return 1920; +} + +/// Available height (excluding OS UI elements like taskbar) +pub fn getAvailHeight(_: *const Screen) u32 { + return 1040; // 40px reserved for taskbar/dock +} + +/// Color depth in bits per pixel +pub fn getColorDepth(_: *const Screen) u32 { + return 24; +} + +/// Pixel depth in bits per pixel (typically same as colorDepth) +pub fn getPixelDepth(_: *const Screen) u32 { + return 24; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Screen); + + pub const Meta = struct { + pub const name = "Screen"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + // Read-only properties + pub const width = bridge.accessor(Screen.getWidth, null, .{}); + pub const height = bridge.accessor(Screen.getHeight, null, .{}); + pub const availWidth = bridge.accessor(Screen.getAvailWidth, null, .{}); + pub const availHeight = bridge.accessor(Screen.getAvailHeight, null, .{}); + pub const colorDepth = bridge.accessor(Screen.getColorDepth, null, .{}); + pub const pixelDepth = bridge.accessor(Screen.getPixelDepth, null, .{}); +}; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 2ebae996c..503dc008f 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -26,6 +26,7 @@ const Console = @import("Console.zig"); const History = @import("History.zig"); const Crypto = @import("Crypto.zig"); const Navigator = @import("Navigator.zig"); +const Screen = @import("Screen.zig"); const Performance = @import("Performance.zig"); const Document = @import("Document.zig"); const Location = @import("Location.zig"); @@ -45,6 +46,7 @@ _document: *Document, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, +_screen: Screen = .init, _performance: Performance, _history: History, _storage_bucket: *storage.Bucket, @@ -79,6 +81,10 @@ pub fn getNavigator(self: *Window) *Navigator { return &self._navigator; } +pub fn getScreen(self: *Window) *Screen { + return &self._screen; +} + pub fn getCrypto(self: *Window) *Crypto { return &self._crypto; } @@ -366,6 +372,7 @@ pub const JsApi = struct { pub const parent = bridge.accessor(Window.getWindow, null, .{ .cache = "parent" }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = "console" }); pub const navigator = bridge.accessor(Window.getNavigator, null, .{ .cache = "navigator" }); + pub const screen = bridge.accessor(Window.getScreen, null, .{ .cache = "screen" }); pub const performance = bridge.accessor(Window.getPerformance, null, .{ .cache = "performance" }); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{ .cache = "localStorage" }); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" }); From 4a4602137b5448a9e5fce8f09d079fd8b0f0f688 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 11:46:54 +0100 Subject: [PATCH 075/257] element: add prefix and localName accessors --- .../tests/document/create_element_ns.html | 8 ++++++++ src/browser/webapi/Element.zig | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/browser/tests/document/create_element_ns.html b/src/browser/tests/document/create_element_ns.html index c14a27734..5a75c359b 100644 --- a/src/browser/tests/document/create_element_ns.html +++ b/src/browser/tests/document/create_element_ns.html @@ -28,5 +28,13 @@ const regularDiv = document.createElement('div'); testing.expectEqual('DIV', regularDiv.tagName); + testing.expectEqual('div', regularDiv.localName); + testing.expectEqual(null, regularDiv.prefix); testing.expectEqual('http://www.w3.org/1999/xhtml', regularDiv.namespaceURI); + + const custom = document.createElementNS('test', 'te:ST'); + testing.expectEqual('TE:ST', custom.tagName); // Should be te:ST + testing.expectEqual('te', custom.prefix); + testing.expectEqual('ST', custom.localName); + testing.expectEqual('http://www.w3.org/1999/xhtml', custom.namespaceURI); // Should be test diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 023da5109..81427e0ba 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -913,6 +913,26 @@ pub const JsApi = struct { return buf.written(); } + pub const prefix = bridge.accessor(_prefix, null, .{}); + fn _prefix(self: *Element) ?[]const u8 { + const name = self.getTagNameLower(); + if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { + return name[0..pos]; + } + + return null; + } + + pub const localName = bridge.accessor(_localName, null, .{}); + fn _localName(self: *Element) []const u8 { + const name = self.getTagNameLower(); + if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { + return name[pos + 1 ..]; + } + + return name; + } + pub const id = bridge.accessor(Element.getId, Element.setId, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, null, .{}); From a0fa232a3a3852bc163fbbce081784644b36aaef Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:12:00 +0100 Subject: [PATCH 076/257] element: upper case only the suffix part of the tagname --- src/browser/tests/document/create_element_ns.html | 2 +- src/browser/webapi/Element.zig | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/document/create_element_ns.html b/src/browser/tests/document/create_element_ns.html index 5a75c359b..46773ebcf 100644 --- a/src/browser/tests/document/create_element_ns.html +++ b/src/browser/tests/document/create_element_ns.html @@ -33,7 +33,7 @@ testing.expectEqual('http://www.w3.org/1999/xhtml', regularDiv.namespaceURI); const custom = document.createElementNS('test', 'te:ST'); - testing.expectEqual('TE:ST', custom.tagName); // Should be te:ST + testing.expectEqual('te:ST', custom.tagName); testing.expectEqual('te', custom.prefix); testing.expectEqual('ST', custom.localName); testing.expectEqual('http://www.w3.org/1999/xhtml', custom.namespaceURI); // Should be test diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 81427e0ba..c16084250 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -767,7 +767,15 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 { log.info(.dom, "tag.long.name", .{ .name = tag_name.str() }); return tag_name.str(); } - return std.ascii.upperString(buf, tag_name.str()); + const tag = tag_name.str(); + // If the tag_name has a prefix, we must uppercase only the suffix part. + // example: te:st should be returned as te:ST. + if (std.mem.indexOfPos(u8, tag, 0, ":")) |pos| { + @memcpy(buf[0 .. pos + 1], tag[0 .. pos + 1]); + _ = std.ascii.upperString(buf[pos..tag.len], tag[pos..tag.len]); + return buf[0..tag.len]; + } + return std.ascii.upperString(buf, tag); } pub fn getTag(self: *const Element) Tag { From be0a808f01732e069433c9a5e4575d35f0455520 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 25 Nov 2025 19:50:53 +0800 Subject: [PATCH 077/257] Add HTMLSlotElement, PerformanceObserver and Script get/set type --- src/browser/Page.zig | 22 +- src/browser/ScriptManager.zig | 57 ++- src/browser/js/bridge.zig | 2 + src/browser/tests/element/html/slot.html | 384 +++++++++++++++ .../custom_element_composition.html | 456 ++++++++++++++++++ src/browser/tests/window/window.html | 1 - src/browser/webapi/Element.zig | 4 + src/browser/webapi/Performance.zig | 66 +++ src/browser/webapi/PerformanceObserver.zig | 67 +++ src/browser/webapi/element/Html.zig | 3 + src/browser/webapi/element/html/Script.zig | 9 + src/browser/webapi/element/html/Slot.zig | 151 ++++++ 12 files changed, 1192 insertions(+), 30 deletions(-) create mode 100644 src/browser/tests/element/html/slot.html create mode 100644 src/browser/tests/integration/custom_element_composition.html create mode 100644 src/browser/webapi/PerformanceObserver.zig create mode 100644 src/browser/webapi/element/html/Slot.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 28c1fcac8..805003168 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1024,6 +1024,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ else => {}, }, 4 => switch (@as(u32, @bitCast(name[0..4].*))) { + asUint("span") => return self.createHtmlElementT( + Element.Html.Generic, + namespace, + attribute_iterator, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "span", .{}) catch unreachable, ._tag = .span }, + ), asUint("meta") => return self.createHtmlElementT( Element.Html.Meta, namespace, @@ -1036,6 +1042,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined }, ), + asUint("slot") => return self.createHtmlElementT( + Element.Html.Slot, + namespace, + attribute_iterator, + .{ ._proto = undefined }, + ), asUint("html") => return self.createHtmlElementT( Element.Html.Html, namespace, @@ -1066,12 +1078,6 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined, ._tag_name = String.init(undefined, "main", .{}) catch unreachable, ._tag = .main }, ), - asUint("span") => return self.createHtmlElementT( - Element.Html.Generic, - namespace, - attribute_iterator, - .{ ._proto = undefined, ._tag_name = String.init(undefined, "span", .{}) catch unreachable, ._tag = .span }, - ), else => {}, }, 5 => switch (@as(u40, @bitCast(name[0..5].*))) { @@ -1787,3 +1793,7 @@ const testing = @import("../testing.zig"); test "WebApi: Page" { try testing.htmlRunner("page", .{}); } + +test "WebApi: Integration" { + try testing.htmlRunner("integration", .{}); +} diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 632be5f2e..0d421db14 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -249,11 +249,14 @@ pub fn addFromElement(self: *ScriptManager, script_element: *Element.Html.Script .error_callback = Script.errorCallback, }); - log.debug(.http, "script queue", .{ - .ctx = ctx, - .url = remote_url.?, - .stack = page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .ctx = ctx, + .url = remote_url.?, + .element = element, + .stack = page.js.stackTrace() catch "???", + }); + } } if (script.mode != .normal) { @@ -326,12 +329,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const var headers = try self.client.newHeaders(); try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers); - log.debug(.http, "script queue", .{ - .url = url, - .ctx = "module", - .referrer = referrer, - .stack = self.page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .url = url, + .ctx = "module", + .referrer = referrer, + .stack = self.page.js.stackTrace() catch "???", + }); + } try self.client.request(.{ .url = url, @@ -403,12 +408,14 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C var headers = try self.client.newHeaders(); try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers); - log.debug(.http, "script queue", .{ - .url = url, - .ctx = "dynamic module", - .referrer = referrer, - .stack = self.page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .url = url, + .ctx = "dynamic module", + .referrer = referrer, + .stack = self.page.js.stackTrace() catch "???", + }); + } // It's possible, but unlikely, for client.request to immediately finish // a request, thus calling our callback. We generally don't want a call @@ -617,11 +624,13 @@ const Script = struct { return; } - log.debug(.http, "script header", .{ - .req = transfer, - .status = header.status, - .content_type = header.contentType(), - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script header", .{ + .req = transfer, + .status = header.status, + .content_type = header.contentType(), + }); + } // If this isn't true, then we'll likely leak memory. If you don't // set `CURLOPT_SUPPRESS_CONNECT_HEADERS` and CONNECT to a proxy, this @@ -649,7 +658,9 @@ const Script = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *Script = @ptrCast(@alignCast(ctx)); self.complete = true; - log.debug(.http, "script fetch complete", .{ .req = self.url }); + if (comptime IS_DEBUG) { + log.debug(.http, "script fetch complete", .{ .req = self.url }); + } const manager = self.manager; if (self.mode == .async or self.mode == .import_async) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7ac4f0ad0..b93c3ec07 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -538,6 +538,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Paragraph.zig"), @import("../webapi/element/html/Script.zig"), @import("../webapi/element/html/Select.zig"), + @import("../webapi/element/html/Slot.zig"), @import("../webapi/element/html/Style.zig"), @import("../webapi/element/html/Template.zig"), @import("../webapi/element/html/TextArea.zig"), @@ -574,4 +575,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Blob.zig"), @import("../webapi/File.zig"), @import("../webapi/Screen.zig"), + @import("../webapi/PerformanceObserver.zig"), }); diff --git a/src/browser/tests/element/html/slot.html b/src/browser/tests/element/html/slot.html new file mode 100644 index 000000000..af2b08086 --- /dev/null +++ b/src/browser/tests/element/html/slot.html @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/integration/custom_element_composition.html b/src/browser/tests/integration/custom_element_composition.html new file mode 100644 index 000000000..3559d9f8b --- /dev/null +++ b/src/browser/tests/integration/custom_element_composition.html @@ -0,0 +1,456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index c378c130c..9cd74b371 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -102,4 +102,3 @@ testing.expectEqual(24, screen.pixelDepth); testing.expectEqual(screen, window.screen); - diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 023da5109..fb9b927e9 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -148,6 +148,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .p => "p", .script => "script", .select => "select", + .slot => "slot", .style => "style", .template => "template", .text_area => "textarea", @@ -192,6 +193,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .p => "P", .script => "SCRIPT", .select => "SELECT", + .slot => "SLOT", .style => "STYLE", .template => "TEMPLATE", .text_area => "TEXTAREA", @@ -790,6 +792,7 @@ pub fn getTag(self: *const Element) Tag { .generic => |g| g._tag, .script => .script, .select => .select, + .slot => .slot, .option => .option, .template => .template, .text_area => .textarea, @@ -855,6 +858,7 @@ pub const Tag = enum { rect, script, select, + slot, span, strong, style, diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 3ac87871f..60b972a30 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -1,6 +1,13 @@ const js = @import("../js/js.zig"); const datetime = @import("../../datetime.zig"); +pub fn registerTypes() []const type { + return &.{ + Performance, + Entry, + }; +} + const Performance = @This(); _time_origin: u64, @@ -34,6 +41,65 @@ pub const JsApi = struct { pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); }; +pub const Entry = struct { + _duration: f64 = 0.0, + _entry_type: Type, + _name: []const u8, + _start_time: f64 = 0.0, + + const Type = enum { + element, + event, + first_input, + largest_contentful_paint, + layout_shift, + long_animation_frame, + longtask, + mark, + measure, + navigation, + paint, + resource, + taskattribution, + visibility_state, + }; + + pub fn getDuration(self: *const Entry) f64 { + return self._duration; + } + + pub fn getEntryType(self: *const Entry) []const u8 { + return switch (self._entry_type) { + .first_input => "first-input", + .largest_contentful_paint => "largest-contentful-paint", + .layout_shift => "layout-shift", + .long_animation_frame => "long-animation-frame", + .visibility_state => "visibility-state", + else => |t| @tagName(t), + }; + } + + pub fn getName(self: *const Entry) []const u8 { + return self._name; + } + + pub fn getStartTime(self: *const Entry) f64 { + return self._start_time; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(Entry); + + pub const Meta = struct { + pub const name = "PerformanceEntry"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const duration = bridge.accessor(Entry.getDuration, null, .{}); + pub const entryType = bridge.accessor(Entry.getEntryType, null, .{}); + }; +}; + const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig new file mode 100644 index 000000000..08bc2733a --- /dev/null +++ b/src/browser/webapi/PerformanceObserver.zig @@ -0,0 +1,67 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const Entry = @import("Performance.zig").Entry; + +// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver +const PerformanceObserver = @This(); + +pub fn init(callback: js.Function) PerformanceObserver { + _ = callback; + return .{}; +} + +const ObserverOptions = struct { + buffered: ?bool = null, + durationThreshold: ?f64 = null, + entryTypes: ?[]const []const u8 = null, + type: ?[]const u8 = null, +}; + +pub fn observe(self: *const PerformanceObserver, opts_: ?ObserverOptions) void { + _ = self; + _ = opts_; + return; +} + +pub fn disconnect(self: *PerformanceObserver) void { + _ = self; +} + +pub fn takeRecords(_: *const PerformanceObserver) []const Entry { + return &.{}; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(PerformanceObserver); + + pub const Meta = struct { + pub const name = "PerformanceObserver"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const constructor = bridge.constructor(PerformanceObserver.init, .{}); + + pub const observe = bridge.function(PerformanceObserver.observe, .{}); + pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); + pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); +}; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 475c1f210..4468b553a 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -51,6 +51,7 @@ pub const Template = @import("html/Template.zig"); pub const TextArea = @import("html/TextArea.zig"); pub const Paragraph = @import("html/Paragraph.zig"); pub const Select = @import("html/Select.zig"); +pub const Slot = @import("html/Slot.zig"); pub const Option = @import("html/Option.zig"); pub const IFrame = @import("html/IFrame.zig"); @@ -90,6 +91,7 @@ pub const Type = union(enum) { p: Paragraph, script: *Script, select: Select, + slot: Slot, style: Style, template: *Template, text_area: *TextArea, @@ -131,6 +133,7 @@ pub fn className(self: *const HtmlElement) []const u8 { .generic => "[object HTMLElement]", .script => "[object HtmlScriptElement]", .select => "[object HTMLSelectElement]", + .slot => "[object HTMLSlotElement]", .template => "[object HTMLTemplateElement]", .option => "[object HTMLOptionElement]", .text_area => "[object HtmlTextAreaElement]", diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index c12038a64..e1f559888 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -57,6 +57,14 @@ pub fn setSrc(self: *Script, src: []const u8, page: *Page) !void { } } +pub fn getType(self: *const Script) []const u8 { + return self.asConstElement().getAttributeSafe("type") orelse ""; +} + +pub fn setType(self: *Script, value: []const u8, page: *Page) !void { + return self.asElement().setAttributeSafe("type", value, page); +} + pub fn getOnLoad(self: *const Script) ?js.Function { return self._on_load; } @@ -95,6 +103,7 @@ pub const JsApi = struct { }; pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); + pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{}); pub const onerorr = bridge.accessor(Script.getOnError, Script.setOnError, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig new file mode 100644 index 000000000..1089ad565 --- /dev/null +++ b/src/browser/webapi/element/html/Slot.zig @@ -0,0 +1,151 @@ +const std = @import("std"); + +const log = @import("../../../../log.zig"); +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); +const ShadowRoot = @import("../../ShadowRoot.zig"); + +const Slot = @This(); + +_proto: *HtmlElement, + +pub fn asElement(self: *Slot) *Element { + return self._proto._proto; +} + +pub fn asConstElement(self: *const Slot) *const Element { + return self._proto._proto; +} + +pub fn asNode(self: *Slot) *Node { + return self.asElement().asNode(); +} + +pub fn getName(self: *const Slot) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Slot, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +const AssignedNodesOptions = struct { + flatten: bool = false, +}; + +pub fn assignedNodes(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Node { + const opts = opts_ orelse AssignedNodesOptions{}; + var nodes: std.ArrayList(*Node) = .empty; + try self.collectAssignedNodes(false, &nodes, opts, page); + return nodes.items; +} + +pub fn assignedElements(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Element { + const opts = opts_ orelse AssignedNodesOptions{}; + var elements: std.ArrayList(*Element) = .empty; + try self.collectAssignedNodes(true, &elements, opts, page); + return elements.items; +} + +fn CollectionType(comptime elements: bool) type { + return if (elements) *std.ArrayList(*Element) else *std.ArrayList(*Node); +} + +fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionType(elements), opts: AssignedNodesOptions, page: *Page) !void { + // Find the shadow root this slot belongs to + const shadow_root = self.findShadowRoot() orelse return; + + const slot_name = self.getName(); + const allocator = page.call_arena; + + const host = shadow_root.getHost(); + var it = host.asNode().childrenIterator(); + while (it.next()) |child| { + if (!isAssignedToSlot(child, slot_name)) { + continue; + } + + if (opts.flatten) { + if (child.is(Slot)) |child_slot| { + // Only flatten if the child slot is actually in a shadow tree + if (child_slot.findShadowRoot()) |_| { + try child_slot.collectAssignedNodes(elements, coll, opts, page); + continue; + } + // Otherwise, treat it as a regular element and fall through + } + } + + if (comptime elements) { + if (child.is(Element)) |el| { + try coll.append(allocator, el); + } + } else { + try coll.append(allocator, child); + } + } +} + +pub fn assign(self: *Slot, nodes: []const *Node) void { + // Imperative slot assignment API + // This would require storing manually assigned nodes + // For now, this is a placeholder for the API + _ = self; + _ = nodes; + + // let's see if this is ever actually used + log.warn(.not_implemented, "Slot.assign", .{ }); +} + +fn findShadowRoot(self: *Slot) ?*ShadowRoot { + // Walk up the parent chain to find the shadow root + var parent = self.asNode()._parent; + while (parent) |p| { + if (p.is(ShadowRoot)) |shadow_root| { + return shadow_root; + } + parent = p._parent; + } + return null; +} + +fn isAssignedToSlot(node: *Node, slot_name: []const u8) bool { + // Check if a node should be assigned to a slot with the given name + if (node.is(Element)) |element| { + // Get the slot attribute from the element + const node_slot = element.getAttributeSafe("slot") orelse ""; + + // Match if: + // - Both are empty (default slot) + // - They match exactly + return std.mem.eql(u8, node_slot, slot_name); + } + + // Text nodes, comments, etc. are only assigned to the default slot + // (when they have no preceding/following element siblings with slot attributes) + // For simplicity, text nodes go to default slot if slot_name is empty + return slot_name.len == 0; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Slot); + + pub const Meta = struct { + pub const name = "HTMLSlotElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const name = bridge.accessor(Slot.getName, Slot.setName, .{}); + pub const assignedNodes = bridge.function(Slot.assignedNodes, .{}); + pub const assignedElements = bridge.function(Slot.assignedElements, .{}); + pub const assign = bridge.function(Slot.assign, .{}); +}; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTMLSlotElement" { + try testing.htmlRunner("element/html/slot.html", .{}); +} From 0da87e1d5ea79fc4b00ae3f1d9c1d237474975c3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Nov 2025 12:13:13 -0800 Subject: [PATCH 078/257] add slab statistics --- src/browser/Page.zig | 5 ++ src/slab.zig | 160 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b93a9f946..b988f2eed 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -174,7 +174,12 @@ pub fn init(arena: Allocator, call_arena: Allocator, session: *Session) !*Page { pub fn deinit(self: *Page) void { if (comptime IS_DEBUG) { log.debug(.page, "page.deinit", .{ .url = self.url }); + + // Uncomment if you want slab statistics to print. + // const stats = self._factory._slab.getStats(self.arena) catch unreachable; + // stats.print() catch unreachable; } + self.js.deinit(); self._script_manager.shutdown = true; self._session.browser.http_client.abort(); diff --git a/src/slab.zig b/src/slab.zig index 52d63c825..509a18791 100644 --- a/src/slab.zig +++ b/src/slab.zig @@ -7,6 +7,11 @@ const Alignment = std.mem.Alignment; pub fn SlabAllocator(comptime slot_count: usize) type { comptime assert(std.math.isPowerOfTwo(slot_count)); + const SlabKey = struct { + size: usize, + alignment: Alignment, + }; + const Slab = struct { const Slab = @This(); const chunk_shift = std.math.log2_int(usize, slot_count); @@ -114,11 +119,45 @@ pub fn SlabAllocator(comptime slot_count: usize) type { const new_capacity = self.chunks.items.len * slot_count; try self.bitset.resize(allocator, new_capacity, true); } - }; - const SlabKey = struct { - size: usize, - alignment: Alignment, + const Stats = struct { + key: SlabKey, + item_size: usize, + chunk_count: usize, + total_slots: usize, + slots_in_use: usize, + slots_free: usize, + bytes_allocated: usize, + bytes_in_use: usize, + bytes_free: usize, + utilization_ratio: f64, + }; + + fn getStats(self: *const Slab, key: SlabKey) Stats { + const total_slots = self.bitset.bit_length; + const free_slots = self.bitset.count(); + const used_slots = total_slots - free_slots; + const bytes_allocated = self.chunks.items.len * slot_count * self.item_size; + const bytes_in_use = used_slots * self.item_size; + + const utilization_ratio = if (bytes_allocated > 0) + @as(f64, @floatFromInt(bytes_in_use)) / @as(f64, @floatFromInt(bytes_allocated)) + else + 0.0; + + return .{ + .key = key, + .item_size = self.item_size, + .chunk_count = self.chunks.items.len, + .total_slots = total_slots, + .slots_in_use = used_slots, + .slots_free = free_slots, + .bytes_allocated = bytes_allocated, + .bytes_in_use = bytes_in_use, + .bytes_free = free_slots * self.item_size, + .utilization_ratio = utilization_ratio, + }; + } }; return struct { @@ -185,6 +224,119 @@ pub fn SlabAllocator(comptime slot_count: usize) type { } } + const Stats = struct { + total_allocated_bytes: usize, + bytes_in_use: usize, + bytes_free: usize, + slab_count: usize, + total_chunks: usize, + total_slots: usize, + slots_in_use: usize, + slots_free: usize, + fragmentation_ratio: f64, + utilization_ratio: f64, + slabs: []const Slab.Stats, + + pub fn print(self: *const Stats) !void { + std.debug.print("\n", .{}); + std.debug.print("\n=== Slab Allocator Statistics ===\n", .{}); + std.debug.print("Overall Memory:\n", .{}); + std.debug.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ + self.total_allocated_bytes, + @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, + }); + std.debug.print(" In use: {} bytes ({d:.2} MB)\n", .{ + self.bytes_in_use, + @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, + }); + std.debug.print(" Free: {} bytes ({d:.2} MB)\n", .{ + self.bytes_free, + @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, + }); + + std.debug.print("\nOverall Structure:\n", .{}); + std.debug.print(" Slab Count: {}\n", .{self.slab_count}); + std.debug.print(" Total chunks: {}\n", .{self.total_chunks}); + std.debug.print(" Total slots: {}\n", .{self.total_slots}); + std.debug.print(" Slots in use: {}\n", .{self.slots_in_use}); + std.debug.print(" Slots free: {}\n", .{self.slots_free}); + + std.debug.print("\nOverall Efficiency:\n", .{}); + std.debug.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); + std.debug.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); + + if (self.slabs.len > 0) { + std.debug.print("\nPer-Slab Breakdown:\n", .{}); + std.debug.print( + " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", + .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, + ); + std.debug.print( + " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", + .{ "", "", "", "", "", "", "" }, + ); + + for (self.slabs) |slab| { + std.debug.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ + slab.key.size, + @intFromEnum(slab.key.alignment), + slab.chunk_count, + slab.total_slots, + slab.slots_in_use, + slab.bytes_allocated, + slab.utilization_ratio * 100.0, + }); + } + } + } + }; + + pub fn getStats(self: *Self, a: std.mem.Allocator) !Stats { + var slab_stats: std.ArrayList(Slab.Stats) = try .initCapacity(a, self.slabs.entries.len); + errdefer slab_stats.deinit(a); + + var stats = Stats{ + .total_allocated_bytes = 0, + .bytes_in_use = 0, + .bytes_free = 0, + .slab_count = self.slabs.count(), + .total_chunks = 0, + .total_slots = 0, + .slots_in_use = 0, + .slots_free = 0, + .fragmentation_ratio = 0.0, + .utilization_ratio = 0.0, + .slabs = &.{}, + }; + + var it = self.slabs.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + const slab = entry.value_ptr; + const slab_stat = slab.getStats(key); + + slab_stats.appendAssumeCapacity(slab_stat); + + stats.total_allocated_bytes += slab_stat.bytes_allocated; + stats.bytes_in_use += slab_stat.bytes_in_use; + stats.bytes_free += slab_stat.bytes_free; + stats.total_chunks += slab_stat.chunk_count; + stats.total_slots += slab_stat.total_slots; + stats.slots_in_use += slab_stat.slots_in_use; + stats.slots_free += slab_stat.slots_free; + } + + if (stats.total_allocated_bytes > 0) { + stats.fragmentation_ratio = @as(f64, @floatFromInt(stats.bytes_free)) / + @as(f64, @floatFromInt(stats.total_allocated_bytes)); + stats.utilization_ratio = @as(f64, @floatFromInt(stats.bytes_in_use)) / + @as(f64, @floatFromInt(stats.total_allocated_bytes)); + } + + stats.slabs = try slab_stats.toOwnedSlice(a); + return stats; + } + pub const vtable = Allocator.VTable{ .alloc = alloc, .free = free, From 058f86ec5f45371ec9a2bdce00a5ca4d30056b35 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Nov 2025 13:40:51 -0800 Subject: [PATCH 079/257] new exponential SlabAllocator --- src/browser/Factory.zig | 4 +- src/slab.zig | 689 +++++++++++++++++++++------------------- 2 files changed, 357 insertions(+), 336 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 6013a2ff6..336924b60 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -24,7 +24,7 @@ const IS_DEBUG = builtin.mode == .Debug; const log = @import("../log.zig"); const String = @import("../string.zig").String; -const SlabAllocator = @import("../slab.zig").SlabAllocator(16); +const SlabAllocator = @import("../slab.zig").SlabAllocator; const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); @@ -53,7 +53,7 @@ _slab: SlabAllocator, pub fn init(page: *Page) Factory { return .{ ._page = page, - ._slab = SlabAllocator.init(page.arena), + ._slab = SlabAllocator.init(page.arena, 128), }; } diff --git a/src/slab.zig b/src/slab.zig index 509a18791..02d10aa72 100644 --- a/src/slab.zig +++ b/src/slab.zig @@ -4,394 +4,415 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Alignment = std.mem.Alignment; -pub fn SlabAllocator(comptime slot_count: usize) type { - comptime assert(std.math.isPowerOfTwo(slot_count)); +const Slab = struct { + alignment: Alignment, + item_size: usize, + max_slot_count: usize, - const SlabKey = struct { - size: usize, - alignment: Alignment, - }; - - const Slab = struct { - const Slab = @This(); - const chunk_shift = std.math.log2_int(usize, slot_count); - const chunk_mask = slot_count - 1; + bitset: std.bit_set.DynamicBitSetUnmanaged, + chunks: std.ArrayListUnmanaged([]u8), + pub fn init( + allocator: Allocator, alignment: Alignment, item_size: usize, + max_slot_count: usize, + ) !Slab { + return .{ + .alignment = alignment, + .item_size = item_size, + .bitset = try .initFull(allocator, 0), + .chunks = .empty, + .max_slot_count = max_slot_count, + }; + } - bitset: std.bit_set.DynamicBitSetUnmanaged, - chunks: std.ArrayListUnmanaged([]u8), - - pub fn init( - allocator: Allocator, - alignment: Alignment, - item_size: usize, - ) !Slab { - return .{ - .alignment = alignment, - .item_size = item_size, - .bitset = try .initFull(allocator, 0), - .chunks = .empty, - }; + pub fn deinit(self: *Slab, allocator: Allocator) void { + self.bitset.deinit(allocator); + + for (self.chunks.items) |chunk| { + allocator.rawFree(chunk, self.alignment, @returnAddress()); } - pub fn deinit(self: *Slab, allocator: Allocator) void { - self.bitset.deinit(allocator); + self.chunks.deinit(allocator); + } - for (self.chunks.items) |chunk| { - allocator.rawFree(chunk, self.alignment, @returnAddress()); - } + inline fn calculateChunkSize(self: *Slab, chunk_index: usize) usize { + const safe_index: u6 = @intCast(@min(std.math.maxInt(u6), chunk_index)); + const exponential = @as(usize, 1) << safe_index; + return @min(exponential, self.max_slot_count); + } - self.chunks.deinit(allocator); + inline fn toBitsetIndex(self: *Slab, chunk_index: usize, slot_index: usize) usize { + var offset: usize = 0; + for (0..chunk_index) |i| { + const chunk_size = self.calculateChunkSize(i); + offset += chunk_size; } + return offset + slot_index; + } - inline fn toBitsetIndex(chunk_index: usize, slot_index: usize) usize { - return chunk_index * slot_count + slot_index; - } + inline fn toChunkAndSlotIndices(self: *Slab, bitset_index: usize) struct { usize, usize } { + var offset: usize = 0; + var chunk_index: usize = 0; - inline fn chunkIndex(bitset_index: usize) usize { - return bitset_index >> chunk_shift; - } + while (chunk_index < self.chunks.items.len) : (chunk_index += 1) { + const chunk_size = self.calculateChunkSize(chunk_index); + if (bitset_index < offset + chunk_size) { + return .{ chunk_index, bitset_index - offset }; + } - inline fn slotIndex(bitset_index: usize) usize { - return bitset_index & chunk_mask; + offset += chunk_size; } - fn alloc(self: *Slab, allocator: Allocator) ![]u8 { - if (self.bitset.findFirstSet()) |index| { - // if we have a free slot - const chunk_index = chunkIndex(index); - const slot_index = slotIndex(index); - self.bitset.unset(index); + unreachable; + } - const chunk = self.chunks.items[chunk_index]; - const offset = slot_index * self.item_size; - return chunk.ptr[offset..][0..self.item_size]; - } else { - const old_capacity = self.bitset.bit_length; + fn alloc(self: *Slab, allocator: Allocator) ![]u8 { + if (self.bitset.findFirstSet()) |index| { + const chunk_index, const slot_index = self.toChunkAndSlotIndices(index); - // if we have don't have a free slot - try self.allocateChunk(allocator); + // if we have a free slot + self.bitset.unset(index); - const first_slot_index = old_capacity; - self.bitset.unset(first_slot_index); + const chunk = self.chunks.items[chunk_index]; + const offset = slot_index * self.item_size; + return chunk.ptr[offset..][0..self.item_size]; + } else { + const old_capacity = self.bitset.bit_length; - const new_chunk = self.chunks.items[self.chunks.items.len - 1]; - return new_chunk.ptr[0..self.item_size]; - } + // if we have don't have a free slot + try self.allocateChunk(allocator); + + const first_slot_index = old_capacity; + self.bitset.unset(first_slot_index); + + const new_chunk = self.chunks.items[self.chunks.items.len - 1]; + return new_chunk.ptr[0..self.item_size]; } + } - fn free(self: *Slab, ptr: [*]u8) void { - const addr = @intFromPtr(ptr); + fn free(self: *Slab, ptr: [*]u8) void { + const addr = @intFromPtr(ptr); - for (self.chunks.items, 0..) |chunk, i| { - const chunk_start = @intFromPtr(chunk.ptr); - const chunk_end = chunk_start + (slot_count * self.item_size); + for (self.chunks.items, 0..) |chunk, i| { + const chunk_start = @intFromPtr(chunk.ptr); + const chunk_end = chunk_start + chunk.len; - if (addr >= chunk_start and addr < chunk_end) { - const offset = addr - chunk_start; - const slot_index = offset / self.item_size; + if (addr >= chunk_start and addr < chunk_end) { + const offset = addr - chunk_start; + const slot_index = offset / self.item_size; - const bitset_index = toBitsetIndex(i, slot_index); - assert(!self.bitset.isSet(bitset_index)); + const bitset_index = self.toBitsetIndex(i, slot_index); + assert(!self.bitset.isSet(bitset_index)); - self.bitset.set(bitset_index); - return; - } + self.bitset.set(bitset_index); + return; } - - unreachable; } - fn allocateChunk(self: *Slab, allocator: Allocator) !void { - const chunk_len = self.item_size * slot_count; + unreachable; + } - const chunk_ptr = allocator.rawAlloc( - chunk_len, - self.alignment, - @returnAddress(), - ) orelse return error.FailedChildAllocation; + fn allocateChunk(self: *Slab, allocator: Allocator) !void { + const next_chunk_size = self.calculateChunkSize(self.chunks.items.len); + const chunk_len = self.item_size * next_chunk_size; - const chunk = chunk_ptr[0..chunk_len]; - try self.chunks.append(allocator, chunk); + const chunk_ptr = allocator.rawAlloc( + chunk_len, + self.alignment, + @returnAddress(), + ) orelse return error.FailedChildAllocation; - const new_capacity = self.chunks.items.len * slot_count; - try self.bitset.resize(allocator, new_capacity, true); - } + const chunk = chunk_ptr[0..chunk_len]; + try self.chunks.append(allocator, chunk); - const Stats = struct { - key: SlabKey, - item_size: usize, - chunk_count: usize, - total_slots: usize, - slots_in_use: usize, - slots_free: usize, - bytes_allocated: usize, - bytes_in_use: usize, - bytes_free: usize, - utilization_ratio: f64, - }; + const new_capacity = self.bitset.bit_length + next_chunk_size; + try self.bitset.resize(allocator, new_capacity, true); + } - fn getStats(self: *const Slab, key: SlabKey) Stats { - const total_slots = self.bitset.bit_length; - const free_slots = self.bitset.count(); - const used_slots = total_slots - free_slots; - const bytes_allocated = self.chunks.items.len * slot_count * self.item_size; - const bytes_in_use = used_slots * self.item_size; - - const utilization_ratio = if (bytes_allocated > 0) - @as(f64, @floatFromInt(bytes_in_use)) / @as(f64, @floatFromInt(bytes_allocated)) - else - 0.0; - - return .{ - .key = key, - .item_size = self.item_size, - .chunk_count = self.chunks.items.len, - .total_slots = total_slots, - .slots_in_use = used_slots, - .slots_free = free_slots, - .bytes_allocated = bytes_allocated, - .bytes_in_use = bytes_in_use, - .bytes_free = free_slots * self.item_size, - .utilization_ratio = utilization_ratio, - }; - } + const Stats = struct { + key: SlabKey, + item_size: usize, + chunk_count: usize, + total_slots: usize, + slots_in_use: usize, + slots_free: usize, + bytes_allocated: usize, + bytes_in_use: usize, + bytes_free: usize, + utilization_ratio: f64, }; - return struct { - const Self = @This(); + fn getStats(self: *const Slab, key: SlabKey) Stats { + const total_slots = self.bitset.bit_length; + const free_slots = self.bitset.count(); + const used_slots = total_slots - free_slots; + const bytes_allocated = total_slots * self.item_size; + const bytes_in_use = used_slots * self.item_size; + + const utilization_ratio = if (bytes_allocated > 0) + @as(f64, @floatFromInt(bytes_in_use)) / @as(f64, @floatFromInt(bytes_allocated)) + else + 0.0; + + return .{ + .key = key, + .item_size = self.item_size, + .chunk_count = self.chunks.items.len, + .total_slots = total_slots, + .slots_in_use = used_slots, + .slots_free = free_slots, + .bytes_allocated = bytes_allocated, + .bytes_in_use = bytes_in_use, + .bytes_free = free_slots * self.item_size, + .utilization_ratio = utilization_ratio, + }; + } +}; - child_allocator: Allocator, - slabs: std.ArrayHashMapUnmanaged(SlabKey, Slab, struct { - const Context = @This(); +const SlabKey = struct { + size: usize, + alignment: Alignment, +}; - pub fn hash(_: Context, key: SlabKey) u32 { - var hasher = std.hash.Wyhash.init(0); - std.hash.autoHash(&hasher, key.size); - std.hash.autoHash(&hasher, key.alignment); - return @truncate(hasher.final()); - } +pub const SlabAllocator = struct { + const Self = @This(); - pub fn eql(_: Context, a: SlabKey, b: SlabKey, _: usize) bool { - return a.size == b.size and a.alignment == b.alignment; - } - }, false) = .empty, + child_allocator: Allocator, + max_slot_count: usize, - pub fn init(child_allocator: Allocator) Self { - return .{ - .child_allocator = child_allocator, - .slabs = .empty, - }; - } + slabs: std.ArrayHashMapUnmanaged(SlabKey, Slab, struct { + const Context = @This(); - pub fn deinit(self: *Self) void { - for (self.slabs.values()) |*slab| { - slab.deinit(self.child_allocator); - } + pub fn hash(_: Context, key: SlabKey) u32 { + var hasher = std.hash.Wyhash.init(0); + std.hash.autoHash(&hasher, key.size); + std.hash.autoHash(&hasher, key.alignment); + return @truncate(hasher.final()); + } - self.slabs.deinit(self.child_allocator); + pub fn eql(_: Context, a: SlabKey, b: SlabKey, _: usize) bool { + return a.size == b.size and a.alignment == b.alignment; } + }, false) = .empty, - pub const ResetKind = enum { - /// Free all chunks and release all memory. - clear, - /// Keep all chunks, reset trees to reuse memory. - retain_capacity, + pub fn init(child_allocator: Allocator, max_slot_count: usize) Self { + assert(std.math.isPowerOfTwo(max_slot_count)); + + return .{ + .child_allocator = child_allocator, + .slabs = .empty, + .max_slot_count = max_slot_count, }; + } - /// This clears all of the stored memory, freeing the currently used chunks. - pub fn reset(self: *Self, kind: ResetKind) void { - switch (kind) { - .clear => { - for (self.slabs.values()) |*slab| { - for (slab.chunks.items) |chunk| { - self.child_allocator.free(chunk); - } - - slab.chunks.clearAndFree(self.child_allocator); - slab.bitset.deinit(self.child_allocator); - } + pub fn deinit(self: *Self) void { + for (self.slabs.values()) |*slab| { + slab.deinit(self.child_allocator); + } - self.slabs.clearAndFree(self.child_allocator); - }, - .retain_capacity => { - for (self.slabs.values()) |*slab| { - slab.bitset.setAll(); + self.slabs.deinit(self.child_allocator); + } + + pub const ResetKind = enum { + /// Free all chunks and release all memory. + clear, + /// Keep all chunks, reset trees to reuse memory. + retain_capacity, + }; + + /// This clears all of the stored memory, freeing the currently used chunks. + pub fn reset(self: *Self, kind: ResetKind) void { + switch (kind) { + .clear => { + for (self.slabs.values()) |*slab| { + for (slab.chunks.items) |chunk| { + self.child_allocator.free(chunk); } - }, - } + + slab.chunks.clearAndFree(self.child_allocator); + slab.bitset.deinit(self.child_allocator); + } + + self.slabs.clearAndFree(self.child_allocator); + }, + .retain_capacity => { + for (self.slabs.values()) |*slab| { + slab.bitset.setAll(); + } + }, } + } - const Stats = struct { - total_allocated_bytes: usize, - bytes_in_use: usize, - bytes_free: usize, - slab_count: usize, - total_chunks: usize, - total_slots: usize, - slots_in_use: usize, - slots_free: usize, - fragmentation_ratio: f64, - utilization_ratio: f64, - slabs: []const Slab.Stats, - - pub fn print(self: *const Stats) !void { - std.debug.print("\n", .{}); - std.debug.print("\n=== Slab Allocator Statistics ===\n", .{}); - std.debug.print("Overall Memory:\n", .{}); - std.debug.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ - self.total_allocated_bytes, - @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, - }); - std.debug.print(" In use: {} bytes ({d:.2} MB)\n", .{ - self.bytes_in_use, - @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, - }); - std.debug.print(" Free: {} bytes ({d:.2} MB)\n", .{ - self.bytes_free, - @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, - }); - - std.debug.print("\nOverall Structure:\n", .{}); - std.debug.print(" Slab Count: {}\n", .{self.slab_count}); - std.debug.print(" Total chunks: {}\n", .{self.total_chunks}); - std.debug.print(" Total slots: {}\n", .{self.total_slots}); - std.debug.print(" Slots in use: {}\n", .{self.slots_in_use}); - std.debug.print(" Slots free: {}\n", .{self.slots_free}); - - std.debug.print("\nOverall Efficiency:\n", .{}); - std.debug.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); - std.debug.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); - - if (self.slabs.len > 0) { - std.debug.print("\nPer-Slab Breakdown:\n", .{}); - std.debug.print( - " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", - .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, - ); - std.debug.print( - " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", - .{ "", "", "", "", "", "", "" }, - ); - - for (self.slabs) |slab| { - std.debug.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ - slab.key.size, - @intFromEnum(slab.key.alignment), - slab.chunk_count, - slab.total_slots, - slab.slots_in_use, - slab.bytes_allocated, - slab.utilization_ratio * 100.0, - }); - } + const Stats = struct { + total_allocated_bytes: usize, + bytes_in_use: usize, + bytes_free: usize, + slab_count: usize, + total_chunks: usize, + total_slots: usize, + slots_in_use: usize, + slots_free: usize, + fragmentation_ratio: f64, + utilization_ratio: f64, + slabs: []const Slab.Stats, + + pub fn print(self: *const Stats) !void { + std.debug.print("\n", .{}); + std.debug.print("\n=== Slab Allocator Statistics ===\n", .{}); + std.debug.print("Overall Memory:\n", .{}); + std.debug.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ + self.total_allocated_bytes, + @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, + }); + std.debug.print(" In use: {} bytes ({d:.2} MB)\n", .{ + self.bytes_in_use, + @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, + }); + std.debug.print(" Free: {} bytes ({d:.2} MB)\n", .{ + self.bytes_free, + @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, + }); + + std.debug.print("\nOverall Structure:\n", .{}); + std.debug.print(" Slab Count: {}\n", .{self.slab_count}); + std.debug.print(" Total chunks: {}\n", .{self.total_chunks}); + std.debug.print(" Total slots: {}\n", .{self.total_slots}); + std.debug.print(" Slots in use: {}\n", .{self.slots_in_use}); + std.debug.print(" Slots free: {}\n", .{self.slots_free}); + + std.debug.print("\nOverall Efficiency:\n", .{}); + std.debug.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); + std.debug.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); + + if (self.slabs.len > 0) { + std.debug.print("\nPer-Slab Breakdown:\n", .{}); + std.debug.print( + " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", + .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, + ); + std.debug.print( + " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", + .{ "", "", "", "", "", "", "" }, + ); + + for (self.slabs) |slab| { + std.debug.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ + slab.key.size, + @intFromEnum(slab.key.alignment), + slab.chunk_count, + slab.total_slots, + slab.slots_in_use, + slab.bytes_allocated, + slab.utilization_ratio * 100.0, + }); } } - }; + } + }; - pub fn getStats(self: *Self, a: std.mem.Allocator) !Stats { - var slab_stats: std.ArrayList(Slab.Stats) = try .initCapacity(a, self.slabs.entries.len); - errdefer slab_stats.deinit(a); - - var stats = Stats{ - .total_allocated_bytes = 0, - .bytes_in_use = 0, - .bytes_free = 0, - .slab_count = self.slabs.count(), - .total_chunks = 0, - .total_slots = 0, - .slots_in_use = 0, - .slots_free = 0, - .fragmentation_ratio = 0.0, - .utilization_ratio = 0.0, - .slabs = &.{}, - }; - - var it = self.slabs.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - const slab = entry.value_ptr; - const slab_stat = slab.getStats(key); - - slab_stats.appendAssumeCapacity(slab_stat); - - stats.total_allocated_bytes += slab_stat.bytes_allocated; - stats.bytes_in_use += slab_stat.bytes_in_use; - stats.bytes_free += slab_stat.bytes_free; - stats.total_chunks += slab_stat.chunk_count; - stats.total_slots += slab_stat.total_slots; - stats.slots_in_use += slab_stat.slots_in_use; - stats.slots_free += slab_stat.slots_free; - } + pub fn getStats(self: *Self, a: std.mem.Allocator) !Stats { + var slab_stats: std.ArrayList(Slab.Stats) = try .initCapacity(a, self.slabs.entries.len); + errdefer slab_stats.deinit(a); + + var stats = Stats{ + .total_allocated_bytes = 0, + .bytes_in_use = 0, + .bytes_free = 0, + .slab_count = self.slabs.count(), + .total_chunks = 0, + .total_slots = 0, + .slots_in_use = 0, + .slots_free = 0, + .fragmentation_ratio = 0.0, + .utilization_ratio = 0.0, + .slabs = &.{}, + }; - if (stats.total_allocated_bytes > 0) { - stats.fragmentation_ratio = @as(f64, @floatFromInt(stats.bytes_free)) / - @as(f64, @floatFromInt(stats.total_allocated_bytes)); - stats.utilization_ratio = @as(f64, @floatFromInt(stats.bytes_in_use)) / - @as(f64, @floatFromInt(stats.total_allocated_bytes)); - } + var it = self.slabs.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + const slab = entry.value_ptr; + const slab_stat = slab.getStats(key); + + slab_stats.appendAssumeCapacity(slab_stat); + + stats.total_allocated_bytes += slab_stat.bytes_allocated; + stats.bytes_in_use += slab_stat.bytes_in_use; + stats.bytes_free += slab_stat.bytes_free; + stats.total_chunks += slab_stat.chunk_count; + stats.total_slots += slab_stat.total_slots; + stats.slots_in_use += slab_stat.slots_in_use; + stats.slots_free += slab_stat.slots_free; + } - stats.slabs = try slab_stats.toOwnedSlice(a); - return stats; + if (stats.total_allocated_bytes > 0) { + stats.fragmentation_ratio = @as(f64, @floatFromInt(stats.bytes_free)) / + @as(f64, @floatFromInt(stats.total_allocated_bytes)); + stats.utilization_ratio = @as(f64, @floatFromInt(stats.bytes_in_use)) / + @as(f64, @floatFromInt(stats.total_allocated_bytes)); } - pub const vtable = Allocator.VTable{ - .alloc = alloc, - .free = free, - .remap = Allocator.noRemap, - .resize = Allocator.noResize, + stats.slabs = try slab_stats.toOwnedSlice(a); + return stats; + } + + pub const vtable = Allocator.VTable{ + .alloc = alloc, + .free = free, + .remap = Allocator.noRemap, + .resize = Allocator.noResize, + }; + + pub fn allocator(self: *Self) Allocator { + return .{ + .ptr = self, + .vtable = &vtable, }; + } - pub fn allocator(self: *Self) Allocator { - return .{ - .ptr = self, - .vtable = &vtable, - }; - } + fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { + const self: *Self = @ptrCast(@alignCast(ctx)); + _ = ret_addr; - fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { - const self: *Self = @ptrCast(@alignCast(ctx)); - _ = ret_addr; + const list_gop = self.slabs.getOrPut( + self.child_allocator, + SlabKey{ .size = len, .alignment = alignment }, + ) catch return null; - const list_gop = self.slabs.getOrPut( + if (!list_gop.found_existing) { + list_gop.value_ptr.* = Slab.init( self.child_allocator, - SlabKey{ .size = len, .alignment = alignment }, + alignment, + len, + self.max_slot_count, ) catch return null; - - if (!list_gop.found_existing) { - list_gop.value_ptr.* = Slab.init( - self.child_allocator, - alignment, - len, - ) catch return null; - } - - const list = list_gop.value_ptr; - const buf = list.alloc(self.child_allocator) catch return null; - return buf.ptr; } - fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { - const self: *Self = @ptrCast(@alignCast(ctx)); - _ = ret_addr; + const list = list_gop.value_ptr; + const buf = list.alloc(self.child_allocator) catch return null; + return buf.ptr; + } - const ptr = memory.ptr; - const len = memory.len; + fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + _ = ret_addr; - const list = self.slabs.getPtr(.{ .size = len, .alignment = alignment }).?; - list.free(ptr); - } - }; -} + const ptr = memory.ptr; + const len = memory.len; + + const list = self.slabs.getPtr(.{ .size = len, .alignment = alignment }).?; + list.free(ptr); + } +}; const testing = std.testing; -const TestSlabAllocator = SlabAllocator(32); +const TestSlabAllocator = SlabAllocator; test "slab allocator - basic allocation and free" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -409,7 +430,7 @@ test "slab allocator - basic allocation and free" { } test "slab allocator - multiple allocations" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -432,7 +453,7 @@ test "slab allocator - multiple allocations" { } test "slab allocator - no coalescing (different size classes)" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -459,7 +480,7 @@ test "slab allocator - no coalescing (different size classes)" { } test "slab allocator - reuse freed memory" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -477,7 +498,7 @@ test "slab allocator - reuse freed memory" { } test "slab allocator - multiple size classes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -501,7 +522,7 @@ test "slab allocator - multiple size classes" { } test "slab allocator - various sizes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -518,7 +539,7 @@ test "slab allocator - various sizes" { } test "slab allocator - exact sizes (no rounding)" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -539,7 +560,7 @@ test "slab allocator - exact sizes (no rounding)" { } test "slab allocator - chunk allocation" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -561,7 +582,7 @@ test "slab allocator - chunk allocation" { } test "slab allocator - reset with retain_capacity" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -588,7 +609,7 @@ test "slab allocator - reset with retain_capacity" { } test "slab allocator - reset with clear" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -610,7 +631,7 @@ test "slab allocator - reset with clear" { } test "slab allocator - stress test" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -647,7 +668,7 @@ test "slab allocator - stress test" { } test "slab allocator - alignment" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -662,7 +683,7 @@ test "slab allocator - alignment" { } test "slab allocator - no resize support" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -678,7 +699,7 @@ test "slab allocator - no resize support" { } test "slab allocator - fragmentation pattern" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -730,7 +751,7 @@ test "slab allocator - fragmentation pattern" { } test "slab allocator - many small allocations" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -752,11 +773,11 @@ test "slab allocator - many small allocations" { // Should have created multiple chunks const slab = slab_alloc.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; - try testing.expect(slab.chunks.items.len > 10); + try testing.expect(slab.chunks.items.len > 1); } test "slab allocator - zero waste for exact sizes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); @@ -776,7 +797,7 @@ test "slab allocator - zero waste for exact sizes" { } test "slab allocator - different size classes don't interfere" { - var slab_alloc = TestSlabAllocator.init(testing.allocator); + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); From e1d9732a6008c8bb5e4181695951a18ff1616f2f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 26 Nov 2025 07:42:19 +0800 Subject: [PATCH 080/257] PerformanceObserver.supportedEntryTypes --- build.zig.zon | 6 ++--- src/browser/js/Env.zig | 26 +++++++++++++----- src/browser/js/bridge.zig | 31 ++++++++++------------ src/browser/webapi/PerformanceObserver.zig | 5 ++++ 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 682f823cf..6d3b20617 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,9 +5,9 @@ .fingerprint = 0xda130f3af836cea0, .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/beb187f3337a8c458e1917dc0105003fb7ae1b2f.tar.gz", - .hash = "v8-0.0.0-xddH6x_gAwAgDtdWGHjv52NsW07MQnfpUQDpZn7RR43Y", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0d19781ccec829640e4f07591cbc166fa7dbe139.tar.gz", + .hash = "v8-0.0.0-xddH6wTgAwALFCYoZbUIqtsRyP6mr69N7aKT_cySHKN2", }, - // .v8 = .{ .path = "../zig-v8-fork" } + //.v8 = .{ .path = "../zig-v8-fork" } }, } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 973ef43eb..3dc59ab54 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -196,9 +196,15 @@ fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void { const context = Context.fromIsolate(isolate); const value = - if (msg.getValue()) |v8_value| context.valueToString(v8_value, .{}) catch |err| @errorName(err) else "no value"; - - log.debug(.js, "unhandled rejection", .{ .value = value }); + if (msg.getValue()) |v8_value| + context.valueToString(v8_value, .{}) catch |err| @errorName(err) + else "no value" + ; + + log.debug(.js, "unhandled rejection", .{ + .value = value, + .stack = context.stackTrace() catch |err| @errorName(err) orelse "???" + }); } // Give it a Zig struct, get back a v8.FunctionTemplate. @@ -232,8 +238,13 @@ pub fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.Funct const js_name = v8.String.initUtf8(isolate, name).toName(); const getter_callback = v8.FunctionTemplate.initCallback(isolate, value.getter); if (value.setter == null) { - template_proto.setAccessorGetter(js_name, getter_callback); + if (value.static) { + template.setAccessorGetter(js_name, getter_callback); + } else { + template_proto.setAccessorGetter(js_name, getter_callback); + } } else { + std.debug.assert(value.static == false); const setter_callback = v8.FunctionTemplate.initCallback(isolate, value.setter); template_proto.setAccessorGetterAndSetter(js_name, getter_callback, setter_callback); } @@ -265,8 +276,11 @@ pub fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.Funct const js_name = v8.Symbol.getIterator(isolate).toName(); template_proto.set(js_name, function_template, v8.PropertyAttribute.None); }, - bridge.Property.Int => { - const js_value = js.simpleZigValueToJs(isolate, value.int, true, false); + bridge.Property => { + const js_value = switch (value) { + .int => |v| js.simpleZigValueToJs(isolate, v, true, false), + }; + const js_name = v8.String.initUtf8(isolate, name).toName(); // apply it both to the type itself template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index b93c3ec07..63aef20a2 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -57,8 +57,12 @@ pub fn Builder(comptime T: type) type { return Callable.init(T, func, opts); } - pub fn property(value: anytype) Property.GetType(@TypeOf(value)) { - return Property.GetType(@TypeOf(value)).init(value); + pub fn property(value: anytype) Property { + switch (@typeInfo(@TypeOf(value))) { + .comptime_int, .int => return .{.int = value}, + else => {}, + } + @compileError("Property for " ++ @typeName(@TypeOf(value)) ++ " hasn't been defined yet"); } pub fn prototypeChain() [prototypeChainLength(T)]js.PrototypeChainEntry { @@ -146,17 +150,22 @@ pub const Function = struct { }; pub const Accessor = struct { + static: bool = false, getter: ?*const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void = null, setter: ?*const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void = null, const Opts = struct { + static: bool = false, cache: ?[]const u8 = null, // @ZIGDOM as_typed_array: bool = false, null_as_undefined: bool = false, }; fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Opts) Accessor { - var accessor = Accessor{}; + var accessor = Accessor{ + .static = opts.static, + }; + if (@typeInfo(@TypeOf(getter)) != .null) { accessor.getter = struct { fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void { @@ -321,20 +330,8 @@ pub const Callable = struct { } }; -pub const Property = struct { - fn GetType(comptime T: type) type { - switch (@typeInfo(T)) { - .comptime_int, .int => return Int, - else => @compileError("Property for " ++ @typeName(T) ++ " hasn't been defined yet"), - } - } - - pub const Int = struct { - int: i64, - pub fn init(value: i64) Int { - return .{ .int = value }; - } - }; +pub const Property = union(enum) { + int: i64, }; // Given a Type, returns the length of the prototype chain, including self diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 08bc2733a..68eafe015 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -49,6 +49,10 @@ pub fn takeRecords(_: *const PerformanceObserver) []const Entry { return &.{}; } +pub fn getSupportedEntryTypes(_: *const PerformanceObserver) [][]const u8 { + return &.{}; +} + pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceObserver); @@ -64,4 +68,5 @@ pub const JsApi = struct { pub const observe = bridge.function(PerformanceObserver.observe, .{}); pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); + pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{.static = true}); }; From 71af78caea6a8cf8665c0ec1c7004201b0ba3b98 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 26 Nov 2025 07:46:24 +0800 Subject: [PATCH 081/257] adoptNode and importNode --- src/browser/tests/document/adopt_import.html | 217 +++++++++++++++++++ src/browser/webapi/Document.zig | 23 ++ 2 files changed, 240 insertions(+) create mode 100644 src/browser/tests/document/adopt_import.html diff --git a/src/browser/tests/document/adopt_import.html b/src/browser/tests/document/adopt_import.html new file mode 100644 index 000000000..32f640b63 --- /dev/null +++ b/src/browser/tests/document/adopt_import.html @@ -0,0 +1,217 @@ + + +
+

+ Child 1 + Child 2 +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 05223fdec..2643e26c0 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -236,6 +236,26 @@ pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList { return sheets; } +pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node { + if (node._type == .document) { + return error.NotSupported; + } + + if (node._parent) |parent| { + page.removeNode(parent, node, .{ .will_be_reconnected = false }); + } + + return node; +} + +pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !*Node { + if (node._type == .document) { + return error.NotSupported; + } + + return node.cloneNode(deep_, page); +} + const ReadyState = enum { loading, interactive, @@ -278,6 +298,9 @@ pub const JsApi = struct { pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{}); + pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); + pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); + pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { return page.window; From 23e3a1d0125fcb004eb965e7984d8d9595d94bff Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 25 Nov 2025 12:42:43 +0300 Subject: [PATCH 082/257] move `html5ever/` under `vendor/` --- {src => vendor}/html5ever/Cargo.lock | 0 {src => vendor}/html5ever/Cargo.toml | 0 {src => vendor}/html5ever/lib.rs | 0 {src => vendor}/html5ever/sink.rs | 0 {src => vendor}/html5ever/types.rs | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename {src => vendor}/html5ever/Cargo.lock (100%) rename {src => vendor}/html5ever/Cargo.toml (100%) rename {src => vendor}/html5ever/lib.rs (100%) rename {src => vendor}/html5ever/sink.rs (100%) rename {src => vendor}/html5ever/types.rs (100%) diff --git a/src/html5ever/Cargo.lock b/vendor/html5ever/Cargo.lock similarity index 100% rename from src/html5ever/Cargo.lock rename to vendor/html5ever/Cargo.lock diff --git a/src/html5ever/Cargo.toml b/vendor/html5ever/Cargo.toml similarity index 100% rename from src/html5ever/Cargo.toml rename to vendor/html5ever/Cargo.toml diff --git a/src/html5ever/lib.rs b/vendor/html5ever/lib.rs similarity index 100% rename from src/html5ever/lib.rs rename to vendor/html5ever/lib.rs diff --git a/src/html5ever/sink.rs b/vendor/html5ever/sink.rs similarity index 100% rename from src/html5ever/sink.rs rename to vendor/html5ever/sink.rs diff --git a/src/html5ever/types.rs b/vendor/html5ever/types.rs similarity index 100% rename from src/html5ever/types.rs rename to vendor/html5ever/types.rs From 6280232e919540d574ad68cbc0e5278a95c3e2ec Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 25 Nov 2025 12:43:52 +0300 Subject: [PATCH 083/257] add a build step for `html5ever` in `build.zig` --- .gitignore | 1 - Makefile | 11 ++--------- build.zig | 43 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 9a7968b9a..9accc0618 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ lightpanda.id /v8/ /build/ -src/html5ever/target/ diff --git a/Makefile b/Makefile index 957705e2b..7208b9ee9 100644 --- a/Makefile +++ b/Makefile @@ -127,20 +127,13 @@ build-v8: # Install and build required dependencies commands # ------------ -.PHONY: install-html5ever install-html5ever-dev .PHONY: install install-dev ## Install and build dependencies for release -install: install-submodule install-html5ever +install: install-submodule ## Install and build dependencies for dev -install-dev: install-submodule install-html5ever-dev - -install-html5ever: - cd src/html5ever && cargo build --release --target-dir ../../build/html5ever/ - -install-html5ever-dev: - cd src/html5ever && cargo build --target-dir ../../build/html5ever/ +install-dev: install-submodule data: cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig diff --git a/build.zig b/build.zig index 704203d2a..b1ef11667 100644 --- a/build.zig +++ b/build.zig @@ -39,6 +39,9 @@ pub fn build(b: *Build) !void { }, } + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + var opts = b.addOptions(); opts.addOption( []const u8, @@ -46,8 +49,30 @@ pub fn build(b: *Build) !void { b.option([]const u8, "git_commit", "Current git commit") orelse "dev", ); - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); + // Build step to install html5ever dependency. + const html5ever_argv = blk: { + const argv: []const []const u8 = &.{ + "cargo", + "build", + // Seems cargo can figure out required paths out of Cargo.toml. + "--manifest-path", + "vendor/html5ever/Cargo.toml", + // TODO: We can prefer `--artifact-dir` once it become stable. + "--target-dir", + b.getInstallPath(.prefix, "html5ever"), + // This must be the last argument. + "--release", + }; + + break :blk switch (optimize) { + // Consider these as dev builds. + .Debug, .ReleaseSafe => argv[0 .. argv.len - 1], + .ReleaseFast, .ReleaseSmall => argv, + }; + }; + const html5ever_exec_cargo = b.addSystemCommand(html5ever_argv); + const html5ever_step = b.step("html5ever", "Install html5ever dependency (requires cargo)"); + html5ever_step.dependOn(&html5ever_exec_cargo.step); const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer"); const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers"); @@ -65,16 +90,16 @@ pub fn build(b: *Build) !void { try addDependencies(b, mod, opts); - if (optimize == .ReleaseFast or optimize == .ReleaseSmall) { - mod.addLibraryPath(b.path("build/html5ever/release")); - } else { - mod.addLibraryPath(b.path("build/html5ever/debug")); - } - mod.linkSystemLibrary("litefetch_html5ever", .{}); - break :blk mod; }; + const html5ever_obj = switch (optimize) { + .Debug, .ReleaseSafe => b.getInstallPath(.prefix, "html5ever/debug/liblitefetch_html5ever.a"), + .ReleaseFast, .ReleaseSmall => b.getInstallPath(.prefix, "html5ever/release/liblitefetch_html5ever.a"), + }; + + lightpanda_module.addObjectFile(.{ .cwd_relative = html5ever_obj }); + { // browser const exe = b.addExecutable(.{ From 444ae001299f663a5eddee4db92110f98af932b1 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 26 Nov 2025 14:27:28 +0300 Subject: [PATCH 084/257] mv `vendor/html5ever` `src/html5ever` --- build.zig | 2 +- {vendor => src}/html5ever/Cargo.lock | 0 {vendor => src}/html5ever/Cargo.toml | 0 {vendor => src}/html5ever/lib.rs | 0 {vendor => src}/html5ever/sink.rs | 0 {vendor => src}/html5ever/types.rs | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename {vendor => src}/html5ever/Cargo.lock (100%) rename {vendor => src}/html5ever/Cargo.toml (100%) rename {vendor => src}/html5ever/lib.rs (100%) rename {vendor => src}/html5ever/sink.rs (100%) rename {vendor => src}/html5ever/types.rs (100%) diff --git a/build.zig b/build.zig index b1ef11667..3632f98db 100644 --- a/build.zig +++ b/build.zig @@ -56,7 +56,7 @@ pub fn build(b: *Build) !void { "build", // Seems cargo can figure out required paths out of Cargo.toml. "--manifest-path", - "vendor/html5ever/Cargo.toml", + "src/html5ever/Cargo.toml", // TODO: We can prefer `--artifact-dir` once it become stable. "--target-dir", b.getInstallPath(.prefix, "html5ever"), diff --git a/vendor/html5ever/Cargo.lock b/src/html5ever/Cargo.lock similarity index 100% rename from vendor/html5ever/Cargo.lock rename to src/html5ever/Cargo.lock diff --git a/vendor/html5ever/Cargo.toml b/src/html5ever/Cargo.toml similarity index 100% rename from vendor/html5ever/Cargo.toml rename to src/html5ever/Cargo.toml diff --git a/vendor/html5ever/lib.rs b/src/html5ever/lib.rs similarity index 100% rename from vendor/html5ever/lib.rs rename to src/html5ever/lib.rs diff --git a/vendor/html5ever/sink.rs b/src/html5ever/sink.rs similarity index 100% rename from vendor/html5ever/sink.rs rename to src/html5ever/sink.rs diff --git a/vendor/html5ever/types.rs b/src/html5ever/types.rs similarity index 100% rename from vendor/html5ever/types.rs rename to src/html5ever/types.rs From d23eacbd373b0e634c8596f68c2b07f8ed7f493e Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 26 Nov 2025 14:28:18 +0300 Subject: [PATCH 085/257] update `.gitignore` LSPs seem to generate the `target` directory when navigating these files through editor. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9accc0618..59d6886ca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ lightpanda.id /v8/ /build/ +/src/html5ever/target/ From 67f63a6bb325dd96bf3fed60a6acf284fb3e7b52 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 26 Nov 2025 19:43:08 +0800 Subject: [PATCH 086/257] improve parsed (i.e. static) custom element callbacks --- src/browser/Page.zig | 25 ++- src/browser/dump.zig | 54 ++++++- src/browser/js/Function.zig | 2 +- .../tests/custom_elements/connected.html | 1 + .../connected_from_parser.html | 122 ++++++++++++++ src/browser/tests/shadowroot/dump.html | 151 ++++++++++++++++++ .../tests/shadowroot/innerHTML_spec.html | 84 ++++++++++ src/browser/webapi/CustomElementRegistry.zig | 1 - src/browser/webapi/DocumentFragment.zig | 6 +- src/browser/webapi/Element.zig | 12 +- src/browser/webapi/element/html/Custom.zig | 72 ++++++--- 11 files changed, 486 insertions(+), 44 deletions(-) create mode 100644 src/browser/tests/custom_elements/connected_from_parser.html create mode 100644 src/browser/tests/shadowroot/dump.html create mode 100644 src/browser/tests/shadowroot/innerHTML_spec.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 805003168..b84e297e7 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1216,6 +1216,22 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ return node; }; + + // After constructor runs, invoke attributeChangedCallback for initial attributes + const element = node.as(Element); + if (element._attributes) |attributes| { + var it = attributes.iterator(); + while (it.next()) |attr| { + Element.Html.Custom.invokeAttributeChangedCallbackOnElement( + element, + attr._name.str(), + null, // old_value is null for initial attributes + attr._value.str(), + self, + ); + } + } + return node; } @@ -1485,6 +1501,13 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod if (el.getAttributeSafe("id")) |id| { try self.addElementId(parent, el, id); } + + // Invoke connectedCallback for custom elements during parsing + // For main document parsing, we know nodes are connected (fast path) + // For fragment parsing (innerHTML), we need to check connectivity + if (self._parse_mode == .document or child.isConnected()) { + try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self); + } } return; } @@ -1518,7 +1541,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } if (should_invoke_connected) { - Element.Html.Custom.invokeConnectedCallbackOnElement(el, self); + try Element.Html.Custom.invokeConnectedCallbackOnElement(false, el, self); } } } diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 0617b4880..73ebe42b9 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); +const Slot = @import("webapi/element/html/Slot.zig"); pub const RootOpts = struct { with_base: bool = false, @@ -27,11 +28,24 @@ pub const RootOpts = struct { pub const Opts = struct { strip: Strip = .{}, + shadow: Shadow = .rendered, + pub const Strip = struct { js: bool = false, ui: bool = false, css: bool = false, }; + + pub const Shadow = enum { + // Skip shadow DOM entirely (innerHTML/outerHTML) + skip, + + // Dump everyhting (like "view source") + complete, + + // Resolve slot elements (like what actually gets rendered) + rendered, + }; }; pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { @@ -45,10 +59,10 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { } } - return deep(doc.asNode(), .{ .strip = opts.strip }, writer); + return deep(doc.asNode(), .{ .strip = opts.strip }, writer, page); } -pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}!void { +pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { switch (node._type) { .cdata => |cd| try writer.writeAll(cd.getData()), .element => |el| { @@ -56,25 +70,39 @@ pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}! return; } + // Handle elements in rendered mode + if (opts.shadow == .rendered) { + if (el.is(Slot)) |slot| { + return dumpSlotContent(slot, opts, writer, page); + } + } + try el.format(writer); - try children(node, opts, writer); + + if (opts.shadow != .skip) { + if (page._element_shadow_roots.get(el)) |shadow| { + try children(shadow.asNode(), opts, writer, page); + } + } + + try children(node, opts, writer, page); if (!isVoidElement(el)) { try writer.writeAll("'); } }, - .document => try children(node, opts, writer), + .document => try children(node, opts, writer, page), .document_type => {}, - .document_fragment => try children(node, opts, writer), + .document_fragment => try children(node, opts, writer, page), .attribute => unreachable, } } -pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer) !void { +pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { var it = parent.childrenIterator(); while (it.next()) |child| { - try deep(child, opts, writer); + try deep(child, opts, writer, page); } } @@ -118,6 +146,18 @@ pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void { try writer.endObject(); } +fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + const assigned = slot.assignedNodes(null, page) catch return; + + if (assigned.len > 0) { + for (assigned) |assigned_node| { + try deep(assigned_node, opts, writer, page); + } + } else { + try children(slot.asNode(), opts, writer, page); + } +} + fn isVoidElement(el: *const Node.Element) bool { return switch (el._type) { .html => |html| switch (html._type) { diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 41d8fa2ca..4ab5be8a5 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -144,7 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args const result = self.func.castToFunction().call(context.v8_context, js_this, js_args); if (result == null) { - std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); + // std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); return error.JSExecCallback; } diff --git a/src/browser/tests/custom_elements/connected.html b/src/browser/tests/custom_elements/connected.html index c126fab63..4b0abff9c 100644 --- a/src/browser/tests/custom_elements/connected.html +++ b/src/browser/tests/custom_elements/connected.html @@ -91,3 +91,4 @@ testing.expectEqual(1, connectedCount); } + diff --git a/src/browser/tests/custom_elements/connected_from_parser.html b/src/browser/tests/custom_elements/connected_from_parser.html new file mode 100644 index 000000000..770c309b5 --- /dev/null +++ b/src/browser/tests/custom_elements/connected_from_parser.html @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/dump.html b/src/browser/tests/shadowroot/dump.html new file mode 100644 index 000000000..57544393c --- /dev/null +++ b/src/browser/tests/shadowroot/dump.html @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/innerHTML_spec.html b/src/browser/tests/shadowroot/innerHTML_spec.html new file mode 100644 index 000000000..029f0e7af --- /dev/null +++ b/src/browser/tests/shadowroot/innerHTML_spec.html @@ -0,0 +1,84 @@ + + + + + + + + + diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 361aced55..9c2951701 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -84,7 +84,6 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu var idx: usize = 0; while (idx < page._undefined_custom_elements.items.len) { const custom = page._undefined_custom_elements.items[idx]; - if (!custom._tag_name.eqlSlice(name)) { idx += 1; continue; diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index f7b20878c..6c712f556 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -160,9 +160,9 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, } } -pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer) !void { +pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.children(self.asNode(), .{}, writer); + return dump.children(self.asNode(), .{ .shadow = .complete }, writer, page); } pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, page: *Page) !void { @@ -224,7 +224,7 @@ pub const JsApi = struct { fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getInnerHTML(&buf.writer); + try self.getInnerHTML(&buf.writer, page); return buf.written(); } }; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index fb9b927e9..f73e5374e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -227,14 +227,14 @@ pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { } } -pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer) !void { +pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.deep(self.asNode(), .{}, writer); + return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page); } -pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer) !void { +pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.children(self.asNode(), .{}, writer); + return dump.children(self.asNode(), .{ .shadow = .skip }, writer, page); } pub fn setInnerHTML(self: *Element, html: []const u8, page: *Page) !void { @@ -906,14 +906,14 @@ pub const JsApi = struct { pub const outerHTML = bridge.accessor(_outerHTML, null, .{}); fn _outerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getOuterHTML(&buf.writer); + try self.getOuterHTML(&buf.writer, page); return buf.written(); } pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{}); fn _innerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getInnerHTML(&buf.writer); + try self.getInnerHTML(&buf.writer, page); return buf.written(); } diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 50e8518eb..a8c95d5c8 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -53,7 +53,9 @@ pub fn invokeConnectedCallback(self: *Custom, page: *Page) void { pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { // Only invoke if we haven't already called it while disconnected - if (self._disconnected_callback_invoked) return; + if (self._disconnected_callback_invoked) { + return; + } self._disconnected_callback_invoked = true; self._connected_callback_invoked = false; @@ -62,30 +64,49 @@ pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { pub fn invokeAttributeChangedCallback(self: *Custom, name: []const u8, old_value: ?[]const u8, new_value: ?[]const u8, page: *Page) void { const definition = self._definition orelse return; - if (!definition.isAttributeObserved(name)) return; + if (!definition.isAttributeObserved(name)) { + return; + } self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value }, page); } -// Static helpers that work on any Element (autonomous or customized built-in) -pub fn invokeConnectedCallbackOnElement(element: *Element, page: *Page) void { +pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, page: *Page) !void { // Autonomous custom element if (element.is(Custom)) |custom| { - custom.invokeConnectedCallback(page); + if (comptime from_parser) { + // From parser, we know the element is brand new + custom._connected_callback_invoked = true; + custom.invokeCallback("connectedCallback", .{}, page); + } else { + custom.invokeConnectedCallback(page); + } return; } - // Customized built-in element - // Check if we've already invoked connectedCallback while connected - if (page._customized_builtin_connected_callback_invoked.contains(element)) return; + // Customized built-in element - check if it actually has a definition first + const definition = page.getCustomizedBuiltInDefinition(element) orelse return; - page._customized_builtin_connected_callback_invoked.put( - page.arena, - element, - {}, - ) catch return; - _ = page._customized_builtin_disconnected_callback_invoked.remove(element); + if (comptime from_parser) { + // From parser, we know the element is brand new, skip the tracking check + try page._customized_builtin_connected_callback_invoked.put( + page.arena, + element, + {}, + ); + } else { + // Not from parser, check if we've already invoked while connected + const gop = try page._customized_builtin_connected_callback_invoked.getOrPut( + page.arena, + element, + ); + if (gop.found_existing) { + return; + } + gop.value_ptr.* = {}; + } - invokeCallbackOnElement(element, "connectedCallback", .{}, page); + _ = page._customized_builtin_disconnected_callback_invoked.remove(element); + invokeCallbackOnElement(element, definition, "connectedCallback", .{}, page); } pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void { @@ -95,18 +116,20 @@ pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void return; } - // Customized built-in element - // Check if we've already invoked disconnectedCallback while disconnected - if (page._customized_builtin_disconnected_callback_invoked.contains(element)) return; + // Customized built-in element - check if it actually has a definition first + const definition = page.getCustomizedBuiltInDefinition(element) orelse return; - page._customized_builtin_disconnected_callback_invoked.put( + // Check if we've already invoked disconnectedCallback while disconnected + const gop = page._customized_builtin_disconnected_callback_invoked.getOrPut( page.arena, element, - {}, ) catch return; + if (gop.found_existing) return; + gop.value_ptr.* = {}; + _ = page._customized_builtin_connected_callback_invoked.remove(element); - invokeCallbackOnElement(element, "disconnectedCallback", .{}, page); + invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, page); } pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: []const u8, old_value: ?[]const u8, new_value: ?[]const u8, page: *Page) void { @@ -119,12 +142,11 @@ pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: []const // Customized built-in element - check if attribute is observed const definition = page.getCustomizedBuiltInDefinition(element) orelse return; if (!definition.isAttributeObserved(name)) return; - invokeCallbackOnElement(element, "attributeChangedCallback", .{ name, old_value, new_value }, page); + invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value }, page); } -fn invokeCallbackOnElement(element: *Element, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { - // Check if this element has a customized built-in definition - _ = page.getCustomizedBuiltInDefinition(element) orelse return; +fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { + _ = definition; const context = page.js; From 63f489d39fb033ff4406eea4e1741ee1ae74a329 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 09:13:58 -0800 Subject: [PATCH 087/257] initial with full chain allocations --- src/browser/Factory.zig | 356 ++++++++++-------- src/browser/Page.zig | 32 +- src/browser/webapi/element/Html.zig | 44 +-- .../webapi/net/XMLHttpRequestEventTarget.zig | 2 +- 4 files changed, 230 insertions(+), 204 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 336924b60..26b30052a 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const assert = std.debug.assert; const builtin = @import("builtin"); const reflect = @import("reflect.zig"); const IS_DEBUG = builtin.mode == .Debug; @@ -35,21 +36,113 @@ const EventTarget = @import("webapi/EventTarget.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); const Blob = @import("webapi/Blob.zig"); -const MemoryPoolAligned = std.heap.MemoryPoolAligned; - -// 1. Generally, wrapping an ArenaAllocator within an ArenaAllocator doesn't make -// much sense. But wrapping a MemoryPool within an Arena does. Specifically, by -// doing so, we solve a major issue with Arena: freed memory can be re-used [for -// more of the same size]. -// 2. Normally, you have a MemoryPool(T) where T is a `User` or something. Then -// the MemoryPool can be used for creating users. But in reality, that memory -// created by that pool could be re-used for anything with the same size (or less) -// than a User (and a compatible alignment). So that's what we do - we have size -// (and alignment) based pools. const Factory = @This(); _page: *Page, _slab: SlabAllocator, +fn PrototypeChain(comptime types: []const type) type { + return struct { + const Self = @This(); + memory: []u8, + + fn totalSize() usize { + var size: usize = 0; + for (types) |T| { + size = std.mem.alignForward(usize, size, @alignOf(T)); + size += @sizeOf(T); + } + return size; + } + + fn maxAlign() std.mem.Alignment { + var alignment: std.mem.Alignment = .@"1"; + + for (types) |T| { + alignment = std.mem.Alignment.max(alignment, std.mem.Alignment.of(T)); + } + + return alignment; + } + + fn getType(comptime index: usize) type { + return types[index]; + } + + fn allocate(allocator: std.mem.Allocator) !Self { + const size = comptime Self.totalSize(); + const alignment = comptime Self.maxAlign(); + + const memory = try allocator.alignedAlloc(u8, alignment, size); + return .{ .memory = memory }; + } + + fn get(self: *const Self, comptime index: usize) *getType(index) { + var offset: usize = 0; + inline for (types, 0..) |T, i| { + offset = std.mem.alignForward(usize, offset, @alignOf(T)); + + if (i == index) { + return @as(*T, @ptrCast(@alignCast(self.memory.ptr + offset))); + } + offset += @sizeOf(T); + } + unreachable; + } + + fn set(self: *const Self, comptime index: usize, value: getType(index)) void { + const ptr = self.get(index); + ptr.* = value; + } + + fn setRoot(self: *const Self, comptime T: type) void { + const ptr = self.get(0); + ptr.* = .{ ._type = unionInit(T, self.get(1)) }; + } + + fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void { + assert(index >= 1); + assert(index < types.len); + + const ptr = self.get(index); + ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, self.get(index + 1)) }; + } + + fn setMiddleWithValue(self: *const Self, comptime index: usize, comptime T: type, value: anytype) void { + assert(index >= 1); + + const ptr = self.get(index); + ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, value) }; + } + + fn setLeaf(self: *const Self, comptime index: usize, value: anytype) void { + assert(index >= 1); + + const ptr = self.get(index); + ptr.* = value; + ptr._proto = self.get(index - 1); + } + }; +} + +fn AutoPrototypeChain(comptime types: []const type) type { + return struct { + fn create(allocator: std.mem.Allocator, leaf_value: anytype) !*@TypeOf(leaf_value) { + const chain = try PrototypeChain(types).allocate(allocator); + + const RootType = types[0]; + chain.setRoot(RootType.Type); + + inline for (1..types.len - 1) |i| { + const MiddleType = types[i]; + chain.setMiddle(i, MiddleType.Type); + } + + chain.setLeaf(types.len - 1, leaf_value); + return chain.get(types.len - 1); + } + }; +} + pub fn init(page: *Page) Factory { return .{ ._page = page, @@ -59,165 +152,127 @@ pub fn init(page: *Page) Factory { // this is a root object pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; + const allocator = self._slab.allocator(); + const chain = try PrototypeChain( + &.{ EventTarget, @TypeOf(child) }, + ).allocate(allocator); - const et = try self.createT(EventTarget); - child_ptr._proto = et; - et.* = .{ ._type = unionInit(EventTarget.Type, child_ptr) }; - return child_ptr; + chain.setRoot(EventTarget.Type); + chain.setLeaf(1, child); + + return chain.get(1); } pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.eventTarget(Node{ - ._proto = undefined, - ._type = unionInit(Node.Type, child_ptr), - }); - return child_ptr; + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, @TypeOf(child) }, + ).create(allocator, child); } pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.node(Document{ - ._proto = undefined, - ._type = unionInit(Document.Type, child_ptr), - }); - return child_ptr; + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, Document, @TypeOf(child) }, + ).create(allocator, child); } pub fn documentFragment(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.node(Node.DocumentFragment{ - ._proto = undefined, - ._type = unionInit(Node.DocumentFragment.Type, child_ptr), - }); - return child_ptr; + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, Node.DocumentFragment, @TypeOf(child) }, + ).create(allocator, child); } pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.node(Element{ - ._proto = undefined, - ._type = unionInit(Element.Type, child_ptr), - }); - return child_ptr; + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, Element, @TypeOf(child) }, + ).create(allocator, child); } pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) { - if (comptime fieldIsPointer(Element.Html.Type, @TypeOf(child))) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.element(Element.Html{ - ._proto = undefined, - ._type = unionInit(Element.Html.Type, child_ptr), - }); - return child_ptr; - } - - // Our union type fields are usually pointers. But, at the leaf, they - // can be struct (if all they contain is the `_proto` field, then we might - // as well store it directly in the struct). - - const html = try self.element(Element.Html{ - ._proto = undefined, - ._type = unionInit(Element.Html.Type, child), - }); - const field_name = comptime unionFieldName(Element.Html.Type, @TypeOf(child)); - var child_ptr = &@field(html._type, field_name); - child_ptr._proto = html; - return child_ptr; + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, Element, Element.Html, @TypeOf(child) }, + ).create(allocator, child); } pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { - if (@TypeOf(child) == Element.Svg) { - return self.element(child); - } + const allocator = self._slab.allocator(); // will never allocate, can't fail const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable; - if (comptime fieldIsPointer(Element.Svg.Type, @TypeOf(child))) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; - child_ptr._proto = try self.element(Element.Svg{ - ._proto = undefined, - ._tag_name = tag_name_str, - ._type = unionInit(Element.Svg.Type, child_ptr), - }); - return child_ptr; - } + const chain = try PrototypeChain( + &.{ EventTarget, Node, Element, Element.Svg, @TypeOf(child) }, + ).allocate(allocator); + + chain.setRoot(EventTarget.Type); + chain.setMiddle(1, Node.Type); + chain.setMiddle(2, Element.Type); - // Our union type fields are usually pointers. But, at the leaf, they - // can be struct (if all they contain is the `_proto` field, then we might - // as well store it directly in the struct). - const svg = try self.element(Element.Svg{ - ._proto = undefined, + // Manually set Element.Svg with the tag_name + chain.set(3, .{ + ._proto = chain.get(2), ._tag_name = tag_name_str, - ._type = unionInit(Element.Svg.Type, child), + ._type = unionInit(Element.Svg.Type, chain.get(4)), }); - const field_name = comptime unionFieldName(Element.Svg.Type, @TypeOf(child)); - var child_ptr = &@field(svg._type, field_name); - child_ptr._proto = svg; - return child_ptr; + + chain.setLeaf(4, child); + return chain.get(4); } // this is a root object pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; + const allocator = self._slab.allocator(); + + // Special case: Event has a _type_string field, so we need manual setup + const chain = try PrototypeChain( + &.{ Event, @TypeOf(child) }, + ).allocate(allocator); - const e = try self.createT(Event); - child_ptr._proto = e; - e.* = .{ - ._type = unionInit(Event.Type, child_ptr), + const event_ptr = chain.get(0); + event_ptr.* = .{ + ._type = unionInit(Event.Type, chain.get(1)), ._type_string = try String.init(self._page.arena, typ, .{}), }; - return child_ptr; + chain.setLeaf(1, child); + + return chain.get(1); } pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { - const et = try self.eventTarget(XMLHttpRequestEventTarget{ - ._proto = undefined, - ._type = unionInit(XMLHttpRequestEventTarget.Type, child), - }); - const field_name = comptime unionFieldName(XMLHttpRequestEventTarget.Type, @TypeOf(child)); - var child_ptr = &@field(et._type, field_name); - child_ptr._proto = et; - return child_ptr; + const allocator = self._slab.allocator(); + + return try AutoPrototypeChain( + &.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) }, + ).create(allocator, child); } pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { - const child_ptr = try self.createT(@TypeOf(child)); - child_ptr.* = child; + const allocator = self._slab.allocator(); + + // Special case: Blob has slice and mime fields, so we need manual setup + const chain = try PrototypeChain( + &.{ Blob, @TypeOf(child) }, + ).allocate(allocator); - const b = try self.createT(Blob); - child_ptr._proto = b; - b.* = .{ - ._type = unionInit(Blob.Type, child_ptr), + const blob_ptr = chain.get(0); + blob_ptr.* = .{ + ._type = unionInit(Blob.Type, chain.get(1)), .slice = "", .mime = "", }; - return child_ptr; -} - -pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) { - const ptr = try self.createT(@TypeOf(value)); - ptr.* = value; - return ptr; -} + chain.setLeaf(1, child); -pub fn createT(self: *Factory, comptime T: type) !*T { - const allocator = self._slab.allocator(); - return try allocator.create(T); + return chain.get(1); } pub fn destroy(self: *Factory, value: anytype) void { const S = reflect.Struct(@TypeOf(value)); + // const allocator = self._slab.allocator(); + if (comptime IS_DEBUG) { // We should always destroy from the leaf down. if (@hasField(S, "_type") and @typeInfo(@TypeOf(value._type)) == .@"union") { @@ -231,12 +286,13 @@ pub fn destroy(self: *Factory, value: anytype) void { } } - self.destroyChain(value, true); + const root_ptr = self.destroyChain(value, true); + _ = root_ptr; + // allocator.destroy(root_ptr); } -fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { +fn destroyChain(self: *Factory, value: anytype, comptime first: bool) *@TypeOf(value) { const S = reflect.Struct(@TypeOf(value)); - const allocator = self._slab.allocator(); // This is initially called from a deinit. We don't want to call that @@ -255,7 +311,7 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { } if (@hasField(S, "_proto")) { - self.destroyChain(value._proto, false); + return self.destroyChain(value._proto, false); } else if (@hasDecl(S, "JsApi")) { // Doesn't have a _proto, but has a JsApi. if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| { @@ -263,36 +319,18 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) void { } } - // Leaf types are allowed by be placed directly within their _proto - // (which makes sense when the @sizeOf(Leaf) == 8). These don't need to - // be (cannot be) freed. But we'll still free the chain. - if (comptime wasAllocated(S)) { - allocator.destroy(value); - } + return @ptrCast(value); } -fn wasAllocated(comptime S: type) bool { - // Whether it's heap allocate or not, we should have a pointer. - // (If it isn't heap allocated, it'll be a pointer from the proto's type - // e.g. &html._type.title) - if (!@hasField(S, "_proto")) { - // a root is always on the heap. - return true; - } - - // the _proto type - const P = reflect.Struct(std.meta.fieldInfo(S, ._proto).type); +pub fn createT(self: *Factory, comptime T: type) !*T { + const allocator = self._slab.allocator(); + return try allocator.create(T); +} - // the _proto._type type (the parent's _type union) - const U = std.meta.fieldInfo(P, ._type).type; - inline for (@typeInfo(U).@"union".fields) |field| { - if (field.type == S) { - // One of the types in the proto's _type union is this non-pointer - // structure, so it isn't heap allocted. - return false; - } - } - return true; +pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) { + const ptr = try self.createT(@TypeOf(value)); + ptr.* = value; + return ptr; } fn unionInit(comptime T: type, value: anytype) T { @@ -316,15 +354,3 @@ fn unionFieldName(comptime T: type, comptime V: type) []const u8 { } @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type"); } - -fn fieldIsPointer(comptime T: type, comptime V: type) bool { - inline for (@typeInfo(T).@"union".fields) |field| { - if (field.type == V) { - return false; - } - if (field.type == *V) { - return true; - } - } - @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type"); -} diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 6b1198433..a11b736a7 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1175,21 +1175,22 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ else => {}, } - if (namespace == .svg) { - const tag_name = try String.init(self.arena, name, .{}); - if (std.ascii.eqlIgnoreCase(name, "svg")) { - return self.createSvgElementT(Element.Svg, name, attribute_iterator, .{ - ._proto = undefined, - ._type = .svg, - ._tag_name = tag_name, - }); - } - - // Other SVG elements (rect, circle, text, g, etc.) - const lower = std.ascii.lowerString(&self.buf, name); - const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown; - return self.createSvgElementT(Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag }); - } + // TODO: uncomment + // if (namespace == .svg) { + // const tag_name = try String.init(self.arena, name, .{}); + // if (std.ascii.eqlIgnoreCase(name, "svg")) { + // return self.createSvgElementT(Element.Svg, name, attribute_iterator, .{ + // ._proto = undefined, + // ._type = .svg, + // ._tag_name = tag_name, + // }); + // } + + // // Other SVG elements (rect, circle, text, g, etc.) + // const lower = std.ascii.lowerString(&self.buf, name); + // const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown; + // return self.createSvgElementT(Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag }); + // } const tag_name = try String.init(self.arena, name, .{}); @@ -1221,7 +1222,6 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ return node; }; - // After constructor runs, invoke attributeChangedCallback for initial attributes const element = node.as(Element); if (element._attributes) |attributes| { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 4468b553a..e6d748c8f 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -67,36 +67,36 @@ pub fn construct(page: *Page) !*Element { } pub const Type = union(enum) { - anchor: Anchor, - body: Body, - br: BR, - button: Button, + anchor: *Anchor, + body: *Body, + br: *BR, + button: *Button, custom: *Custom, - dialog: Dialog, - div: Div, - form: Form, + dialog: *Dialog, + div: *Div, + form: *Form, generic: *Generic, heading: *Heading, - head: Head, - html: Html, - hr: HR, - img: Image, - iframe: IFrame, + head: *Head, + html: *Html, + hr: *HR, + img: *Image, + iframe: *IFrame, input: *Input, - li: LI, - link: Link, - meta: Meta, - ol: OL, + li: *LI, + link: *Link, + meta: *Meta, + ol: *OL, option: *Option, - p: Paragraph, + p: *Paragraph, script: *Script, - select: Select, - slot: Slot, - style: Style, + select: *Select, + slot: *Slot, + style: *Style, template: *Template, text_area: *TextArea, - title: Title, - ul: UL, + title: *Title, + ul: *UL, unknown: *Unknown, }; diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index c5568a9ae..4bc16b236 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -35,7 +35,7 @@ _on_progress: ?js.Function = null, _on_timeout: ?js.Function = null, pub const Type = union(enum) { - request: @import("XMLHttpRequest.zig"), + request: *@import("XMLHttpRequest.zig"), // TODO: xml_http_request_upload }; From 8348f2dcc84ffda4c544418fd1ea23acfeb65529 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 09:45:56 -0800 Subject: [PATCH 088/257] fix slot alignment in slab chunks --- src/slab.zig | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/slab.zig b/src/slab.zig index 02d10aa72..0e574fef0 100644 --- a/src/slab.zig +++ b/src/slab.zig @@ -376,23 +376,25 @@ pub const SlabAllocator = struct { const self: *Self = @ptrCast(@alignCast(ctx)); _ = ret_addr; + const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); + const list_gop = self.slabs.getOrPut( self.child_allocator, - SlabKey{ .size = len, .alignment = alignment }, + SlabKey{ .size = aligned_len, .alignment = alignment }, ) catch return null; if (!list_gop.found_existing) { list_gop.value_ptr.* = Slab.init( self.child_allocator, alignment, - len, + aligned_len, self.max_slot_count, ) catch return null; } const list = list_gop.value_ptr; const buf = list.alloc(self.child_allocator) catch return null; - return buf.ptr; + return buf[0..len].ptr; } fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { @@ -401,8 +403,9 @@ pub const SlabAllocator = struct { const ptr = memory.ptr; const len = memory.len; + const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); - const list = self.slabs.getPtr(.{ .size = len, .alignment = alignment }).?; + const list = self.slabs.getPtr(.{ .size = aligned_len, .alignment = alignment }).?; list.free(ptr); } }; @@ -822,3 +825,39 @@ test "slab allocator - different size classes don't interfere" { allocator.free(ptr_128); allocator.free(ptr_64_again); } + +test "slab allocator - 16-byte alignment" { + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); + defer slab_alloc.deinit(); + + const allocator = slab_alloc.allocator(); + + // Request 16-byte aligned memory + const ptr = try allocator.alignedAlloc(u8, .@"16", 152); + defer allocator.free(ptr); + + // Verify alignment + const addr = @intFromPtr(ptr.ptr); + try testing.expect(addr % 16 == 0); + + // Make sure we can use it + @memset(ptr, 0xFF); +} + +test "slab allocator - various alignments" { + var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); + defer slab_alloc.deinit(); + + const allocator = slab_alloc.allocator(); + + const alignments = [_]std.mem.Alignment{ .@"1", .@"2", .@"4", .@"8", .@"16" }; + + inline for (alignments) |alignment| { + const ptr = try allocator.alignedAlloc(u8, alignment, 100); + defer allocator.free(ptr); + + const addr = @intFromPtr(ptr.ptr); + const align_value = alignment.toByteUnits(); + try testing.expect(addr % align_value == 0); + } +} From afe9ee5367a47e62b1f0e2a97a009bf50de89327 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 10:07:40 -0800 Subject: [PATCH 089/257] fix freeing with new combined chains --- src/browser/Factory.zig | 105 ++++++++++++++++------------- src/browser/Page.zig | 4 +- src/browser/webapi/Blob.zig | 6 ++ src/browser/webapi/Event.zig | 4 ++ src/browser/webapi/EventTarget.zig | 2 + 5 files changed, 72 insertions(+), 49 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 26b30052a..4a7333f25 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -40,6 +40,13 @@ const Factory = @This(); _page: *Page, _slab: SlabAllocator, +pub const FactoryAllocationKind = union(enum) { + /// Allocated as part of a Factory PrototypeChain + chain: []u8, + /// Allocated standalone via factory.create() + standalone, +}; + fn PrototypeChain(comptime types: []const type) type { return struct { const Self = @This(); @@ -96,7 +103,7 @@ fn PrototypeChain(comptime types: []const type) type { fn setRoot(self: *const Self, comptime T: type) void { const ptr = self.get(0); - ptr.* = .{ ._type = unionInit(T, self.get(1)) }; + ptr.* = .{ ._type = unionInit(T, self.get(1)), ._allocation = FactoryAllocationKind{ .chain = self.memory } }; } fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void { @@ -163,6 +170,46 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { return chain.get(1); } +// this is a root object +pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + + // Special case: Event has a _type_string field, so we need manual setup + const chain = try PrototypeChain( + &.{ Event, @TypeOf(child) }, + ).allocate(allocator); + + const event_ptr = chain.get(0); + event_ptr.* = .{ + ._type = unionInit(Event.Type, chain.get(1)), + ._type_string = try String.init(self._page.arena, typ, .{}), + ._allocation = FactoryAllocationKind{ .chain = chain.memory }, + }; + chain.setLeaf(1, child); + + return chain.get(1); +} + +pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + + // Special case: Blob has slice and mime fields, so we need manual setup + const chain = try PrototypeChain( + &.{ Blob, @TypeOf(child) }, + ).allocate(allocator); + + const blob_ptr = chain.get(0); + blob_ptr.* = .{ + ._type = unionInit(Blob.Type, chain.get(1)), + ._allocation = FactoryAllocationKind{ .chain = chain.memory }, + .slice = "", + .mime = "", + }; + chain.setLeaf(1, child); + + return chain.get(1); +} + pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( @@ -223,25 +270,6 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO return chain.get(4); } -// this is a root object -pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - - // Special case: Event has a _type_string field, so we need manual setup - const chain = try PrototypeChain( - &.{ Event, @TypeOf(child) }, - ).allocate(allocator); - - const event_ptr = chain.get(0); - event_ptr.* = .{ - ._type = unionInit(Event.Type, chain.get(1)), - ._type_string = try String.init(self._page.arena, typ, .{}), - }; - chain.setLeaf(1, child); - - return chain.get(1); -} - pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); @@ -250,28 +278,9 @@ pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { ).create(allocator, child); } -pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - - // Special case: Blob has slice and mime fields, so we need manual setup - const chain = try PrototypeChain( - &.{ Blob, @TypeOf(child) }, - ).allocate(allocator); - - const blob_ptr = chain.get(0); - blob_ptr.* = .{ - ._type = unionInit(Blob.Type, chain.get(1)), - .slice = "", - .mime = "", - }; - chain.setLeaf(1, child); - - return chain.get(1); -} - pub fn destroy(self: *Factory, value: anytype) void { const S = reflect.Struct(@TypeOf(value)); - // const allocator = self._slab.allocator(); + const allocator = self._slab.allocator(); if (comptime IS_DEBUG) { // We should always destroy from the leaf down. @@ -286,12 +295,14 @@ pub fn destroy(self: *Factory, value: anytype) void { } } - const root_ptr = self.destroyChain(value, true); - _ = root_ptr; - // allocator.destroy(root_ptr); + const allocation_kind = self.destroyChain(value, true) orelse return; + switch (allocation_kind) { + .chain => |buf| allocator.free(buf), + .standalone => {}, + } } -fn destroyChain(self: *Factory, value: anytype, comptime first: bool) *@TypeOf(value) { +fn destroyChain(self: *Factory, value: anytype, comptime first: bool) ?FactoryAllocationKind { const S = reflect.Struct(@TypeOf(value)); const allocator = self._slab.allocator(); @@ -317,9 +328,9 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) *@TypeOf(v if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| { allocator.destroy(tagged); } - } - - return @ptrCast(value); + } else if (@hasField(S, "_allocation")) { + return value._allocation; + } else return null; } pub fn createT(self: *Factory, comptime T: type) !*T { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index a11b736a7..37bb6d1aa 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -176,8 +176,8 @@ pub fn deinit(self: *Page) void { log.debug(.page, "page.deinit", .{ .url = self.url }); // Uncomment if you want slab statistics to print. - // const stats = self._factory._slab.getStats(self.arena) catch unreachable; - // stats.print() catch unreachable; + const stats = self._factory._slab.getStats(self.arena) catch unreachable; + stats.print() catch unreachable; } self.js.deinit(); diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 9abe6f295..144280850 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -21,12 +21,15 @@ const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); _type: Type, +_allocation: FactoryAllocationKind, + /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, /// so its better to leave the deallocation of it to arena allocator. @@ -78,6 +81,7 @@ pub fn init( return page._factory.create(Blob{ ._type = .generic, + ._allocation = .standalone, .slice = slice, .mime = mime, }); @@ -267,6 +271,7 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, + ._allocation = .standalone, .slice = slice[start..end], .mime = mime, }); @@ -274,6 +279,7 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, + ._allocation = .standalone, .slice = slice, .mime = mime, }); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 70de6e078..c994004fd 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -20,12 +20,15 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; const EventTarget = @import("EventTarget.zig"); const String = @import("../../string.zig").String; pub const Event = @This(); _type: Type, +_allocation: FactoryAllocationKind, + _bubbles: bool = false, _cancelable: bool = false, _type_string: String, @@ -65,6 +68,7 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { return page._factory.create(Event{ ._type = .generic, + ._allocation = .standalone, ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 23ecdf985..fd2cefe77 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -21,12 +21,14 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const RegisterOptions = @import("../EventManager.zig").RegisterOptions; +const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; const Event = @import("Event.zig"); const EventTarget = @This(); _type: Type, +_allocation: FactoryAllocationKind, pub const Type = union(enum) { node: *@import("Node.zig"), From 2ddaa351abbd4f844a9c13a6f0b5e078a3b6475a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 10:20:27 -0800 Subject: [PATCH 090/257] use stream for logging stats --- src/browser/Page.zig | 6 ++++-- src/slab.zig | 40 ++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 37bb6d1aa..4cc4e3e17 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -176,8 +176,10 @@ pub fn deinit(self: *Page) void { log.debug(.page, "page.deinit", .{ .url = self.url }); // Uncomment if you want slab statistics to print. - const stats = self._factory._slab.getStats(self.arena) catch unreachable; - stats.print() catch unreachable; + // const stats = self._factory._slab.getStats(self.arena) catch unreachable; + // var buffer: [256]u8 = undefined; + // var stream = std.fs.File.stderr().writer(&buffer).interface; + // stats.print(&stream) catch unreachable; } self.js.deinit(); diff --git a/src/slab.zig b/src/slab.zig index 0e574fef0..dab2a0ef4 100644 --- a/src/slab.zig +++ b/src/slab.zig @@ -258,47 +258,47 @@ pub const SlabAllocator = struct { utilization_ratio: f64, slabs: []const Slab.Stats, - pub fn print(self: *const Stats) !void { - std.debug.print("\n", .{}); - std.debug.print("\n=== Slab Allocator Statistics ===\n", .{}); - std.debug.print("Overall Memory:\n", .{}); - std.debug.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ + pub fn print(self: *const Stats, stream: *std.io.Writer) !void { + try stream.print("\n", .{}); + try stream.print("\n=== Slab Allocator Statistics ===\n", .{}); + try stream.print("Overall Memory:\n", .{}); + try stream.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ self.total_allocated_bytes, @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, }); - std.debug.print(" In use: {} bytes ({d:.2} MB)\n", .{ + try stream.print(" In use: {} bytes ({d:.2} MB)\n", .{ self.bytes_in_use, @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, }); - std.debug.print(" Free: {} bytes ({d:.2} MB)\n", .{ + try stream.print(" Free: {} bytes ({d:.2} MB)\n", .{ self.bytes_free, @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, }); - std.debug.print("\nOverall Structure:\n", .{}); - std.debug.print(" Slab Count: {}\n", .{self.slab_count}); - std.debug.print(" Total chunks: {}\n", .{self.total_chunks}); - std.debug.print(" Total slots: {}\n", .{self.total_slots}); - std.debug.print(" Slots in use: {}\n", .{self.slots_in_use}); - std.debug.print(" Slots free: {}\n", .{self.slots_free}); + try stream.print("\nOverall Structure:\n", .{}); + try stream.print(" Slab Count: {}\n", .{self.slab_count}); + try stream.print(" Total chunks: {}\n", .{self.total_chunks}); + try stream.print(" Total slots: {}\n", .{self.total_slots}); + try stream.print(" Slots in use: {}\n", .{self.slots_in_use}); + try stream.print(" Slots free: {}\n", .{self.slots_free}); - std.debug.print("\nOverall Efficiency:\n", .{}); - std.debug.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); - std.debug.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); + try stream.print("\nOverall Efficiency:\n", .{}); + try stream.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); + try stream.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); if (self.slabs.len > 0) { - std.debug.print("\nPer-Slab Breakdown:\n", .{}); - std.debug.print( + try stream.print("\nPer-Slab Breakdown:\n", .{}); + try stream.print( " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, ); - std.debug.print( + try stream.print( " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", .{ "", "", "", "", "", "", "" }, ); for (self.slabs) |slab| { - std.debug.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ + try stream.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ slab.key.size, @intFromEnum(slab.key.alignment), slab.chunk_count, From 45c7184fdeeae41a5d754394a293849c1aaf895f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 11:14:30 -0800 Subject: [PATCH 091/257] use nullable slice for tracking chain allocations --- src/browser/Factory.zig | 28 +++++++++++----------------- src/browser/webapi/Blob.zig | 9 ++++----- src/browser/webapi/Event.zig | 5 ++--- src/browser/webapi/EventTarget.zig | 5 ++--- 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 4a7333f25..f986c8954 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -40,13 +40,6 @@ const Factory = @This(); _page: *Page, _slab: SlabAllocator, -pub const FactoryAllocationKind = union(enum) { - /// Allocated as part of a Factory PrototypeChain - chain: []u8, - /// Allocated standalone via factory.create() - standalone, -}; - fn PrototypeChain(comptime types: []const type) type { return struct { const Self = @This(); @@ -103,7 +96,7 @@ fn PrototypeChain(comptime types: []const type) type { fn setRoot(self: *const Self, comptime T: type) void { const ptr = self.get(0); - ptr.* = .{ ._type = unionInit(T, self.get(1)), ._allocation = FactoryAllocationKind{ .chain = self.memory } }; + ptr.* = .{ ._type = unionInit(T, self.get(1)), ._allocation = self.memory }; } fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void { @@ -164,7 +157,11 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { &.{ EventTarget, @TypeOf(child) }, ).allocate(allocator); - chain.setRoot(EventTarget.Type); + const event_ptr = chain.get(0); + event_ptr.* = .{ + ._type = unionInit(EventTarget.Type, chain.get(1)), + ._allocation = chain.memory, + }; chain.setLeaf(1, child); return chain.get(1); @@ -183,7 +180,7 @@ pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { event_ptr.* = .{ ._type = unionInit(Event.Type, chain.get(1)), ._type_string = try String.init(self._page.arena, typ, .{}), - ._allocation = FactoryAllocationKind{ .chain = chain.memory }, + ._allocation = chain.memory, }; chain.setLeaf(1, child); @@ -201,7 +198,7 @@ pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { const blob_ptr = chain.get(0); blob_ptr.* = .{ ._type = unionInit(Blob.Type, chain.get(1)), - ._allocation = FactoryAllocationKind{ .chain = chain.memory }, + ._allocation = chain.memory, .slice = "", .mime = "", }; @@ -295,14 +292,11 @@ pub fn destroy(self: *Factory, value: anytype) void { } } - const allocation_kind = self.destroyChain(value, true) orelse return; - switch (allocation_kind) { - .chain => |buf| allocator.free(buf), - .standalone => {}, - } + const chain_memory = self.destroyChain(value, true) orelse return; + allocator.free(chain_memory); } -fn destroyChain(self: *Factory, value: anytype, comptime first: bool) ?FactoryAllocationKind { +fn destroyChain(self: *Factory, value: anytype, comptime first: bool) ?[]u8 { const S = reflect.Struct(@TypeOf(value)); const allocator = self._slab.allocator(); diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 144280850..2b134e3f6 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -21,14 +21,13 @@ const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); -const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); _type: Type, -_allocation: FactoryAllocationKind, +_allocation: ?[]u8, /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, @@ -81,7 +80,7 @@ pub fn init( return page._factory.create(Blob{ ._type = .generic, - ._allocation = .standalone, + ._allocation = null, .slice = slice, .mime = mime, }); @@ -271,7 +270,7 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, - ._allocation = .standalone, + ._allocation = null, .slice = slice[start..end], .mime = mime, }); @@ -279,7 +278,7 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, - ._allocation = .standalone, + ._allocation = null, .slice = slice, .mime = mime, }); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index c994004fd..21c4f83be 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -20,14 +20,13 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); -const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; const EventTarget = @import("EventTarget.zig"); const String = @import("../../string.zig").String; pub const Event = @This(); _type: Type, -_allocation: FactoryAllocationKind, +_allocation: ?[]u8, _bubbles: bool = false, _cancelable: bool = false, @@ -68,7 +67,7 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { return page._factory.create(Event{ ._type = .generic, - ._allocation = .standalone, + ._allocation = null, ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index fd2cefe77..b9e584e19 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -21,14 +21,13 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const RegisterOptions = @import("../EventManager.zig").RegisterOptions; -const FactoryAllocationKind = @import("../Factory.zig").FactoryAllocationKind; const Event = @import("Event.zig"); const EventTarget = @This(); _type: Type, -_allocation: FactoryAllocationKind, +_allocation: ?[]u8, pub const Type = union(enum) { node: *@import("Node.zig"), @@ -124,7 +123,7 @@ pub const JsApi = struct { const testing = @import("../../testing.zig"); test "WebApi: EventTarget" { // we create thousands of these per page. Nothing should bloat it. - try testing.expectEqual(16, @sizeOf(EventTarget)); + try testing.expectEqual(32, @sizeOf(EventTarget)); try testing.htmlRunner("events.html", .{}); } From 15dff342a6088614e5b8d1f5242443688a75f783 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Nov 2025 12:07:59 -0800 Subject: [PATCH 092/257] shrink EventTarget back to 16 --- src/browser/Factory.zig | 87 +++++++++++++++++++++++++----- src/browser/webapi/Blob.zig | 5 +- src/browser/webapi/Event.zig | 3 +- src/browser/webapi/EventTarget.zig | 4 +- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index f986c8954..8a0893e1f 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -96,7 +96,7 @@ fn PrototypeChain(comptime types: []const type) type { fn setRoot(self: *const Self, comptime T: type) void { const ptr = self.get(0); - ptr.* = .{ ._type = unionInit(T, self.get(1)), ._allocation = self.memory }; + ptr.* = .{ ._type = unionInit(T, self.get(1)) }; } fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void { @@ -160,7 +160,6 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { const event_ptr = chain.get(0); event_ptr.* = .{ ._type = unionInit(EventTarget.Type, chain.get(1)), - ._allocation = chain.memory, }; chain.setLeaf(1, child); @@ -180,7 +179,6 @@ pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { event_ptr.* = .{ ._type = unionInit(Event.Type, chain.get(1)), ._type_string = try String.init(self._page.arena, typ, .{}), - ._allocation = chain.memory, }; chain.setLeaf(1, child); @@ -198,7 +196,6 @@ pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { const blob_ptr = chain.get(0); blob_ptr.* = .{ ._type = unionInit(Blob.Type, chain.get(1)), - ._allocation = chain.memory, .slice = "", .mime = "", }; @@ -275,9 +272,34 @@ pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { ).create(allocator, child); } +fn hasChainRoot(comptime T: type) bool { + // Check if this is a root + if (@hasDecl(T, "_prototype_root")) { + return true; + } + + // If no _proto field, we're at the top but not a recognized root + if (!@hasField(T, "_proto")) return false; + + // Get the _proto field's type and recurse + const fields = @typeInfo(T).@"struct".fields; + inline for (fields) |field| { + if (std.mem.eql(u8, field.name, "_proto")) { + const ProtoType = reflect.Struct(field.type); + return hasChainRoot(ProtoType); + } + } + + return false; +} + +fn isChainType(comptime T: type) bool { + if (@hasField(T, "_proto")) return false; + return comptime hasChainRoot(T); +} + pub fn destroy(self: *Factory, value: anytype) void { const S = reflect.Struct(@TypeOf(value)); - const allocator = self._slab.allocator(); if (comptime IS_DEBUG) { // We should always destroy from the leaf down. @@ -292,14 +314,48 @@ pub fn destroy(self: *Factory, value: anytype) void { } } - const chain_memory = self.destroyChain(value, true) orelse return; - allocator.free(chain_memory); + if (comptime isChainType(S)) { + self.destroyChain(value, true, 0, std.mem.Alignment.@"1"); + } else { + self.destroyStandalone(value); + } +} + +pub fn destroyStandalone(self: *Factory, value: anytype) void { + const S = reflect.Struct(@TypeOf(value)); + assert(!@hasDecl(S, "_prototype_root")); + + const allocator = self._slab.allocator(); + + if (@hasDecl(S, "deinit")) { + // And it has a deinit, we'll call it + switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) { + 1 => value.deinit(), + 2 => value.deinit(self._page), + else => @compileLog(@typeName(S) ++ " has an invalid deinit function"), + } + } + + allocator.destroy(value); } -fn destroyChain(self: *Factory, value: anytype, comptime first: bool) ?[]u8 { +fn destroyChain( + self: *Factory, + value: anytype, + comptime first: bool, + old_size: usize, + old_align: std.mem.Alignment, +) void { const S = reflect.Struct(@TypeOf(value)); const allocator = self._slab.allocator(); + // aligns the old size to the alignment of this element + const current_size = std.mem.alignForward(usize, old_size, @alignOf(S)); + const alignment = std.mem.Alignment.fromByteUnits(@alignOf(S)); + + const new_align = std.mem.Alignment.max(old_align, alignment); + const new_size = current_size + @sizeOf(S); + // This is initially called from a deinit. We don't want to call that // same deinit. So when this is the first time destroyChain is called // we don't call deinit (because we're in that deinit) @@ -316,15 +372,22 @@ fn destroyChain(self: *Factory, value: anytype, comptime first: bool) ?[]u8 { } if (@hasField(S, "_proto")) { - return self.destroyChain(value._proto, false); + self.destroyChain(value._proto, false, new_size, new_align); } else if (@hasDecl(S, "JsApi")) { // Doesn't have a _proto, but has a JsApi. if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| { allocator.destroy(tagged); } - } else if (@hasField(S, "_allocation")) { - return value._allocation; - } else return null; + } else { + // no proto so this is the head of the chain. + // we use this as the ptr to the start of the chain. + // and we have summed up the length. + assert(@hasDecl(S, "_prototype_root")); + + const memory_ptr: [*]const u8 = @ptrCast(value); + const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits()); + allocator.free(memory_ptr[0..len]); + } } pub fn createT(self: *Factory, comptime T: type) !*T { diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 2b134e3f6..a60f4b424 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -26,8 +26,8 @@ const Page = @import("../Page.zig"); /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); +const _prototype_root = true; _type: Type, -_allocation: ?[]u8, /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, @@ -80,7 +80,6 @@ pub fn init( return page._factory.create(Blob{ ._type = .generic, - ._allocation = null, .slice = slice, .mime = mime, }); @@ -270,7 +269,6 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, - ._allocation = null, .slice = slice[start..end], .mime = mime, }); @@ -278,7 +276,6 @@ pub fn getSlice( return page._factory.create(Blob{ ._type = .generic, - ._allocation = null, .slice = slice, .mime = mime, }); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 21c4f83be..b02357baa 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -25,8 +25,8 @@ const String = @import("../../string.zig").String; pub const Event = @This(); +const _prototype_root = true; _type: Type, -_allocation: ?[]u8, _bubbles: bool = false, _cancelable: bool = false, @@ -67,7 +67,6 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { return page._factory.create(Event{ ._type = .generic, - ._allocation = null, ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index b9e584e19..4e5ab768c 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -26,8 +26,8 @@ const Event = @import("Event.zig"); const EventTarget = @This(); +const _prototype_root = true; _type: Type, -_allocation: ?[]u8, pub const Type = union(enum) { node: *@import("Node.zig"), @@ -123,7 +123,7 @@ pub const JsApi = struct { const testing = @import("../../testing.zig"); test "WebApi: EventTarget" { // we create thousands of these per page. Nothing should bloat it. - try testing.expectEqual(32, @sizeOf(EventTarget)); + try testing.expectEqual(16, @sizeOf(EventTarget)); try testing.htmlRunner("events.html", .{}); } From 8775564e04d2ad97a1924172a2f4eeff46fb5389 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Nov 2025 10:53:31 +0800 Subject: [PATCH 093/257] merge module loading tweaks that were made to main --- src/browser/ScriptManager.zig | 2 +- src/browser/js/Caller.zig | 3 --- src/browser/js/Context.zig | 47 ++++++++++++++++++++++------------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0d421db14..57cd65245 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -300,7 +300,7 @@ pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, base: [:0]const return s; } - return URL.resolve(arena, base, specifier, .{}); + return URL.resolve(arena, base, specifier, .{.always_dupe = true}); } pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index edd016658..efb696ec7 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -80,9 +80,6 @@ pub fn deinit(self: *Caller) void { _ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN }); } - // Set this _after_ we've executed the above code, so that if the - // above code executes any callbacks, they aren't being executed - // at scope 0, which would be wrong. context.call_depth = call_depth; } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index fddcdfa48..a2f358dc4 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -254,8 +254,8 @@ pub fn module(self: *Context, comptime want_result: bool, src: []const u8, url: } } - const m = try compileModule(self.isolate, src, url); const owned_url = try arena.dupeZ(u8, url); + const m = try compileModule(self.isolate, src, owned_url); if (cacheable) { // compileModule is synchronous - nothing can modify the cache during compilation @@ -1342,6 +1342,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c var resolver = persistent_resolver.castToPromiseResolver(); const state = try self.arena.create(DynamicModuleResolveState); + state.* = .{ .module = null, .context = self, @@ -1379,27 +1380,39 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c } // So we have a module, but no async resolver. This can only - // happen if the module was first synchronously loaded (e.g., as a - // static import dependency). You'd think we can just return the module + // happen if the module was first synchronously loaded (Does that + // ever even happen?!) You'd think we cann just return the module // but no, we need to resolve the module namespace, and the // module could still be loading! // We need to do part of what the first case is going to do in // `dynamicModuleSourceCallback`, but we can skip some steps - // since the module is already compiled. + // since the module is alrady loaded, std.debug.assert(gop.value_ptr.module != null); // If the module hasn't been evaluated yet (it was only instantiated // as a static import dependency), we need to evaluate it now. if (gop.value_ptr.module_promise == null) { const mod = gop.value_ptr.module.?.castToModule(); - const evaluated = mod.evaluate(self.v8_context) catch { - std.debug.assert(mod.getStatus() == .kErrored); - const error_msg = v8.String.initUtf8(isolate, "Module evaluation failed"); - _ = resolver.reject(self.v8_context, error_msg.toValue()); - return promise; - }; - std.debug.assert(evaluated.isPromise()); - gop.value_ptr.module_promise = PersistentPromise.init(self.isolate, .{ .handle = evaluated.handle }); + const status = mod.getStatus(); + if (status == .kEvaluated or status == .kEvaluating) { + // Module was already evaluated (shouldn't normally happen, but handle it). + // Create a pre-resolved promise with the module namespace. + const persisted_module_resolver = v8.Persistent(v8.PromiseResolver).init(isolate, v8.PromiseResolver.init(self.v8_context)); + try self.persisted_promise_resolvers.append(self.arena, persisted_module_resolver); + var module_resolver = persisted_module_resolver.castToPromiseResolver(); + _ = module_resolver.resolve(self.v8_context, mod.getModuleNamespace()); + gop.value_ptr.module_promise = PersistentPromise.init(self.isolate, module_resolver.getPromise()); + } else { + // the module was loaded, but not evaluated, we _have_ to evaluate it now + const evaluated = mod.evaluate(self.v8_context) catch { + std.debug.assert(status == .kErrored); + const error_msg = v8.String.initUtf8(isolate, "Module evaluation failed"); + _ = resolver.reject(self.v8_context, error_msg.toValue()); + return promise; + }; + std.debug.assert(evaluated.isPromise()); + gop.value_ptr.module_promise = PersistentPromise.init(self.isolate, .{ .handle = evaluated.handle }); + } } // like before, we want to set this up so that if anything else @@ -1407,30 +1420,30 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c // since we're going to be doing all the work. gop.value_ptr.resolver_promise = persisted_promise; - // But we can skip directly to `resolveDynamicModule` which is + // But we can skip direclty to `resolveDynamicModule` which is // what the above callback will eventually do. self.resolveDynamicModule(state, gop.value_ptr.*); return promise; } -fn dynamicModuleSourceCallback(ctx: *anyopaque, fetch_result_: anyerror!ScriptManager.ModuleSource) void { +fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptManager.ModuleSource) void { const state: *DynamicModuleResolveState = @ptrCast(@alignCast(ctx)); var self = state.context; - var fetch_result = fetch_result_ catch |err| { + var ms = module_source_ catch |err| { const error_msg = v8.String.initUtf8(self.isolate, @errorName(err)); _ = state.resolver.castToPromiseResolver().reject(self.v8_context, error_msg.toValue()); return; }; const module_entry = blk: { - defer fetch_result.deinit(); + defer ms.deinit(); var try_catch: js.TryCatch = undefined; try_catch.init(self); defer try_catch.deinit(); - break :blk self.module(true, fetch_result.src(), state.specifier, true) catch { + break :blk self.module(true, ms.src(), state.specifier, true) catch { const ex = try_catch.exception(self.call_arena) catch |err| @errorName(err) orelse "unknown error"; log.err(.js, "module compilation failed", .{ .specifier = state.specifier, From 0d57356c1149d016303268a482820b03326b971d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Nov 2025 15:12:54 +0800 Subject: [PATCH 094/257] Response constructor, window.CSS --- src/browser/Page.zig | 1 - src/browser/ScriptManager.zig | 2 +- src/browser/js/Env.zig | 9 +- src/browser/js/bridge.zig | 3 +- src/browser/tests/css.html | 69 +++++++++++ src/browser/webapi/Element.zig | 6 +- src/browser/webapi/Performance.zig | 2 +- src/browser/webapi/PerformanceObserver.zig | 2 +- src/browser/webapi/Window.zig | 7 ++ src/browser/webapi/css.zig | 138 +++++++++++++++++++++ src/browser/webapi/element/html/Slot.zig | 2 +- src/browser/webapi/net/Response.zig | 18 +++ 12 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 src/browser/tests/css.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 6b1198433..37d947c49 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1221,7 +1221,6 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ return node; }; - // After constructor runs, invoke attributeChangedCallback for initial attributes const element = node.as(Element); if (element._attributes) |attributes| { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 57cd65245..f037713f7 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -300,7 +300,7 @@ pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, base: [:0]const return s; } - return URL.resolve(arena, base, specifier, .{.always_dupe = true}); + return URL.resolve(arena, base, specifier, .{ .always_dupe = true }); } pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 3dc59ab54..2c87510fa 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -198,13 +198,10 @@ fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void { const value = if (msg.getValue()) |v8_value| context.valueToString(v8_value, .{}) catch |err| @errorName(err) - else "no value" - ; + else + "no value"; - log.debug(.js, "unhandled rejection", .{ - .value = value, - .stack = context.stackTrace() catch |err| @errorName(err) orelse "???" - }); + log.debug(.js, "unhandled rejection", .{ .value = value, .stack = context.stackTrace() catch |err| @errorName(err) orelse "???" }); } // Give it a Zig struct, get back a v8.FunctionTemplate. diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 63aef20a2..850c80703 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -59,7 +59,7 @@ pub fn Builder(comptime T: type) type { pub fn property(value: anytype) Property { switch (@typeInfo(@TypeOf(value))) { - .comptime_int, .int => return .{.int = value}, + .comptime_int, .int => return .{ .int = value }, else => {}, } @compileError("Property for " ++ @typeName(@TypeOf(value)) ++ " hasn't been defined yet"); @@ -485,6 +485,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), + @import("../webapi/CSS.zig"), @import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSStyleDeclaration.zig"), diff --git a/src/browser/tests/css.html b/src/browser/tests/css.html new file mode 100644 index 000000000..ac0b6abae --- /dev/null +++ b/src/browser/tests/css.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f73e5374e..c688d6a8b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -32,7 +32,7 @@ pub const Attribute = @import("element/Attribute.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMStringMap = @import("element/DOMStringMap.zig"); const DOMRect = @import("DOMRect.zig"); -const css = @import("css.zig"); +const CSS = @import("CSS.zig"); const ShadowRoot = @import("ShadowRoot.zig"); pub const Svg = @import("element/Svg.zig"); @@ -623,8 +623,8 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { const style = try self.getStyle(page); const decl = style.asCSSStyleDeclaration(); - width = css.parseDimension(decl.getPropertyValue("width", page)) orelse 1.0; - height = css.parseDimension(decl.getPropertyValue("height", page)) orelse 1.0; + width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 1.0; + height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 1.0; if (width == 1.0 or height == 1.0) { const tag = self.getTag(); diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 60b972a30..c659a7f89 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -69,7 +69,7 @@ pub const Entry = struct { } pub fn getEntryType(self: *const Entry) []const u8 { - return switch (self._entry_type) { + return switch (self._entry_type) { .first_input => "first-input", .largest_contentful_paint => "largest-contentful-paint", .layout_shift => "layout-shift", diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 68eafe015..cd77ad188 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -68,5 +68,5 @@ pub const JsApi = struct { pub const observe = bridge.function(PerformanceObserver.observe, .{}); pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); - pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{.static = true}); + pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{ .static = true }); }; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 503dc008f..16562980d 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -25,6 +25,7 @@ const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); const Crypto = @import("Crypto.zig"); +const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); const Screen = @import("Screen.zig"); const Performance = @import("Performance.zig"); @@ -43,6 +44,7 @@ const Window = @This(); _proto: *EventTarget, _document: *Document, +_css: CSS = .init, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, @@ -89,6 +91,10 @@ pub fn getCrypto(self: *Window) *Crypto { return &self._crypto; } +pub fn getCSS(self: *Window) *CSS { + return &self._css; +} + pub fn getPerformance(self: *Window) *Performance { return &self._performance; } @@ -380,6 +386,7 @@ pub const JsApi = struct { pub const location = bridge.accessor(Window.getLocation, null, .{ .cache = "location" }); pub const history = bridge.accessor(Window.getHistory, null, .{ .cache = "history" }); pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" }); + pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); diff --git a/src/browser/webapi/css.zig b/src/browser/webapi/css.zig index f285e8d2d..a2f320f7d 100644 --- a/src/browser/webapi/css.zig +++ b/src/browser/webapi/css.zig @@ -17,6 +17,13 @@ // along with this program. If not, see . const std = @import("std"); +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +const CSS = @This(); +_pad: bool = false, + +pub const init: CSS = .{}; pub fn parseDimension(value: []const u8) ?f64 { if (value.len == 0) { @@ -30,3 +37,134 @@ pub fn parseDimension(value: []const u8) ?f64 { return std.fmt.parseFloat(f64, num_str) catch null; } + +/// Escapes a CSS identifier string +/// https://drafts.csswg.org/cssom/#the-css.escape()-method +pub fn escape(_: *const CSS, value: []const u8, page: *Page) ![]const u8 { + if (value.len == 0) { + return error.InvalidCharacterError; + } + + const first = value[0]; + + // Count how many characters we need for the output + var out_len: usize = escapeLen(true, first); + for (value[1..]) |c| { + out_len += escapeLen(false, c); + } + + if (out_len == value.len) { + return value; + } + + const result = try page.call_arena.alloc(u8, out_len); + var pos: usize = 0; + + if (needsEscape(true, first)) { + pos = writeEscape(true, result, first); + } else { + result[0] = first; + pos = 1; + } + + for (value[1..]) |c| { + if (!needsEscape(false, c)) { + result[pos] = c; + pos += 1; + } else { + pos += writeEscape(false, result[pos..], c); + } + } + + return result; +} + +pub fn supports(_: *const CSS, property_or_condition: []const u8, value: ?[]const u8) bool { + _ = property_or_condition; + _ = value; + return true; +} + +fn escapeLen(comptime is_first: bool, c: u8) usize { + if (needsEscape(is_first, c) == false) { + return 1; + } + if (c == 0) { + return "\u{FFFD}".len; + } + if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) { + // Will be escaped as \XX (backslash + 1-6 hex digits + space) + return 2 + hexDigitsNeeded(c); + } + // Escaped as \C (backslash + character) + return 2; +} + +fn needsEscape(comptime is_first: bool, c: u8) bool { + if (comptime is_first) { + if (c >= '0' and c <= '9') { + return true; + } + if (c == '-') { + return true; + } + } + + // Characters that need escaping + return switch (c) { + 0...0x1F, 0x7F => true, + '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~' => true, + ' ' => true, + else => false, + }; +} + +fn isHexEscape(c: u8) bool { + return (c >= 0x00 and c <= 0x1F) or c == 0x7F; +} + +fn hexDigitsNeeded(c: u8) usize { + if (c < 0x10) { + return 1; + } + return 2; +} + +fn writeEscape(comptime is_first: bool, buf: []u8, c: u8) usize { + buf[0] = '\\'; + var data = buf[1..]; + + if (c == 0) { + // NULL character becomes replacement character + const replacement = "\u{FFFD}"; + @memcpy(data[0..replacement.len], replacement); + return 1 + replacement.len; + } + + if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) { + const hex_str = std.fmt.bufPrint(data, "{x} ", .{c}) catch unreachable; + return 1 + hex_str.len; + } + + data[0] = c; + return 2; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CSS); + + pub const Meta = struct { + pub const name = "Css"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const escape = bridge.function(CSS.escape, .{}); + pub const supports = bridge.function(CSS.supports, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: CSS" { + try testing.htmlRunner("css.html", .{}); +} diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig index 1089ad565..9e0c41b9a 100644 --- a/src/browser/webapi/element/html/Slot.zig +++ b/src/browser/webapi/element/html/Slot.zig @@ -97,7 +97,7 @@ pub fn assign(self: *Slot, nodes: []const *Node) void { _ = nodes; // let's see if this is ever actually used - log.warn(.not_implemented, "Slot.assign", .{ }); + log.warn(.not_implemented, "Slot.assign", .{}); } fn findShadowRoot(self: *Slot) ?*ShadowRoot { diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index d072f7b6c..de1c151b1 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Headers = @import("Headers.zig"); const Allocator = std.mem.Allocator; const Response = @This(); @@ -28,6 +29,22 @@ _status: u16, _data: []const u8, _arena: Allocator, +const InitOpts = struct { + status: u16 = 200, + headers: ?*Headers = null, + statusText: ?[]const u8 = null, +}; + +pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { + const opts = opts_ orelse InitOpts{}; + + return page._factory.create(Response{ + ._status = opts.status, + ._data = if (body_) |b| try page.arena.dupe(u8, b) else "", + ._arena = page.arena, + }); +} + pub fn initFromFetch(arena: Allocator, data: []const u8, page: *Page) !*Response { return page._factory.create(Response{ ._status = 200, @@ -65,6 +82,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const constructor = bridge.constructor(Response.init, .{}); pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); pub const json = bridge.function(Response.getJson, .{}); From f25b8fc7b0371dc2ddd0860bc3b1cbaa641ac5ca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Nov 2025 16:57:33 +0800 Subject: [PATCH 095/257] Event.composedPath and adjusted target when crossing shadowroot boundary --- src/browser/EventManager.zig | 86 +++++++++++++- src/browser/js/Function.zig | 2 +- src/browser/tests/shadowroot/events.html | 145 +++++++++++++++++++---- src/browser/webapi/Event.zig | 73 ++++++++++++ 4 files changed, 281 insertions(+), 25 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index e6d1ec0b3..3eb02bae6 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -162,18 +162,36 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E } fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + var path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; var node: ?*Node = target; - while (node) |n| : (node = n._parent) { + while (node) |n| { if (path_len >= path_buffer.len) break; path_buffer[path_len] = n.asEventTarget(); path_len += 1; + + // Check if this node is a shadow root + if (n.is(ShadowRoot)) |shadow| { + event._needs_retargeting = true; + + // If event is not composed, stop at shadow boundary + if (!event._composed) { + break; + } + + // Otherwise, jump to the shadow host and continue + node = shadow._host.asNode(); + continue; + } + + node = n._parent; } // Even though the window isn't part of the DOM, events always propagate - // through it in the capture phase + // through it in the capture phase (unless we stopped at a shadow boundary) if (path_len < path_buffer.len) { path_buffer[path_len] = self.page.window.asEventTarget(); path_len += 1; @@ -257,6 +275,12 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe was_handled.* = true; event._current_target = current_target; + // Compute adjusted target for shadow DOM retargeting (only if needed) + const original_target = event._target; + if (event._needs_retargeting) { + event._target = getAdjustedTarget(original_target, current_target); + } + switch (listener.function) { .value => |value| try value.call(void, .{event}), .string => |string| { @@ -265,6 +289,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe }, } + // Restore original target (only if we changed it) + if (event._needs_retargeting) { + event._target = original_target; + } + if (listener.once) { self.removeListener(list, listener); } @@ -325,3 +354,56 @@ const Function = union(enum) { }; } }; + +// Computes the adjusted target for shadow DOM event retargeting +// Returns the lowest shadow-including ancestor of original_target that is +// also an ancestor-or-self of current_target +fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + + const orig_node = switch ((original_target orelse return null)._type) { + .node => |n| n, + else => return original_target, + }; + const curr_node = switch (current_target._type) { + .node => |n| n, + else => return original_target, + }; + + // Walk up from original target, checking if we can reach current target + var node: ?*Node = orig_node; + while (node) |n| { + // Check if current_target is an ancestor of n (or n itself) + if (isAncestorOrSelf(curr_node, n)) { + return n.asEventTarget(); + } + + // Cross shadow boundary if needed + if (n.is(ShadowRoot)) |shadow| { + node = shadow._host.asNode(); + continue; + } + + node = n._parent; + } + + return original_target; +} + +// Check if ancestor is an ancestor of (or the same as) node +// WITHOUT crossing shadow boundaries (just regular DOM tree) +fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool { + if (ancestor == node) { + return true; + } + + var current: ?*Node = node._parent; + while (current) |n| { + if (n == ancestor) { + return true; + } + current = n._parent; + } + + return false; +} diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 4ab5be8a5..41d8fa2ca 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -144,7 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args const result = self.func.castToFunction().call(context.v8_context, js_this, js_args); if (result == null) { - // std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); + std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); return error.JSExecCallback; } diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html index 46285cb23..de6f7cdc1 100644 --- a/src/browser/tests/shadowroot/events.html +++ b/src/browser/tests/shadowroot/events.html @@ -52,34 +52,135 @@ + + + + + + -// const button = shadow.getElementById('btn'); + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 70de6e078..b11a83faf 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -21,6 +21,7 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); +const Node = @import("Node.zig"); const String = @import("../../string.zig").String; pub const Event = @This(); @@ -28,6 +29,7 @@ pub const Event = @This(); _type: Type, _bubbles: bool = false, _cancelable: bool = false, +_composed: bool = false, _type_string: String, _target: ?*EventTarget = null, _current_target: ?*EventTarget = null, @@ -36,6 +38,7 @@ _stop_propagation: bool = false, _stop_immediate_propagation: bool = false, _event_phase: EventPhase = .none, _time_stamp: u64 = 0, +_needs_retargeting: bool = false, pub const EventPhase = enum(u8) { none = 0, @@ -54,6 +57,7 @@ pub const Type = union(enum) { const Options = struct { bubbles: bool = false, cancelable: bool = false, + composed: bool = false, }; pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { @@ -68,6 +72,7 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, + ._composed = opts.composed, ._type_string = try String.init(page.arena, typ, .{}), }); } @@ -84,6 +89,10 @@ pub fn getCancelable(self: *const Event) bool { return self._cancelable; } +pub fn getComposed(self: *const Event) bool { + return self._composed; +} + pub fn getTarget(self: *const Event) ?*EventTarget { return self._target; } @@ -117,6 +126,68 @@ pub fn getTimeStamp(self: *const Event) u64 { return self._time_stamp; } +pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { + // Return empty array if event is not being dispatched + if (self._event_phase == .none) { + return &.{}; + } + + // If there's no target, return empty array + const target = self._target orelse return &.{}; + + // Only nodes have a propagation path + const target_node = switch (target._type) { + .node => |n| n, + else => return &.{}, + }; + + // Build the path by walking up from target + var path_len: usize = 0; + var path_buffer: [128]*EventTarget = undefined; + var stopped_at_shadow_boundary = false; + + var node: ?*Node = target_node; + while (node) |n| { + if (path_len >= path_buffer.len) { + break; + } + path_buffer[path_len] = n.asEventTarget(); + path_len += 1; + + // Check if this node is a shadow root + if (n._type == .document_fragment) { + if (n._type.document_fragment._type == .shadow_root) { + const shadow = n._type.document_fragment._type.shadow_root; + + // If event is not composed, stop at shadow boundary + if (!self._composed) { + stopped_at_shadow_boundary = true; + break; + } + + // Otherwise, jump to the shadow host and continue + node = shadow._host.asNode(); + continue; + } + } + + node = n._parent; + } + + // Add window at the end (unless we stopped at shadow boundary) + if (!stopped_at_shadow_boundary) { + if (path_len < path_buffer.len) { + path_buffer[path_len] = page.window.asEventTarget(); + path_len += 1; + } + } + + // Allocate and return the path using call_arena (short-lived) + const path = try page.call_arena.alloc(*EventTarget, path_len); + @memcpy(path, path_buffer[0..path_len]); + return path; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Event); @@ -131,6 +202,7 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Event.getType, null, .{}); pub const bubbles = bridge.accessor(Event.getBubbles, null, .{}); pub const cancelable = bridge.accessor(Event.getCancelable, null, .{}); + pub const composed = bridge.accessor(Event.getComposed, null, .{}); pub const target = bridge.accessor(Event.getTarget, null, .{}); pub const currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{}); pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{}); @@ -139,6 +211,7 @@ pub const JsApi = struct { pub const preventDefault = bridge.function(Event.preventDefault, .{}); pub const stopPropagation = bridge.function(Event.stopPropagation, .{}); pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{}); + pub const composedPath = bridge.function(Event.composedPath, .{}); // Event phase constants pub const NONE = bridge.property(@intFromEnum(EventPhase.none)); From 819424fd3b0a61bf54b88e7f6d63c3b96db1f6a4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Nov 2025 18:16:03 +0800 Subject: [PATCH 096/257] Support Image constructor (i.e. new Image(..)) --- src/browser/js/Env.zig | 13 --- src/browser/js/ExecutionWorld.zig | 5 +- src/browser/tests/element/html/image.html | 89 +++++++++++++++++ src/browser/webapi/element/html/Image.zig | 114 ++++++++++++++++++---- 4 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 src/browser/tests/element/html/image.html diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 2c87510fa..052c1916d 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -333,19 +333,6 @@ fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTem return template; } -// fn generateUndetectable(comptime Struct: type, template: v8.ObjectTemplate) void { -// const has_js_call_as_function = @hasDecl(Struct, "jsCallAsFunction"); - -// if (has_js_call_as_function) { - -// if (@hasDecl(Struct, "htmldda") and Struct.htmldda) { -// if (!has_js_call_as_function) { -// @compileError(@typeName(Struct) ++ ": htmldda required jsCallAsFunction to be defined. This is a hard-coded requirement in V8, because mark_as_undetectable only exists for HTMLAllCollection which is also callable."); -// } -// template.markAsUndetectable(); -// } -// } - pub fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt { @setEvalBranchQuota(2000); comptime { diff --git a/src/browser/js/ExecutionWorld.zig b/src/browser/js/ExecutionWorld.zig index 723833436..77a5865dd 100644 --- a/src/browser/js/ExecutionWorld.zig +++ b/src/browser/js/ExecutionWorld.zig @@ -116,8 +116,9 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal // are now going to get associated with our global instance. inline for (JsApis, 0..) |JsApi, i| { if (@hasDecl(JsApi.Meta, "name")) { - const class_name = v8.String.initUtf8(isolate, JsApi.Meta.name); - global_template.set(class_name.toName(), templates[i], v8.PropertyAttribute.None); + const class_name = if (@hasDecl(JsApi.Meta, "constructor_alias")) JsApi.Meta.constructor_alias else JsApi.Meta.name; + const v8_class_name = v8.String.initUtf8(isolate, class_name); + global_template.set(v8_class_name.toName(), templates[i], v8.PropertyAttribute.None); } } diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html new file mode 100644 index 000000000..5ad6454df --- /dev/null +++ b/src/browser/tests/element/html/image.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 2cbd2634d..86f82d1d7 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -1,42 +1,120 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - +const std = @import("std"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +pub fn registerTypes() []const type { + return &.{ + Image, + // Factory, + }; +} + const Image = @This(); _proto: *HtmlElement, +pub fn constructor(w_: ?u32, h_: ?u32, page: *Page) !*Image { + const node = try page.createElement(null, "img", null); + const el = node.as(Element); + + if (w_) |w| blk: { + const w_string = std.fmt.bufPrint(&page.buf, "{d}", .{w}) catch break :blk; + try el.setAttributeSafe("width", w_string, page); + } + if (h_) |h| blk: { + const h_string = std.fmt.bufPrint(&page.buf, "{d}", .{h}) catch break :blk; + try el.setAttributeSafe("height", h_string, page); + } + return el.as(Image); +} + pub fn asElement(self: *Image) *Element { return self._proto._proto; } +pub fn asConstElement(self: *const Image) *const Element { + return self._proto._proto; +} pub fn asNode(self: *Image) *Node { return self.asElement().asNode(); } +pub fn getSrc(self: *const Image) []const u8 { + return self.asConstElement().getAttributeSafe("src") orelse ""; +} + +pub fn setSrc(self: *Image, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("src", value, page); +} + +pub fn getAlt(self: *const Image) []const u8 { + return self.asConstElement().getAttributeSafe("alt") orelse ""; +} + +pub fn setAlt(self: *Image, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("alt", value, page); +} + +pub fn getWidth(self: *const Image) u32 { + const attr = self.asConstElement().getAttributeSafe("width") orelse return 0; + return std.fmt.parseUnsigned(u32, attr, 10) catch 0; +} + +pub fn setWidth(self: *Image, value: u32, page: *Page) !void { + const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); + try self.asElement().setAttributeSafe("width", str, page); +} + +pub fn getHeight(self: *const Image) u32 { + const attr = self.asConstElement().getAttributeSafe("height") orelse return 0; + return std.fmt.parseUnsigned(u32, attr, 10) catch 0; +} + +pub fn setHeight(self: *Image, value: u32, page: *Page) !void { + const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); + try self.asElement().setAttributeSafe("height", str, page); +} + +pub fn getCrossOrigin(self: *const Image) ?[]const u8 { + return self.asConstElement().getAttributeSafe("crossorigin"); +} + +pub fn setCrossOrigin(self: *Image, value: ?[]const u8, page: *Page) !void { + if (value) |v| { + return self.asElement().setAttributeSafe("crossorigin", v, page); + } + return self.asElement().removeAttribute("crossorigin", page); +} + +pub fn getLoading(self: *const Image) []const u8 { + return self.asConstElement().getAttributeSafe("loading") orelse "eager"; +} + +pub fn setLoading(self: *Image, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("loading", value, page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Image); pub const Meta = struct { pub const name = "HTMLImageElement"; + pub const constructor_alias = "Image"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const constructor = bridge.constructor(Image.constructor, .{}); + pub const src = bridge.accessor(Image.getSrc, Image.setSrc, .{}); + pub const alt = bridge.accessor(Image.getAlt, Image.setAlt, .{}); + pub const width = bridge.accessor(Image.getWidth, Image.setWidth, .{}); + pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{}); + pub const crossOrigin = bridge.accessor(Image.getCrossOrigin, Image.setCrossOrigin, .{}); + pub const loading = bridge.accessor(Image.getLoading, Image.setLoading, .{}); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Image" { + try testing.htmlRunner("element/html/image.html", .{}); +} From 94bcb30f115a65ec8050e6d9059211329ce2fbe2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Nov 2025 18:54:11 +0800 Subject: [PATCH 097/257] fetch response headers --- src/browser/webapi/element/html/Image.zig | 7 ------ src/browser/webapi/net/Fetch.zig | 29 ++++++++++++++++------- src/browser/webapi/net/Response.zig | 23 +++++++++--------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 86f82d1d7..9576fde75 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -5,13 +5,6 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); -pub fn registerTypes() []const type { - return &.{ - Image, - // Factory, - }; -} - const Image = @This(); _proto: *HtmlElement, diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 547a6ab1c..d3589ee2d 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -24,6 +24,7 @@ const Http = @import("../../../http/Http.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Headers = @import("Headers.zig"); const Request = @import("Request.zig"); const Response = @import("Response.zig"); @@ -32,20 +33,22 @@ const Allocator = std.mem.Allocator; const Fetch = @This(); _page: *Page, -_response: std.ArrayList(u8), +_buf: std.ArrayList(u8), +_response: *Response, _resolver: js.PersistentPromiseResolver, pub const Input = Request.Input; -// @ZIGDOM just enough to get campire demo working +// @ZIGDOM just enough to get campfire demo working pub fn init(input: Input, page: *Page) !js.Promise { const request = try Request.init(input, null, page); const fetch = try page.arena.create(Fetch); fetch.* = .{ ._page = page, - ._response = .empty, + ._buf = .empty, ._resolver = try page.js.createPromiseResolver(.page), + ._response = try Response.init(null, .{ .status = 0 }, page), }; const http_client = page._session.browser.http_client; @@ -68,20 +71,28 @@ pub fn init(input: Input, page: *Page) !js.Promise { fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); - _ = self; + + if (transfer.getContentLength()) |cl| { + try self._buf.ensureTotalCapacity(self._page.arena, cl); + } + + const res = self._response; + res._status = transfer.response_header.?.status; + var it = transfer.responseHeaderIterator(); + while (it.next()) |hdr| { + try res._headers.append(hdr.name, hdr.value, self._page); + } } fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); - try self._response.appendSlice(self._page.arena, data); + try self._buf.appendSlice(self._page.arena, data); } fn httpDoneCallback(ctx: *anyopaque) !void { const self: *Fetch = @ptrCast(@alignCast(ctx)); - - const page = self._page; - const res = try Response.initFromFetch(page.arena, self._response.items, page); - return self._resolver.resolve(res); + self._response._body = self._buf.items; + return self._resolver.resolve(self._response); } fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index de1c151b1..bc66fb001 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -26,8 +26,9 @@ const Allocator = std.mem.Allocator; const Response = @This(); _status: u16, -_data: []const u8, _arena: Allocator, +_headers: *Headers, +_body: []const u8, const InitOpts = struct { status: u16 = 200, @@ -39,17 +40,10 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { const opts = opts_ orelse InitOpts{}; return page._factory.create(Response{ - ._status = opts.status, - ._data = if (body_) |b| try page.arena.dupe(u8, b) else "", ._arena = page.arena, - }); -} - -pub fn initFromFetch(arena: Allocator, data: []const u8, page: *Page) !*Response { - return page._factory.create(Response{ - ._status = 200, - ._data = data, - ._arena = arena, + ._status = opts.status, + ._body = if (body_) |b| try page.arena.dupe(u8, b) else "", + ._headers = opts.headers orelse try Headers.init(page), }); } @@ -57,6 +51,10 @@ pub fn getStatus(self: *const Response) u16 { return self._status; } +pub fn getHeaders(self: *const Response) *Headers { + return self._headers; +} + pub fn isOK(self: *const Response) bool { return self._status >= 200 and self._status <= 299; } @@ -65,7 +63,7 @@ pub fn getJson(self: *Response, page: *Page) !js.Promise { const value = std.json.parseFromSliceLeaky( std.json.Value, page.call_arena, - self._data, + self._body, .{}, ) catch |err| { return page.js.rejectPromise(.{@errorName(err)}); @@ -86,4 +84,5 @@ pub const JsApi = struct { pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); pub const json = bridge.function(Response.getJson, .{}); + pub const headers = bridge.accessor(Response.getHeaders, null, .{}); }; From 8ce8c7a0f35b20af5b022a0aa255e75186566e1d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 27 Nov 2025 12:55:48 -0800 Subject: [PATCH 098/257] use _prototype_root decl everywhere --- src/browser/Factory.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 8a0893e1f..a915d74af 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -303,7 +303,7 @@ pub fn destroy(self: *Factory, value: anytype) void { if (comptime IS_DEBUG) { // We should always destroy from the leaf down. - if (@hasField(S, "_type") and @typeInfo(@TypeOf(value._type)) == .@"union") { + if (@hasDecl(S, "_prototype_root")) { // A Event{._type == .generic} (or any other similar types) // _should_ be destoyed directly. The _type = .generic is a pseudo // child From 34c10e1e4889aae5511ea677e9d9ff557f9e8097 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 27 Nov 2025 13:10:35 -0800 Subject: [PATCH 099/257] fix svgElement + allow base tags --- src/browser/Factory.zig | 11 ++++++++--- src/browser/Page.zig | 31 +++++++++++++++---------------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index a915d74af..2a4a06276 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -241,18 +241,23 @@ pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) { pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); + const ChildT = @TypeOf(child); - // will never allocate, can't fail - const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable; + if (ChildT == Element.Svg) { + return self.element(child); + } const chain = try PrototypeChain( - &.{ EventTarget, Node, Element, Element.Svg, @TypeOf(child) }, + &.{ EventTarget, Node, Element, Element.Svg, ChildT }, ).allocate(allocator); chain.setRoot(EventTarget.Type); chain.setMiddle(1, Node.Type); chain.setMiddle(2, Element.Type); + // will never allocate, can't fail + const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable; + // Manually set Element.Svg with the tag_name chain.set(3, .{ ._proto = chain.get(2), diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 4cc4e3e17..4ca3241cc 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1177,22 +1177,21 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ else => {}, } - // TODO: uncomment - // if (namespace == .svg) { - // const tag_name = try String.init(self.arena, name, .{}); - // if (std.ascii.eqlIgnoreCase(name, "svg")) { - // return self.createSvgElementT(Element.Svg, name, attribute_iterator, .{ - // ._proto = undefined, - // ._type = .svg, - // ._tag_name = tag_name, - // }); - // } - - // // Other SVG elements (rect, circle, text, g, etc.) - // const lower = std.ascii.lowerString(&self.buf, name); - // const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown; - // return self.createSvgElementT(Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag }); - // } + if (namespace == .svg) { + const tag_name = try String.init(self.arena, name, .{}); + if (std.ascii.eqlIgnoreCase(name, "svg")) { + return self.createSvgElementT(Element.Svg, name, attribute_iterator, .{ + ._proto = undefined, + ._type = .svg, + ._tag_name = tag_name, + }); + } + + // Other SVG elements (rect, circle, text, g, etc.) + const lower = std.ascii.lowerString(&self.buf, name); + const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown; + return self.createSvgElementT(Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag }); + } const tag_name = try String.init(self.arena, name, .{}); From 833a33678cea50792b3afdba578b24f2914d9af3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Nov 2025 13:04:42 +0800 Subject: [PATCH 100/257] call AttributeChangedCallback on upgrade --- src/browser/ScriptManager.zig | 6 ++-- .../custom_elements/attribute_changed.html | 13 ++++---- .../tests/custom_elements/upgrade.html | 30 +++++++++++++++++++ src/browser/webapi/CustomElementRegistry.zig | 18 ++++++++--- src/browser/webapi/Element.zig | 4 +-- src/browser/webapi/Window.zig | 14 +++++++++ src/browser/webapi/net/Fetch.zig | 5 ++++ src/browser/webapi/net/XMLHttpRequest.zig | 3 +- 8 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index f037713f7..a1df242e4 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -215,14 +215,12 @@ pub fn addFromElement(self: *ScriptManager, script_element: *Element.Html.Script .url = remote_url orelse page.url, .mode = blk: { if (source == .@"inline") { - // inline modules are deferred, all other inline scripts have a - // normal execution flow - break :blk if (kind == .module) .@"defer" else .normal; + break :blk .normal; } if (element.getAttributeSafe("async") != null) { break :blk .async; } - if (element.getAttributeSafe("defer") != null) { + if (kind == .module or element.getAttributeSafe("defer") != null) { break :blk .@"defer"; } break :blk .normal; diff --git a/src/browser/tests/custom_elements/attribute_changed.html b/src/browser/tests/custom_elements/attribute_changed.html index 24a8a48d7..f94de7f35 100644 --- a/src/browser/tests/custom_elements/attribute_changed.html +++ b/src/browser/tests/custom_elements/attribute_changed.html @@ -122,13 +122,16 @@ testing.expectEqual(0, callbackCalls.length); customElements.define('upgrade-attr-element', UpgradeAttrElement); - testing.expectEqual(0, callbackCalls.length); - - el.setAttribute('existing', 'after-upgrade'); testing.expectEqual(1, callbackCalls.length); testing.expectEqual('existing', callbackCalls[0].name); - testing.expectEqual('before-upgrade', callbackCalls[0].oldValue); - testing.expectEqual('after-upgrade', callbackCalls[0].newValue); + testing.expectEqual(null, callbackCalls[0].oldValue); + testing.expectEqual('before-upgrade', callbackCalls[0].newValue); + + el.setAttribute('existing', 'after-upgrade'); + testing.expectEqual(2, callbackCalls.length); + testing.expectEqual('existing', callbackCalls[1].name); + testing.expectEqual('before-upgrade', callbackCalls[1].oldValue); + testing.expectEqual('after-upgrade', callbackCalls[1].newValue); } { diff --git a/src/browser/tests/custom_elements/upgrade.html b/src/browser/tests/custom_elements/upgrade.html index 44f37bd65..73f1367c4 100644 --- a/src/browser/tests/custom_elements/upgrade.html +++ b/src/browser/tests/custom_elements/upgrade.html @@ -151,4 +151,34 @@ customElements.upgrade(elem); testing.expectEqual(1, alreadyUpgradedCalled); } + +{ + let attributeChangedCalls = []; + + class UpgradeWithAttrs extends HTMLElement { + static get observedAttributes() { + return ['data-foo', 'data-bar']; + } + + attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCalls.push({ name, oldValue, newValue }); + } + } + + const container = document.createElement('div'); + container.innerHTML = ''; + document.body.appendChild(container); + + testing.expectEqual(0, attributeChangedCalls.length); + + customElements.define('upgrade-with-attrs', UpgradeWithAttrs); + + testing.expectEqual(2, attributeChangedCalls.length); + testing.expectEqual('data-foo', attributeChangedCalls[0].name); + testing.expectEqual(null, attributeChangedCalls[0].oldValue); + testing.expectEqual('hello', attributeChangedCalls[0].newValue); + testing.expectEqual('data-bar', attributeChangedCalls[1].name); + testing.expectEqual(null, attributeChangedCalls[1].oldValue); + testing.expectEqual('world', attributeChangedCalls[1].newValue); +} diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 9c2951701..318b80e6b 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -18,10 +18,13 @@ const std = @import("std"); const log = @import("../../log.zig"); + const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); -const Element = @import("Element.zig"); + const Node = @import("Node.zig"); +const Element = @import("Element.zig"); +const Custom = @import("element/html/Custom.zig"); const CustomElementDefinition = @import("CustomElementDefinition.zig"); const CustomElementRegistry = @This(); @@ -119,8 +122,6 @@ fn upgradeNode(self: *CustomElementRegistry, node: *Node, page: *Page) !void { } fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) !void { - const Custom = @import("element/html/Custom.zig"); - const custom = element.is(Custom) orelse { return Custom.checkAndAttachBuiltIn(element, page); }; @@ -133,7 +134,7 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) try upgradeCustomElement(custom, definition, page); } -fn upgradeCustomElement(custom: *@import("element/html/Custom.zig"), definition: *CustomElementDefinition, page: *Page) !void { +fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void { custom._definition = definition; // Reset callback flags since this is a fresh upgrade @@ -151,6 +152,15 @@ fn upgradeCustomElement(custom: *@import("element/html/Custom.zig"), definition: return error.CustomElementUpgradeFailed; }; + // Invoke attributeChangedCallback for existing observed attributes + var attr_it = custom.asElement().attributeIterator(); + while (attr_it.next()) |attr| { + const name = attr._name.str(); + if (definition.isAttributeObserved(name)) { + custom.invokeAttributeChangedCallback(name, null, attr._value.str(), page); + } + } + if (node.isConnected()) { custom.invokeConnectedCallback(page); } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c688d6a8b..36215d479 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -272,9 +272,9 @@ pub fn setClassName(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe("class", value, page); } -pub fn attributeIterator(self: *Element) Attribute.Iterator { +pub fn attributeIterator(self: *Element) Attribute.InnerIterator { const attributes = self._attributes orelse return .{}; - return attributes.iterator(self); + return attributes.iterator(); } pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]const u8 { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 16562980d..f025d1ed6 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -54,6 +54,7 @@ _history: History, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, _on_error: ?js.Function = null, // TODO: invoke on error? +_on_unhandled_rejection: ?js.Function = null, // TODO: invoke on error _location: *Location, _timer_id: u30 = 0, _timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, @@ -143,6 +144,18 @@ pub fn setOnError(self: *Window, cb_: ?js.Function) !void { } } +pub fn getOnUnhandledRejection(self: *const Window) ?js.Function { + return self._on_unhandled_rejection; +} + +pub fn setOnUnhandledRejection(self: *Window, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_unhandled_rejection = cb; + } else { + self._on_unhandled_rejection = null; + } +} + pub fn fetch(_: *const Window, input: Fetch.Input, page: *Page) !js.Promise { return Fetch.init(input, page); } @@ -390,6 +403,7 @@ pub const JsApi = struct { pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); + pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const fetch = bridge.function(Window.fetch, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); pub const setTimeout = bridge.function(Window.setTimeout, .{}); diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index d3589ee2d..6fd4c4fde 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -29,6 +29,7 @@ const Request = @import("Request.zig"); const Response = @import("Response.zig"); const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; const Fetch = @This(); @@ -54,6 +55,10 @@ pub fn init(input: Input, page: *Page) !js.Promise { const http_client = page._session.browser.http_client; const headers = try http_client.newHeaders(); + if (comptime IS_DEBUG) { + log.debug(.http, "fetch", .{ .url = request._url }); + } + try http_client.request(.{ .ctx = fetch, .url = request._url, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 6239ddc42..3bb219e2f 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -19,8 +19,6 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const IS_DEBUG = @import("builtin").mode == .Debug; - const log = @import("../../../log.zig"); const Http = @import("../../../http/Http.zig"); @@ -32,6 +30,7 @@ const EventTarget = @import("../EventTarget.zig"); const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; const XMLHttpRequest = @This(); _page: *Page, From 8858f889b4066104947ba1d376af03f5018176b7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Nov 2025 18:01:41 +0800 Subject: [PATCH 101/257] Window.scrollX/Y, postMessage, more custom element edge cases --- src/browser/js/bridge.zig | 1 + .../custom_elements/attribute_changed.html | 4 + .../tests/custom_elements/upgrade.html | 171 ++++++++++++++++++ src/browser/tests/event/message.html | 170 +++++++++++++++++ .../event/message_multiple_listeners.html | 19 ++ src/browser/webapi/CustomElementRegistry.zig | 7 +- src/browser/webapi/Event.zig | 3 +- src/browser/webapi/Window.zig | 96 ++++++++-- src/browser/webapi/element/html/Custom.zig | 10 + src/browser/webapi/event/MessageEvent.zig | 90 +++++++++ 10 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 src/browser/tests/event/message.html create mode 100644 src/browser/tests/event/message_multiple_listeners.html create mode 100644 src/browser/webapi/event/MessageEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 850c80703..68f5e4891 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -550,6 +550,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Event.zig"), @import("../webapi/event/CustomEvent.zig"), @import("../webapi/event/ErrorEvent.zig"), + @import("../webapi/event/MessageEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), diff --git a/src/browser/tests/custom_elements/attribute_changed.html b/src/browser/tests/custom_elements/attribute_changed.html index f94de7f35..2ebbf99e2 100644 --- a/src/browser/tests/custom_elements/attribute_changed.html +++ b/src/browser/tests/custom_elements/attribute_changed.html @@ -122,6 +122,10 @@ testing.expectEqual(0, callbackCalls.length); customElements.define('upgrade-attr-element', UpgradeAttrElement); + testing.expectEqual(0, callbackCalls.length); + + document.body.appendChild(el); + testing.expectEqual(1, callbackCalls.length); testing.expectEqual('existing', callbackCalls[0].name); testing.expectEqual(null, callbackCalls[0].oldValue); diff --git a/src/browser/tests/custom_elements/upgrade.html b/src/browser/tests/custom_elements/upgrade.html index 73f1367c4..3827ca826 100644 --- a/src/browser/tests/custom_elements/upgrade.html +++ b/src/browser/tests/custom_elements/upgrade.html @@ -181,4 +181,175 @@ testing.expectEqual(null, attributeChangedCalls[1].oldValue); testing.expectEqual('world', attributeChangedCalls[1].newValue); } + +{ + let attributeChangedCalls = []; + let connectedCalls = 0; + + class DetachedWithAttrs extends HTMLElement { + static get observedAttributes() { + return ['foo']; + } + + attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCalls.push({ name, oldValue, newValue }); + } + + connectedCallback() { + connectedCalls++; + } + } + + const container = document.createElement('div'); + container.innerHTML = ''; + + testing.expectEqual(0, attributeChangedCalls.length); + + customElements.define('detached-with-attrs', DetachedWithAttrs); + + testing.expectEqual(0, attributeChangedCalls.length); + testing.expectEqual(0, connectedCalls); + + document.body.appendChild(container); + + testing.expectEqual(1, attributeChangedCalls.length); + testing.expectEqual('foo', attributeChangedCalls[0].name); + testing.expectEqual(null, attributeChangedCalls[0].oldValue); + testing.expectEqual('bar', attributeChangedCalls[0].newValue); + testing.expectEqual(1, connectedCalls); +} + +{ + let attributeChangedCalls = []; + let constructorCalled = 0; + + class ManualUpgradeWithAttrs extends HTMLElement { + static get observedAttributes() { + return ['x', 'y']; + } + + constructor() { + super(); + constructorCalled++; + } + + attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCalls.push({ name, oldValue, newValue }); + } + } + + customElements.define('manual-upgrade-with-attrs', ManualUpgradeWithAttrs); + + const container = document.createElement('div'); + container.innerHTML = ''; + + testing.expectEqual(1, constructorCalled); + testing.expectEqual(2, attributeChangedCalls.length); + + const elem = container.querySelector('manual-upgrade-with-attrs'); + elem.setAttribute('z', '3'); + + customElements.upgrade(container); + + testing.expectEqual(1, constructorCalled); + testing.expectEqual(2, attributeChangedCalls.length); +} + +{ + let attributeChangedCalls = []; + + class MixedAttrs extends HTMLElement { + static get observedAttributes() { + return ['watched']; + } + + attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCalls.push({ name, oldValue, newValue }); + } + } + + const container = document.createElement('div'); + container.innerHTML = ''; + document.body.appendChild(container); + + testing.expectEqual(0, attributeChangedCalls.length); + + customElements.define('mixed-attrs', MixedAttrs); + + testing.expectEqual(1, attributeChangedCalls.length); + testing.expectEqual('watched', attributeChangedCalls[0].name); + testing.expectEqual('yes', attributeChangedCalls[0].newValue); +} + +{ + let attributeChangedCalls = []; + + class EmptyAttr extends HTMLElement { + static get observedAttributes() { + return ['empty', 'non-empty']; + } + + attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCalls.push({ name, oldValue, newValue }); + } + } + + const container = document.createElement('div'); + container.innerHTML = ''; + document.body.appendChild(container); + + customElements.define('empty-attr', EmptyAttr); + + testing.expectEqual(2, attributeChangedCalls.length); + testing.expectEqual('empty', attributeChangedCalls[0].name); + testing.expectEqual('', attributeChangedCalls[0].newValue); + testing.expectEqual('non-empty', attributeChangedCalls[1].name); + testing.expectEqual('value', attributeChangedCalls[1].newValue); +} + +{ + let parentCalls = []; + let childCalls = []; + + class NestedParent extends HTMLElement { + static get observedAttributes() { + return ['parent-attr']; + } + + attributeChangedCallback(name, oldValue, newValue) { + parentCalls.push({ name, oldValue, newValue }); + } + } + + class NestedChild extends HTMLElement { + static get observedAttributes() { + return ['child-attr']; + } + + attributeChangedCallback(name, oldValue, newValue) { + childCalls.push({ name, oldValue, newValue }); + } + } + + const container = document.createElement('div'); + container.innerHTML = ''; + document.body.appendChild(container); + + testing.expectEqual(0, parentCalls.length); + testing.expectEqual(0, childCalls.length); + + customElements.define('nested-parent', NestedParent); + + testing.expectEqual(1, parentCalls.length); + testing.expectEqual('parent-attr', parentCalls[0].name); + testing.expectEqual('p', parentCalls[0].newValue); + testing.expectEqual(0, childCalls.length); + + customElements.define('nested-child', NestedChild); + + testing.expectEqual(1, parentCalls.length); + testing.expectEqual(1, childCalls.length); + testing.expectEqual('child-attr', childCalls[0].name); + testing.expectEqual('c', childCalls[0].newValue); +} diff --git a/src/browser/tests/event/message.html b/src/browser/tests/event/message.html new file mode 100644 index 000000000..079f9c7a3 --- /dev/null +++ b/src/browser/tests/event/message.html @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/event/message_multiple_listeners.html b/src/browser/tests/event/message_multiple_listeners.html new file mode 100644 index 000000000..36f13eb29 --- /dev/null +++ b/src/browser/tests/event/message_multiple_listeners.html @@ -0,0 +1,19 @@ + + + + diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 318b80e6b..6fc67621d 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -92,6 +92,11 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu continue; } + if (!custom.asElement().asNode().isConnected()) { + idx += 1; + continue; + } + upgradeCustomElement(custom, definition, page) catch { _ = page._undefined_custom_elements.swapRemove(idx); continue; @@ -134,7 +139,7 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) try upgradeCustomElement(custom, definition, page); } -fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void { +pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void { custom._definition = definition; // Reset callback flags since this is a fresh upgrade diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index b11a83faf..56461eb5b 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -49,9 +49,10 @@ pub const EventPhase = enum(u8) { pub const Type = union(enum) { generic, - progress_event: *@import("event/ProgressEvent.zig"), error_event: *@import("event/ErrorEvent.zig"), custom_event: *@import("event/CustomEvent.zig"), + message_event: *@import("event/MessageEvent.zig"), + progress_event: *@import("event/ProgressEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f025d1ed6..ba3683b6c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -34,6 +34,7 @@ const Location = @import("Location.zig"); const Fetch = @import("net/Fetch.zig"); const EventTarget = @import("EventTarget.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); const Element = @import("Element.zig"); @@ -255,6 +256,28 @@ pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !*CSSStyleDe return CSSStyleDeclaration.init(null, page); } +pub fn postMessage(self: *Window, message: js.Object, target_origin: ?[]const u8, page: *Page) !void { + // For now, we ignore targetOrigin checking and just dispatch the message + // In a full implementation, we would validate the origin + _ = target_origin; + + // postMessage queues a task (not a microtask), so use the scheduler + const origin = try self._location.getOrigin(page); + const callback = try page._factory.create(PostMessageCallback{ + .window = self, + .message = try message.persist() , + .origin = try page.arena.dupe(u8, origin), + .page = page, + }); + errdefer page._factory.destroy(callback); + + + try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ + .name = "postMessage", + .low_priority = false, + }); +} + pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { const encoded_len = std.base64.standard.Encoder.calcSize(input.len); const encoded = try page.call_arena.alloc(u8, encoded_len); @@ -268,6 +291,26 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } +pub fn getLength(_: *const Window) u32 { + return 0; +} + +pub fn getInnerWidth(_: *const Window) u32 { + return 1920; +} + +pub fn getInnerHeight(_: *const Window) u32 { + return 1080; +} + +pub fn getScrollX(_: *const Window) u32 { + return 0; +} + +pub fn getScrollY(_: *const Window) u32 { + return 0; +} + const ScheduleOpts = struct { repeat: bool, params: []js.Object, @@ -376,6 +419,35 @@ const ScheduleCallback = struct { } }; +const PostMessageCallback = struct { + window: *Window, + message: js.Object, + origin: []const u8, + page: *Page, + + fn deinit(self: *PostMessageCallback) void { + self.page._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const message_event = try MessageEvent.init("message", .{ + .data = self.message, + .origin = self.origin, + .source = self.window, + .bubbles = false, + .cancelable = false, + }, self.page); + + const event = message_event.asEvent(); + try self.page._event_manager.dispatch(self.window.asEventTarget(), event); + + return null; + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(Window); @@ -415,27 +487,19 @@ pub const JsApi = struct { pub const requestAnimationFrame = bridge.function(Window.requestAnimationFrame, .{}); pub const cancelAnimationFrame = bridge.function(Window.cancelAnimationFrame, .{}); pub const matchMedia = bridge.function(Window.matchMedia, .{}); + pub const postMessage = bridge.function(Window.postMessage, .{}); pub const btoa = bridge.function(Window.btoa, .{}); pub const atob = bridge.function(Window.atob, .{}); pub const reportError = bridge.function(Window.reportError, .{}); pub const frames = bridge.accessor(Window.getWindow, null, .{ .cache = "frames" }); - pub const length = bridge.accessor(struct { - fn wrap(_: *const Window) u32 { - return 0; - } - }.wrap, null, .{ .cache = "length" }); - - pub const innerWidth = bridge.accessor(struct { - fn wrap(_: *const Window) u32 { - return 1920; - } - }.wrap, null, .{ .cache = "innerWidth" }); - pub const innerHeight = bridge.accessor(struct { - fn wrap(_: *const Window) u32 { - return 1080; - } - }.wrap, null, .{ .cache = "innerHeight" }); pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{}); + pub const length = bridge.accessor(Window.getLength, null, .{ .cache = "length" }); + pub const innerWidth = bridge.accessor(Window.getInnerWidth, null, .{ .cache = "innerWidth" }); + pub const innerHeight = bridge.accessor(Window.getInnerHeight, null, .{ .cache = "innerHeight" }); + pub const scrollX = bridge.accessor(Window.getScrollX, null, .{ .cache = "scrollX" }); + pub const scrollY = bridge.accessor(Window.getScrollY, null, .{ .cache = "scrollY" }); + pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{ .cache = "pageXOffset" }); + pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{ .cache = "pageYOffset" }); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index a8c95d5c8..3fc9071fa 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -73,6 +73,16 @@ pub fn invokeAttributeChangedCallback(self: *Custom, name: []const u8, old_value pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, page: *Page) !void { // Autonomous custom element if (element.is(Custom)) |custom| { + // If the element is undefined, check if a definition now exists and upgrade + if (custom._definition == null) { + const name = custom._tag_name.str(); + if (page.window._custom_elements._definitions.get(name)) |definition| { + const CustomElementRegistry = @import("../../CustomElementRegistry.zig"); + CustomElementRegistry.upgradeCustomElement(custom, definition, page) catch {}; + return; + } + } + if (comptime from_parser) { // From parser, we know the element is brand new custom._connected_callback_invoked = true; diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig new file mode 100644 index 000000000..ed59bb2f4 --- /dev/null +++ b/src/browser/webapi/event/MessageEvent.zig @@ -0,0 +1,90 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const Event = @import("../Event.zig"); +const Window = @import("../Window.zig"); + +const MessageEvent = @This(); + +_proto: *Event, +_data: ?js.Object = null, +_origin: []const u8 = "", +_source: ?*Window = null, + +pub const InitOptions = struct { + data: ?js.Object = null, + origin: ?[]const u8 = null, + source: ?*Window = null, + bubbles: bool = false, + cancelable: bool = false, +}; + +pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*MessageEvent { + const opts = opts_ orelse InitOptions{}; + + const event = try page._factory.event(typ, MessageEvent{ + ._proto = undefined, + ._data = if (opts.data) |d| try d.persist() else null, + ._origin = if (opts.origin) |str| try page.arena.dupe(u8, str) else "", + ._source = opts.source, + }); + + event._proto._bubbles = opts.bubbles; + event._proto._cancelable = opts.cancelable; + + return event; +} + +pub fn asEvent(self: *MessageEvent) *Event { + return self._proto; +} + +pub fn getData(self: *const MessageEvent) ?js.Object { + return self._data; +} + +pub fn getOrigin(self: *const MessageEvent) []const u8 { + return self._origin; +} + +pub fn getSource(self: *const MessageEvent) ?*Window { + return self._source; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MessageEvent); + + pub const Meta = struct { + pub const name = "MessageEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(MessageEvent.init, .{}); + pub const data = bridge.accessor(MessageEvent.getData, null, .{}); + pub const origin = bridge.accessor(MessageEvent.getOrigin, null, .{}); + pub const source = bridge.accessor(MessageEvent.getSource, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: MessageEvent" { + try testing.htmlRunner("event/message.html", .{}); +} From 9f587ab24b0632e3b488d65ee97668cd0c787e97 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Nov 2025 22:11:55 +0800 Subject: [PATCH 102/257] MessageChannel and MessagePort --- src/browser/EventManager.zig | 2 +- src/browser/Page.zig | 3 + src/browser/Scheduler.zig | 11 +- src/browser/ScriptManager.zig | 4 + src/browser/js/bridge.zig | 2 + src/browser/tests/message_channel.html | 86 +++++++++++++ src/browser/webapi/EventTarget.zig | 2 + src/browser/webapi/MessageChannel.zig | 66 ++++++++++ src/browser/webapi/MessagePort.zig | 169 +++++++++++++++++++++++++ 9 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/message_channel.html create mode 100644 src/browser/webapi/MessageChannel.zig create mode 100644 src/browser/webapi/MessagePort.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 3eb02bae6..a138c44fc 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -117,7 +117,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list => { + .xhr, .window, .abort_signal, .media_query_list, .message_port => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 37d947c49..f237c315f 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -702,6 +702,9 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { } pub fn tick(self: *Page) void { + if (comptime IS_DEBUG) { + log.debug(.page, "tick", .{}); + } _ = self.scheduler.run() catch |err| { log.err(.page, "tick", .{ .err = err }); }; diff --git a/src/browser/Scheduler.zig b/src/browser/Scheduler.zig index 6ad048877..78a7ca1e4 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/Scheduler.zig @@ -26,17 +26,22 @@ const IS_DEBUG = builtin.mode == .Debug; const Queue = std.PriorityQueue(Task, void, struct { fn compare(_: void, a: Task, b: Task) std.math.Order { - return std.math.order(a.run_at, b.run_at); + const time_order = std.math.order(a.run_at, b.run_at); + if (time_order != .eq) return time_order; + // Break ties with sequence number to maintain FIFO order + return std.math.order(a.sequence, b.sequence); } }.compare); const Scheduler = @This(); +_sequence: u64, low_priority: Queue, high_priority: Queue, pub fn init(allocator: std.mem.Allocator) Scheduler { return .{ + ._sequence = 0, .low_priority = Queue.init(allocator, {}), .high_priority = Queue.init(allocator, {}), }; @@ -59,9 +64,12 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts log.debug(.scheduler, "scheduler.add", .{ .name = opts.name, .run_in_ms = run_in_ms, .low_priority = opts.low_priority }); } var queue = if (opts.low_priority) &self.low_priority else &self.high_priority; + const seq = self._sequence + 1; + self._sequence = seq; return queue.add(.{ .ctx = ctx, .callback = cb, + .sequence = seq, .name = opts.name, .run_at = timestamp(.monotonic) + run_in_ms, }); @@ -105,6 +113,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { const Task = struct { run_at: u64, + sequence: u64, ctx: *anyopaque, name: []const u8, callback: Callback, diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index a1df242e4..ca098e9f4 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -751,6 +751,10 @@ const Script = struct { break :blk true; }; + if (comptime IS_DEBUG) { + log.info(.browser, "executed script", .{.src = url}); + } + defer page.tick(); if (success) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 68f5e4891..d3d983d4e 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -552,6 +552,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ErrorEvent.zig"), @import("../webapi/event/MessageEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), + @import("../webapi/MessageChannel.zig"), + @import("../webapi/MessagePort.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), diff --git a/src/browser/tests/message_channel.html b/src/browser/tests/message_channel.html new file mode 100644 index 000000000..0a9848f78 --- /dev/null +++ b/src/browser/tests/message_channel.html @@ -0,0 +1,86 @@ + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 23ecdf985..e0d0acd16 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -34,6 +34,7 @@ pub const Type = union(enum) { xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), + message_port: *@import("MessagePort.zig"), }; pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { @@ -101,6 +102,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), + .message_port => writer.writeAll(""), }; } diff --git a/src/browser/webapi/MessageChannel.zig b/src/browser/webapi/MessageChannel.zig new file mode 100644 index 000000000..766310133 --- /dev/null +++ b/src/browser/webapi/MessageChannel.zig @@ -0,0 +1,66 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const MessagePort = @import("MessagePort.zig"); + +const MessageChannel = @This(); + +_port1: *MessagePort, +_port2: *MessagePort, + +pub fn init(page: *Page) !*MessageChannel { + const port1 = try MessagePort.init(page); + const port2 = try MessagePort.init(page); + + MessagePort.entangle(port1, port2); + + return page._factory.create(MessageChannel{ + ._port1 = port1, + ._port2 = port2, + }); +} + + +pub fn getPort1(self: *const MessageChannel) *MessagePort { + return self._port1; +} + +pub fn getPort2(self: *const MessageChannel) *MessagePort { + return self._port2; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MessageChannel); + + pub const Meta = struct { + pub const name = "MessageChannel"; + pub var class_id: bridge.ClassId = undefined; + pub const prototype_chain = bridge.prototypeChain(); + }; + + pub const constructor = bridge.constructor(MessageChannel.init, .{}); + pub const port1 = bridge.accessor(MessageChannel.getPort1, null, .{}); + pub const port2 = bridge.accessor(MessageChannel.getPort2, null, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: MessageChannel" { + try testing.htmlRunner("message_channel.html", .{}); +} diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig new file mode 100644 index 000000000..65a7d36b9 --- /dev/null +++ b/src/browser/webapi/MessagePort.zig @@ -0,0 +1,169 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../js/js.zig"); +const log = @import("../../log.zig"); + +const Page = @import("../Page.zig"); +const EventTarget = @import("EventTarget.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); + +const MessagePort = @This(); + +_proto: *EventTarget, +_enabled: bool = false, +_closed: bool = false, +_on_message: ?js.Function = null, +_on_message_error: ?js.Function = null, +_entangled_port: ?*MessagePort = null, + +pub fn init(page: *Page) !*MessagePort { + return page._factory.eventTarget(MessagePort{ + ._proto = undefined, + }); +} + +pub fn asEventTarget(self: *MessagePort) *EventTarget { + return self._proto; +} + +pub fn entangle(port1: *MessagePort, port2: *MessagePort) void { + port1._entangled_port = port2; + port2._entangled_port = port1; +} + +pub fn postMessage(self: *MessagePort, message: js.Object, page: *Page) !void { + if (self._closed) { + return; + } + + const other = self._entangled_port orelse return; + if (other._closed) { + return; + } + + // Create callback to deliver message + const callback = try page._factory.create(PostMessageCallback{ + .page = page, + .port = other, + .message = try message.persist(), + }); + + try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ + .name = "MessagePort.postMessage", + .low_priority = false, + }); +} + +pub fn start(self: *MessagePort) void { + if (self._closed) { + return; + } + self._enabled = true; +} + +pub fn close(self: *MessagePort) void { + self._closed = true; + + // Break entanglement + if (self._entangled_port) |other| { + other._entangled_port = null; + } + self._entangled_port = null; +} + +pub fn getOnMessage(self: *const MessagePort) ?js.Function { + return self._on_message; +} + +pub fn setOnMessage(self: *MessagePort, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_message = cb; + } else { + self._on_message = null; + } +} + +pub fn getOnMessageError(self: *const MessagePort) ?js.Function { + return self._on_message_error; +} + +pub fn setOnMessageError(self: *MessagePort, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_message_error = cb; + } else { + self._on_message_error = null; + } +} + +const PostMessageCallback = struct { + port: *MessagePort, + message: js.Object, + page: *Page, + + fn deinit(self: *PostMessageCallback) void { + self.page._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + if (self.port._closed) { + return null; + } + + const event = MessageEvent.init("message", .{ + .data = self.message, + .origin = "", + .source = null, + }, self.page) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{.err = err}); + return null; + }; + + self.page._event_manager.dispatchWithFunction( + self.port.asEventTarget(), + event.asEvent(), + self.port._on_message, + .{ .context = "MessagePort message" }, + ) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{.err = err}); + }; + + return null; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(MessagePort); + + pub const Meta = struct { + pub const name = "MessagePort"; + pub var class_id: bridge.ClassId = undefined; + pub const prototype_chain = bridge.prototypeChain(); + }; + + pub const postMessage = bridge.function(MessagePort.postMessage, .{}); + pub const start = bridge.function(MessagePort.start, .{}); + pub const close = bridge.function(MessagePort.close, .{}); + + pub const onmessage = bridge.accessor(MessagePort.getOnMessage, MessagePort.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(MessagePort.getOnMessageError, MessagePort.setOnMessageError, .{}); +}; From 0bc0a38704113b61c68e20212ab704d76df81bc0 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:21:28 +0100 Subject: [PATCH 103/257] ci: update installation workflow --- .github/actions/install/action.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index e9864c01d..d347f11b3 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -66,29 +66,3 @@ runs: mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/ ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a - - - name: Cache libiconv - id: cache-libiconv - uses: actions/cache@v4 - env: - cache-name: cache-libiconv - with: - path: ${{ inputs.cache-dir }}/libiconv - key: vendor/libiconv/libiconv-1.17 - - - name: download libiconv - if: ${{ steps.cache-libiconv.outputs.cache-hit != 'true' }} - shell: bash - run: make download-libiconv - - - name: build libiconv - shell: bash - run: make build-libiconv - - - name: build mimalloc - shell: bash - run: make install-mimalloc - - - name: build netsurf - shell: bash - run: make install-netsurf From dbd500cab913f027194e26e5e67d150fe8cc912f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:24:28 +0100 Subject: [PATCH 104/257] update docker file --- Dockerfile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 919a9a658..24936ffbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,10 +40,6 @@ WORKDIR /browser RUN git submodule init && \ git submodule update --recursive -RUN make install-libiconv && \ - make install-netsurf && \ - make install-mimalloc - # download and install v8 RUN case $TARGETPLATFORM in \ "linux/arm64") ARCH="aarch64" ;; \ From a1064a54cc331687ccc69f59ce20772fe8baf311 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:24:41 +0100 Subject: [PATCH 105/257] cleanup README --- README.md | 41 +++++------------------------------------ 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 87c393a52..a51ba2980 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,14 @@ You may still encounter errors or crashes. Please open an issue with specifics i Here are the key features we have implemented: -- [x] HTTP loader (based on Libcurl) -- [x] HTML parser and DOM tree (based on Netsurf libs) -- [x] Javascript support (v8) +- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) +- [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) +- [x] DOM tree +- [x] Javascript support ([v8](https://v8.dev/)) - [x] DOM APIs - [x] Ajax - [x] XHR API - - [x] Fetch API (polyfill) + - [x] Fetch API - [x] DOM dump - [x] CDP/websockets server - [x] Click @@ -214,38 +215,6 @@ To init or update the submodules in the `vendor/` directory: make install-submodule ``` -**iconv** - -libiconv is an internationalization library used by Netsurf. - -``` -make install-libiconv -``` - -**Netsurf libs** - -Netsurf libs are used for HTML parsing and DOM tree generation. - -``` -make install-netsurf -``` - -For dev env, use `make install-netsurf-dev`. - -**Mimalloc** - -Mimalloc is used as a C memory allocator. - -``` -make install-mimalloc -``` - -For dev env, use `make install-mimalloc-dev`. - -Note: when Mimalloc is built in dev mode, you can dump memory stats with the -env var `MIMALLOC_SHOW_STATS=1`. See -[https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html). - **v8** First, get the tools necessary for building V8, as well as the V8 source code: From 1e090f9d30184b96bded56b2c64ea101c027f29e Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:39:40 +0100 Subject: [PATCH 106/257] add html5ever install method --- .github/actions/install/action.yml | 4 ++++ Dockerfile | 2 ++ README.md | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index d347f11b3..2acb71b81 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -66,3 +66,7 @@ runs: mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/ ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a + + - name: build html5ever + shell: bash + run: make install-html5ever diff --git a/Dockerfile b/Dockerfile index 24936ffbf..76531df70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,8 @@ WORKDIR /browser RUN git submodule init && \ git submodule update --recursive +RUN make install-html5ever + # download and install v8 RUN case $TARGETPLATFORM in \ "linux/arm64") ARCH="aarch64" ;; \ diff --git a/README.md b/README.md index a51ba2980..f40eab57d 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,16 @@ To init or update the submodules in the `vendor/` directory: make install-submodule ``` +**html5ever** + +[html5ver](https://github.com/servo/html5ever) is high-performance browser-grade HTML5 parser. + +``` +make install-html5ever +``` + +For dev env, use `make install-html5ever-dev`. + **v8** First, get the tools necessary for building V8, as well as the V8 source code: From e74a286d7053e2240e2452cde62f17516d929913 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Nov 2025 12:50:01 +0100 Subject: [PATCH 107/257] ci: add install-html5ever-dev --- .github/actions/install/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 2acb71b81..db615ed45 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -70,3 +70,7 @@ runs: - name: build html5ever shell: bash run: make install-html5ever + + - name: build html5ever dev + shell: bash + run: make install-html5ever-dev From bde8b64ba3ebfe803693bd0d452afddc1e73fc76 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 26 Nov 2025 16:19:16 +0100 Subject: [PATCH 108/257] update html5ever instructions --- .github/actions/install/action.yml | 27 +++++++++++++++++++-------- .github/workflows/build.yml | 4 ++++ .github/workflows/e2e-test.yml | 2 ++ Dockerfile | 2 +- README.md | 4 ++-- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index db615ed45..258170c7b 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -26,6 +26,10 @@ inputs: description: 'cache dir to use' required: false default: '~/.cache' + mode: + description: 'debug or release' + required: false + default: 'debug' runs: using: "composite" @@ -58,19 +62,26 @@ runs: wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a - - name: install v8 + - name: install v8 release + if: ${{ inputs.mode == 'release' }} shell: bash run: | - mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/ - ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a - mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/ ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a - - name: build html5ever + - name: install v8 debug + if: ${{ inputs.mode == 'debug' }} + shell: bash + run: | + mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/ + ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a + + - name: hmtl5ever release + if: ${{ inputs.mode == 'release' }} shell: bash - run: make install-html5ever + run: zig -Doptimize=ReleaseSafe build html5ever - - name: build html5ever dev + - name: hmtl5ever debug + if: ${{ inputs.mode == 'debug' }} shell: bash - run: make install-html5ever-dev + run: zig build html5ever diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df16af4c9..0ab034e84 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,7 @@ jobs: with: os: ${{env.OS}} arch: ${{env.ARCH}} + mode: 'release' - name: zig build run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) @@ -74,6 +75,7 @@ jobs: with: os: ${{env.OS}} arch: ${{env.ARCH}} + mode: 'release' - name: zig build run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) @@ -114,6 +116,7 @@ jobs: with: os: ${{env.OS}} arch: ${{env.ARCH}} + mode: 'release' - name: zig build run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) @@ -157,6 +160,7 @@ jobs: with: os: ${{env.OS}} arch: ${{env.ARCH}} + mode: 'release' - name: zig build run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index fb295246c..992c8b2a9 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -56,6 +56,8 @@ jobs: submodules: recursive - uses: ./.github/actions/install + with: + mode: 'release' - name: zig build release run: zig build -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) diff --git a/Dockerfile b/Dockerfile index 76531df70..6f0b2936c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ WORKDIR /browser RUN git submodule init && \ git submodule update --recursive -RUN make install-html5ever +RUN zig build -Doptimize=ReleaseFast html5ever # download and install v8 RUN case $TARGETPLATFORM in \ diff --git a/README.md b/README.md index f40eab57d..5e25926ab 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,10 @@ make install-submodule [html5ver](https://github.com/servo/html5ever) is high-performance browser-grade HTML5 parser. ``` -make install-html5ever +zig build html5ever ``` -For dev env, use `make install-html5ever-dev`. +For a release build, use `zig build -Doptimize=ReleaseFast html5ever`. **v8** From 613428c54c0f12485af1b08815f71202530fa008 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 30 Nov 2025 12:48:15 +0800 Subject: [PATCH 109/257] Execute script.onload/onerror Add object-support for URLSearchParams. Start to treat js.Value as a first class object (instead of js.Object, where appropriate). --- src/browser/ScriptManager.zig | 39 ++++++++--- src/browser/js/Array.zig | 38 ++++++++++ src/browser/js/Context.zig | 25 +++++-- src/browser/js/Object.zig | 29 +++++--- src/browser/js/Value.zig | 74 ++++++++++++++++++++ src/browser/js/js.zig | 54 +------------- src/browser/tests/net/url_search_params.html | 2 +- src/browser/webapi/CustomElementRegistry.zig | 7 +- src/browser/webapi/net/Fetch.zig | 4 ++ src/browser/webapi/net/URLSearchParams.zig | 38 ++++++++-- 10 files changed, 226 insertions(+), 84 deletions(-) create mode 100644 src/browser/js/Array.zig create mode 100644 src/browser/js/Value.zig diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index ca098e9f4..bc5468bd9 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -726,10 +726,10 @@ const Script = struct { .kind = self.kind, .cacheable = cacheable, }); - self.executeCallback(script_element._on_error, page); + self.executeCallback("error", script_element._on_error, page); return; }; - self.executeCallback(script_element._on_load, page); + self.executeCallback("load", script_element._on_load, page); return; } @@ -752,13 +752,17 @@ const Script = struct { }; if (comptime IS_DEBUG) { - log.info(.browser, "executed script", .{.src = url}); + log.debug(.browser, "executed script", .{ + .src = url, + .success = success, + .on_load = script_element._on_load != null + }); } defer page.tick(); if (success) { - self.executeCallback(script_element._on_load, page); + self.executeCallback("load", script_element._on_load, page); return; } @@ -776,16 +780,31 @@ const Script = struct { .cacheable = cacheable, }); - self.executeCallback(script_element._on_error, page); + self.executeCallback("error", script_element._on_error, page); } - fn executeCallback(self: *const Script, cb_: ?js.Function, page: *Page) void { + fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void { const cb = cb_ orelse return; - // @ZIGDOM execute the callback - _ = cb; - _ = self; - _ = page; + const Event = @import("webapi/Event.zig"); + const event = Event.init(typ, .{}, page) catch |err| { + log.warn(.js, "script internal callback", .{ + .url = self.url, + .type = typ, + .err = err, + }); + return; + }; + + var result: js.Function.Result = undefined; + cb.tryCall(void, .{event}, &result) catch { + log.warn(.js, "script callback", .{ + .url = self.url, + .type = typ, + .err = result.exception, + .stack = result.stack, + }); + }; } }; diff --git a/src/browser/js/Array.zig b/src/browser/js/Array.zig new file mode 100644 index 000000000..95bc0e32d --- /dev/null +++ b/src/browser/js/Array.zig @@ -0,0 +1,38 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("js.zig"); +const v8 = js.v8; + +const Array = @This(); +js_arr: v8.Array, +context: *js.Context, + +pub fn len(self: Array) usize { + return @intCast(self.js_arr.length()); +} + +pub fn get(self: Array, index: usize) !js.Value { + const idx_key = v8.Integer.initU32(self.context.isolate, @intCast(index)); + const js_obj = self.js_arr.castTo(v8.Object); + return .{ + .context = self.context, + .js_val = try js_obj.getValue(self.context.v8_context, idx_key.toValue()), + }; +} diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index a2f358dc4..eea764b21 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -392,9 +392,9 @@ pub fn createException(self: *const Context, e: v8.Value) js.Exception { // Wrap a v8.Value, largely so that we can provide a convenient // toString function -pub fn createValue(self: *const Context, value: v8.Value) js.Value { +pub fn createValue(self: *Context, value: v8.Value) js.Value { return .{ - .value = value, + .js_val = value, .context = self, }; } @@ -665,8 +665,7 @@ pub fn mapZigInstanceToJs(self: *Context, js_obj_: ?v8.Object, value: anytype) ! pub fn jsValueToZig(self: *Context, comptime T: type, js_value: v8.Value) !T { switch (@typeInfo(T)) { .optional => |o| { - if (comptime o.child == js.Object) { - // If type type is a ?js.Object, then we want to pass + // If type type is a ?js.Value or a ?js.Object, then we want to pass // a js.Object, not null. Consider a function, // _doSomething(arg: ?Env.JsObjet) void { ... } // @@ -681,6 +680,14 @@ pub fn jsValueToZig(self: *Context, comptime T: type, js_value: v8.Value) !T { // pass in `null` and the the doSomething won't // be able to tell if `null` was explicitly passed // or whether no parameter was passed. + if (comptime o.child == js.Value) { + return js.Value{ + .context = self, + .js_val = js_value, + }; + } + + if (comptime o.child == js.Object) { return js.Object{ .context = self, .js_obj = js_value.castTo(v8.Object), @@ -831,6 +838,16 @@ fn jsValueToStruct(self: *Context, comptime T: type, js_value: v8.Value) !?T { return .{ .string = try self.valueToString(js_value, .{ .allocator = self.arena }) }; } + + if (comptime T == js.Value) { + // Caller wants an opaque js.Object. Probably a parameter + // that it needs to pass back into a callback + return js.Value{ + .context = self, + .js_val = js_value, + }; + } + const js_obj = js_value.castTo(v8.Object); if (comptime T == js.Object) { diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 53bcafe7a..222f2b752 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -135,7 +135,7 @@ pub fn isNullOrUndefined(self: Object) bool { return self.js_obj.toValue().isNullOrUndefined(); } -pub fn nameIterator(self: Object) js.ValueIterator { +pub fn nameIterator(self: Object, allocator: Allocator) NameIterator { const context = self.context; const js_obj = self.js_obj; @@ -145,6 +145,7 @@ pub fn nameIterator(self: Object) js.ValueIterator { return .{ .count = count, .context = context, + .allocator = allocator, .js_obj = array.castTo(v8.Object), }; } @@ -153,10 +154,22 @@ pub fn toZig(self: Object, comptime T: type) !T { return self.context.jsValueToZig(T, self.js_obj.toValue()); } -pub fn TriState(comptime T: type) type { - return union(enum) { - null: void, - undefined: void, - value: T, - }; -} +pub const NameIterator = struct { + count: u32, + idx: u32 = 0, + js_obj: v8.Object, + allocator: Allocator, + context: *const Context, + + pub fn next(self: *NameIterator) !?[]const u8 { + const idx = self.idx; + if (idx == self.count) { + return null; + } + self.idx += 1; + + const context = self.context; + const js_val = try self.js_obj.getAtIndex(context.v8_context, idx); + return try context.valueToString(js_val, .{ .allocator = self.allocator }); + } +}; diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig new file mode 100644 index 000000000..143d221a4 --- /dev/null +++ b/src/browser/js/Value.zig @@ -0,0 +1,74 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("js.zig"); + +const v8 = js.v8; + +const Allocator = std.mem.Allocator; + +const Value = @This(); +js_val: v8.Value, +context: *js.Context, + +pub fn isObject(self: Value) bool { + return self.js_val.isObject(); +} + +pub fn isString(self: Value) bool { + return self.js_val.isString(); +} + +pub fn isArray(self: Value) bool { + return self.js_val.isArray(); +} + +pub fn toString(self: Value, allocator: Allocator) ![]const u8 { + return self.context.valueToString(self.js_val, .{ .allocator = allocator }); +} + +pub fn toObject(self: Value) js.Object { + return .{ + .context = self.context, + .js_obj = self.js_val.castTo(v8.Object), + }; +} + +pub fn toArray(self: Value) js.Array { + return .{ + .context = self.context, + .js_arr = self.js_val.castTo(v8.Array), + }; +} + +// pub const Value = struct { +// value: v8.Value, +// context: *const Context, + +// // the caller needs to deinit the string returned +// pub fn toString(self: Value, allocator: Allocator) ![]const u8 { +// return self.context.valueToString(self.value, .{ .allocator = allocator }); +// } + +// pub fn fromJson(ctx: *Context, json: []const u8) !Value { +// const json_string = v8.String.initUtf8(ctx.isolate, json); +// const value = try v8.Json.parse(ctx.v8_context, json_string); +// return Value{ .context = ctx, .value = value }; +// } +// }; diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 71e192865..4f993d8de 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -29,6 +29,8 @@ pub const Inspector = @import("Inspector.zig"); // TODO: Is "This" really necessary? pub const This = @import("This.zig"); +pub const Value = @import("Value.zig"); +pub const Array = @import("Array.zig"); pub const Object = @import("Object.zig"); pub const TryCatch = @import("TryCatch.zig"); pub const Function = @import("Function.zig"); @@ -150,58 +152,6 @@ pub const Exception = struct { } }; -pub const Value = struct { - value: v8.Value, - context: *const Context, - - // the caller needs to deinit the string returned - pub fn toString(self: Value, allocator: Allocator) ![]const u8 { - return self.context.valueToString(self.value, .{ .allocator = allocator }); - } - - pub fn fromJson(ctx: *Context, json: []const u8) !Value { - const json_string = v8.String.initUtf8(ctx.isolate, json); - const value = try v8.Json.parse(ctx.v8_context, json_string); - return Value{ .context = ctx, .value = value }; - } - - pub fn isArray(self: Value) bool { - return self.value.isArray(); - } - - pub fn arrayLength(self: Value) u32 { - std.debug.assert(self.value.isArray()); - return self.value.castTo(v8.Array).length(); - } - - pub fn arrayGet(self: Value, index: u32) !Value { - std.debug.assert(self.value.isArray()); - const array_obj = self.value.castTo(v8.Array).castTo(v8.Object); - const idx_key = v8.Integer.initU32(self.context.isolate, index); - const elem_val = try array_obj.getValue(self.context.v8_context, idx_key.toValue()); - return self.context.createValue(elem_val); - } -}; - -pub const ValueIterator = struct { - count: u32, - idx: u32 = 0, - js_obj: v8.Object, - context: *const Context, - - pub fn next(self: *ValueIterator) !?Value { - const idx = self.idx; - if (idx == self.count) { - return null; - } - self.idx += 1; - - const context = self.context; - const js_val = try self.js_obj.getAtIndex(context.v8_context, idx); - return context.createValue(js_val); - } -}; - pub fn UndefinedOr(comptime T: type) type { return union(enum) { undefined: void, diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html index bece1c646..54b66b3d3 100644 --- a/src/browser/tests/net/url_search_params.html +++ b/src/browser/tests/net/url_search_params.html @@ -21,7 +21,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/MessageChannel.zig b/src/browser/webapi/MessageChannel.zig index 766310133..d43ba7dfc 100644 --- a/src/browser/webapi/MessageChannel.zig +++ b/src/browser/webapi/MessageChannel.zig @@ -37,7 +37,6 @@ pub fn init(page: *Page) !*MessageChannel { }); } - pub fn getPort1(self: *const MessageChannel) *MessagePort { return self._port1; } diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index 65a7d36b9..4d72a9342 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -134,7 +134,7 @@ const PostMessageCallback = struct { .origin = "", .source = null, }, self.page) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{.err = err}); + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }; @@ -144,7 +144,7 @@ const PostMessageCallback = struct { self.port._on_message, .{ .context = "MessagePort message" }, ) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{.err = err}); + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); }; return null; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ba3683b6c..84fae9f8a 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -265,13 +265,12 @@ pub fn postMessage(self: *Window, message: js.Object, target_origin: ?[]const u8 const origin = try self._location.getOrigin(page); const callback = try page._factory.create(PostMessageCallback{ .window = self, - .message = try message.persist() , + .message = try message.persist(), .origin = try page.arena.dupe(u8, origin), .page = page, }); errdefer page._factory.destroy(callback); - try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "postMessage", .low_priority = false, diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index bc66fb001..9e79072ba 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -21,6 +21,7 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); +const ReadableStream = @import("../streams/ReadableStream.zig"); const Allocator = std.mem.Allocator; const Response = @This(); @@ -28,7 +29,7 @@ const Response = @This(); _status: u16, _arena: Allocator, _headers: *Headers, -_body: []const u8, +_body: ?[]const u8, const InitOpts = struct { status: u16 = 200, @@ -39,10 +40,13 @@ const InitOpts = struct { pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { const opts = opts_ orelse InitOpts{}; + // Store empty string as empty string, not null + const body = if (body_) |b| try page.arena.dupe(u8, b) else null; + return page._factory.create(Response{ ._arena = page.arena, ._status = opts.status, - ._body = if (body_) |b| try page.arena.dupe(u8, b) else "", + ._body = body, ._headers = opts.headers orelse try Headers.init(page), }); } @@ -55,15 +59,34 @@ pub fn getHeaders(self: *const Response) *Headers { return self._headers; } +pub fn getBody(self: *const Response, page: *Page) !?*ReadableStream { + const body = self._body orelse return null; + + // Empty string should create a closed stream with no data + if (body.len == 0) { + const stream = try ReadableStream.init(page); + try stream._controller.close(); + return stream; + } + + return ReadableStream.initWithData(body, page); +} + pub fn isOK(self: *const Response) bool { return self._status >= 200 and self._status <= 299; } +pub fn getText(self: *const Response, page: *Page) !js.Promise { + const body = self._body orelse ""; + return page.js.resolvePromise(body); +} + pub fn getJson(self: *Response, page: *Page) !js.Promise { + const body = self._body orelse ""; const value = std.json.parseFromSliceLeaky( std.json.Value, page.call_arena, - self._body, + body, .{}, ) catch |err| { return page.js.rejectPromise(.{@errorName(err)}); @@ -83,6 +106,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Response.init, .{}); pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); + pub const text = bridge.function(Response.getText, .{}); pub const json = bridge.function(Response.getJson, .{}); pub const headers = bridge.accessor(Response.getHeaders, null, .{}); + pub const body = bridge.accessor(Response.getBody, null, .{}); }; diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 8012f23b1..9bdecd2ec 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -45,13 +45,13 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), .value => |js_val| { if (js_val.isObject()) { - break :blk try paramsFromObject(arena, js_val.toObject()); + break :blk try paramsFromObject(arena, js_val.toObject()); } if (js_val.isString()) { break :blk try paramsFromString(arena, try js_val.toString(arena), &page.buf); } return error.InvalidArgument; - } + }, } }; diff --git a/src/browser/webapi/streams/ReadableStream.zig b/src/browser/webapi/streams/ReadableStream.zig new file mode 100644 index 000000000..eec8cb943 --- /dev/null +++ b/src/browser/webapi/streams/ReadableStream.zig @@ -0,0 +1,140 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig"); +const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); + +pub fn registerTypes() []const type { + return &.{ + ReadableStream, + AsyncIterator, + }; +} + +const ReadableStream = @This(); + +pub const State = enum { + readable, + closed, + errored, +}; + +_page: *Page, +_state: State, +_reader: ?*ReadableStreamDefaultReader, +_controller: *ReadableStreamDefaultController, +_stored_error: ?[]const u8, + +pub fn init(page: *Page) !*ReadableStream { + const stream = try page._factory.create(ReadableStream{ + ._page = page, + ._state = .readable, + ._reader = null, + ._controller = undefined, + ._stored_error = null, + }); + + stream._controller = try ReadableStreamDefaultController.init(stream, page); + return stream; +} + +pub fn initWithData(data: []const u8, page: *Page) !*ReadableStream { + const stream = try init(page); + + // For Phase 1: immediately enqueue all data and close + try stream._controller.enqueue(data); + try stream._controller.close(); + + return stream; +} + +pub fn getReader(self: *ReadableStream, page: *Page) !*ReadableStreamDefaultReader { + if (self._reader != null) { + return error.ReaderLocked; + } + + const reader = try ReadableStreamDefaultReader.init(self, page); + self._reader = reader; + return reader; +} + +pub fn releaseReader(self: *ReadableStream) void { + self._reader = null; +} + +pub fn getAsyncIterator(self: *ReadableStream, page: *Page) !*AsyncIterator { + return AsyncIterator.init(self, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ReadableStream); + + pub const Meta = struct { + pub const name = "ReadableStream"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(ReadableStream.init, .{}); + pub const getReader = bridge.function(ReadableStream.getReader, .{}); + pub const symbol_async_iterator = bridge.iterator(ReadableStream.getAsyncIterator, .{ .async = true }); +}; + +pub const AsyncIterator = struct { + _stream: *ReadableStream, + _reader: *ReadableStreamDefaultReader, + + pub fn init(stream: *ReadableStream, page: *Page) !*AsyncIterator { + const reader = try stream.getReader(page); + return page._factory.create(AsyncIterator{ + ._reader = reader, + ._stream = stream, + }); + } + + pub fn next(self: *AsyncIterator, page: *Page) !js.Promise { + return self._reader.read(page); + } + + pub fn @"return"(self: *AsyncIterator, page: *Page) !js.Promise { + self._reader.releaseLock(); + return page.js.resolvePromise(.{ .done = true, .value = null }); + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(ReadableStream.AsyncIterator); + + pub const Meta = struct { + pub const name = "ReadableStreamAsyncIterator"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const next = bridge.function(ReadableStream.AsyncIterator.next, .{}); + pub const @"return" = bridge.function(ReadableStream.AsyncIterator.@"return", .{}); + }; +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: ReadableStream" { + try testing.htmlRunner("streams/readable_stream.html", .{}); +} diff --git a/src/browser/webapi/streams/ReadableStreamDefaultController.zig b/src/browser/webapi/streams/ReadableStreamDefaultController.zig new file mode 100644 index 000000000..876f546a9 --- /dev/null +++ b/src/browser/webapi/streams/ReadableStreamDefaultController.zig @@ -0,0 +1,100 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const ReadableStream = @import("ReadableStream.zig"); + +const ReadableStreamDefaultController = @This(); + +_page: *Page, +_stream: *ReadableStream, +_arena: std.mem.Allocator, +_queue: std.ArrayList([]const u8), + +pub fn init(stream: *ReadableStream, page: *Page) !*ReadableStreamDefaultController { + return page._factory.create(ReadableStreamDefaultController{ + ._page = page, + ._stream = stream, + ._arena = page.arena, + ._queue = std.ArrayList([]const u8){}, + }); +} + +pub fn enqueue(self: *ReadableStreamDefaultController, chunk: []const u8) !void { + if (self._stream._state != .readable) { + return error.StreamNotReadable; + } + + // Store a copy of the chunk in the page arena + const chunk_copy = try self._page.arena.dupe(u8, chunk); + try self._queue.append(self._arena, chunk_copy); +} + +pub fn close(self: *ReadableStreamDefaultController) !void { + if (self._stream._state != .readable) { + return error.StreamNotReadable; + } + + self._stream._state = .closed; +} + +pub fn doError(self: *ReadableStreamDefaultController, err: []const u8) !void { + if (self._stream._state != .readable) { + return; + } + + self._stream._state = .errored; + self._stream._stored_error = try self._page.arena.dupe(u8, err); +} + +pub fn dequeue(self: *ReadableStreamDefaultController) ?[]const u8 { + if (self._queue.items.len == 0) { + return null; + } + return self._queue.orderedRemove(0); +} + +pub fn getDesiredSize(self: *const ReadableStreamDefaultController) ?i32 { + switch (self._stream._state) { + .errored => return null, + .closed => return 0, + .readable => { + // For now, just report based on queue size + // In a real implementation, this would use highWaterMark + return @as(i32, 1) - @as(i32, @intCast(self._queue.items.len)); + }, + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ReadableStreamDefaultController); + + pub const Meta = struct { + pub const name = "ReadableStreamDefaultController"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const enqueue = bridge.function(ReadableStreamDefaultController.enqueue, .{}); + pub const close = bridge.function(ReadableStreamDefaultController.close, .{}); + pub const @"error" = bridge.function(ReadableStreamDefaultController.doError, .{}); + pub const desiredSize = bridge.accessor(ReadableStreamDefaultController.getDesiredSize, null, .{}); +}; diff --git a/src/browser/webapi/streams/ReadableStreamDefaultReader.zig b/src/browser/webapi/streams/ReadableStreamDefaultReader.zig new file mode 100644 index 000000000..7a531a3b8 --- /dev/null +++ b/src/browser/webapi/streams/ReadableStreamDefaultReader.zig @@ -0,0 +1,107 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const ReadableStream = @import("ReadableStream.zig"); + +const ReadableStreamDefaultReader = @This(); + +_page: *Page, +_stream: ?*ReadableStream, + +pub fn init(stream: *ReadableStream, page: *Page) !*ReadableStreamDefaultReader { + return page._factory.create(ReadableStreamDefaultReader{ + ._stream = stream, + ._page = page, + }); +} + +pub const ReadResult = struct { + done: bool, + value: ?js.TypedArray(u8), +}; + +pub fn read(self: *ReadableStreamDefaultReader, page: *Page) !js.Promise { + const stream = self._stream orelse { + return page.js.rejectPromise("Reader has been released"); + }; + + if (stream._state == .errored) { + const err = stream._stored_error orelse "Stream errored"; + return page.js.rejectPromise(err); + } + + if (stream._controller.dequeue()) |chunk| { + const result = ReadResult{ + .done = false, + .value = js.TypedArray(u8){ .values = chunk }, + }; + return page.js.resolvePromise(result); + } + + if (stream._state == .closed) { + const result = ReadResult{ + .value = null, + .done = true, + }; + return page.js.resolvePromise(result); + } + + const result = ReadResult{ + .done = true, + .value = null, + }; + return page.js.resolvePromise(result); +} + +pub fn releaseLock(self: *ReadableStreamDefaultReader) void { + if (self._stream) |stream| { + stream.releaseReader(); + self._stream = null; + } +} + +pub fn cancel(self: *ReadableStreamDefaultReader, reason_: ?[]const u8, page: *Page) !js.Promise { + const stream = self._stream orelse { + return page.js.rejectPromise("Reader has been released"); + }; + + const reason = reason_ orelse "canceled"; + + try stream._controller.doError(reason); + self.releaseLock(); + + return page.js.resolvePromise(.{}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ReadableStreamDefaultReader); + + pub const Meta = struct { + pub const name = "ReadableStreamDefaultReader"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const read = bridge.function(ReadableStreamDefaultReader.read, .{}); + pub const cancel = bridge.function(ReadableStreamDefaultReader.cancel, .{}); + pub const releaseLock = bridge.function(ReadableStreamDefaultReader.releaseLock, .{}); +}; From 92572c977be2f10550c31f7d9df51d4f28fd91a6 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 29 Nov 2025 15:11:15 +0100 Subject: [PATCH 111/257] update zig-v8 version --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 258170c7b..9f73944e7 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -17,7 +17,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.1.33' + default: 'v0.1.35' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 6f0b2936c..a405a057c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG MINISIG=0.12 ARG ZIG=0.15.2 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.1.33 +ARG ZIG_V8=v0.1.35 ARG TARGETPLATFORM RUN apt-get update -yq && \ From f968db63e9507d1bda84030f7f0ef9685a13c307 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 29 Nov 2025 15:25:19 +0100 Subject: [PATCH 112/257] ci: use setup-zig v2.0.5 --- .github/actions/install/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 9f73944e7..1d3006418 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -42,7 +42,7 @@ runs: sudo apt-get update sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang - - uses: mlugg/setup-zig@v2 + - uses: mlugg/setup-zig@v2.0.5 with: version: ${{ inputs.zig }} From c9b9ef993411705aa3dbd6979553f0be4163af98 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 1 Dec 2025 08:38:56 +0100 Subject: [PATCH 113/257] ci: build html5ever typo --- .github/actions/install/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 1d3006418..e0ca3c02c 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -79,7 +79,7 @@ runs: - name: hmtl5ever release if: ${{ inputs.mode == 'release' }} shell: bash - run: zig -Doptimize=ReleaseSafe build html5ever + run: zig build -Doptimize=ReleaseSafe html5ever - name: hmtl5ever debug if: ${{ inputs.mode == 'debug' }} From d18253d50b2c4fe9153bff3aacd037c68a0032f3 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 1 Dec 2025 08:42:42 +0100 Subject: [PATCH 114/257] fix import for rename CSS.zig insto css.zig --- src/browser/js/bridge.zig | 2 +- src/browser/webapi/Element.zig | 2 +- src/browser/webapi/Window.zig | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index d3d983d4e..748dbc8e4 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -485,7 +485,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), - @import("../webapi/CSS.zig"), + @import("../webapi/css.zig"), @import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSStyleDeclaration.zig"), diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 36215d479..dddb56553 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -32,7 +32,7 @@ pub const Attribute = @import("element/Attribute.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMStringMap = @import("element/DOMStringMap.zig"); const DOMRect = @import("DOMRect.zig"); -const CSS = @import("CSS.zig"); +const CSS = @import("css.zig"); const ShadowRoot = @import("ShadowRoot.zig"); pub const Svg = @import("element/Svg.zig"); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ba3683b6c..16c553c47 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -25,7 +25,7 @@ const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); const Crypto = @import("Crypto.zig"); -const CSS = @import("CSS.zig"); +const CSS = @import("css.zig"); const Navigator = @import("Navigator.zig"); const Screen = @import("Screen.zig"); const Performance = @import("Performance.zig"); From ee7c38045f127ec03d0a392f0fc94c17c84dfb1f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 1 Dec 2025 08:43:42 +0100 Subject: [PATCH 115/257] zig fmt --- src/browser/webapi/Window.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 16c553c47..670935790 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -265,13 +265,12 @@ pub fn postMessage(self: *Window, message: js.Object, target_origin: ?[]const u8 const origin = try self._location.getOrigin(page); const callback = try page._factory.create(PostMessageCallback{ .window = self, - .message = try message.persist() , + .message = try message.persist(), .origin = try page.arena.dupe(u8, origin), .page = page, }); errdefer page._factory.destroy(callback); - try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "postMessage", .low_priority = false, From 4b60f56e5f573443fd9d1f405aa4169739fccc76 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 1 Dec 2025 09:06:52 +0100 Subject: [PATCH 116/257] ci: use releaseFast for hmtl5ever release mode --- .github/actions/install/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index e0ca3c02c..3b29b7b27 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -79,7 +79,7 @@ runs: - name: hmtl5ever release if: ${{ inputs.mode == 'release' }} shell: bash - run: zig build -Doptimize=ReleaseSafe html5ever + run: zig build -Doptimize=ReleaseFast html5ever - name: hmtl5ever debug if: ${{ inputs.mode == 'debug' }} From 129b59a43fad9970d8fae434cd9d82c06de0711d Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 1 Dec 2025 11:17:59 +0300 Subject: [PATCH 117/257] html5ever: prefer `dev` build only on `Debug` optimization --- build.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/build.zig b/build.zig index 3632f98db..a273d81f9 100644 --- a/build.zig +++ b/build.zig @@ -65,9 +65,9 @@ pub fn build(b: *Build) !void { }; break :blk switch (optimize) { - // Consider these as dev builds. - .Debug, .ReleaseSafe => argv[0 .. argv.len - 1], - .ReleaseFast, .ReleaseSmall => argv, + // Prefer dev build on debug option. + .Debug => argv[0 .. argv.len - 1], + else => argv, }; }; const html5ever_exec_cargo = b.addSystemCommand(html5ever_argv); @@ -94,8 +94,9 @@ pub fn build(b: *Build) !void { }; const html5ever_obj = switch (optimize) { - .Debug, .ReleaseSafe => b.getInstallPath(.prefix, "html5ever/debug/liblitefetch_html5ever.a"), - .ReleaseFast, .ReleaseSmall => b.getInstallPath(.prefix, "html5ever/release/liblitefetch_html5ever.a"), + .Debug => b.getInstallPath(.prefix, "html5ever/debug/liblitefetch_html5ever.a"), + // Release builds. + else => b.getInstallPath(.prefix, "html5ever/release/liblitefetch_html5ever.a"), }; lightpanda_module.addObjectFile(.{ .cwd_relative = html5ever_obj }); From e807c9b6beeb609667fbcbc941a1dde59426e715 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 00:08:45 +0800 Subject: [PATCH 118/257] Add XmlSerializer, add Response.type, tweak HTMLTemplate to redirect some calls to its Content (DocumentFragment) --- src/browser/Page.zig | 8 +- src/browser/Session.zig | 6 - src/browser/js/Caller.zig | 4 + src/browser/js/Context.zig | 1 - src/browser/js/Function.zig | 2 +- src/browser/js/bridge.zig | 1 + src/browser/js/js.zig | 17 +-- src/browser/tests/element/html/template.html | 40 ++++++ src/browser/tests/xmlserializer.html | 131 +++++++++++++++++++ src/browser/webapi/Window.zig | 4 +- src/browser/webapi/XMLSerializer.zig | 56 ++++++++ src/browser/webapi/element/html/Template.zig | 29 ++++ src/browser/webapi/net/Fetch.zig | 7 +- src/browser/webapi/net/Response.zig | 15 +++ 14 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 src/browser/tests/xmlserializer.html create mode 100644 src/browser/webapi/XMLSerializer.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c2c2b17e9..7158231fb 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -182,7 +182,12 @@ pub fn deinit(self: *Page) void { // stats.print(&stream) catch unreachable; } - self.js.deinit(); + // removeContext() will execute the destructor of any type that + // registered a destructor (e.g. XMLHttpRequest). + // Should be called before we deinit the page, because these objects + // could be referencing it. + self._session.executor.removeContext(); + self._script_manager.shutdown = true; self._session.browser.http_client.abort(); self._script_manager.deinit(); @@ -597,6 +602,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { // haven't started navigating, I guess. return .done; } + self.js.runMicrotasks(); // Either we have active http connections, or we're in CDP // mode with an extra socket. Either way, we're waiting diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 0f90a82a3..cacd6f0ee 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -112,12 +112,6 @@ pub fn removePage(self: *Session) void { std.debug.assert(self.page != null); - // RemoveJsContext() will execute the destructor of any type that - // registered a destructor (e.g. XMLHttpRequest). - // Should be called before we deinit the page, because these objects - // could be referencing it. - self.executor.removeContext(); - self.page.?.deinit(); self.page = null; diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index efb696ec7..f4b32ddbe 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -130,6 +130,10 @@ pub fn method(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionC pub fn _method(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) !void { const F = @TypeOf(func); + var handle_scope: v8.HandleScope = undefined; + handle_scope.init(self.isolate); + defer handle_scope.deinit(); + var args = try self.getArgs(F, 1, info); @field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis()); const res = @call(.auto, func, args); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 1b0c768fe..ce497e487 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1176,7 +1176,6 @@ pub fn resolvePromise(self: *Context, value: anytype) !js.Promise { return error.FailedToResolvePromise; } self.runMicrotasks(); - return resolver.getPromise(); } diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 41d8fa2ca..4ab5be8a5 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -144,7 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args const result = self.func.castToFunction().call(context.v8_context, js_this, js_args); if (result == null) { - std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); + // std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); return error.JSExecCallback; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 026ccffac..0feec8a85 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -515,6 +515,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/DOMNodeIterator.zig"), @import("../webapi/DOMRect.zig"), @import("../webapi/DOMParser.zig"), + @import("../webapi/XMLSerializer.zig"), @import("../webapi/NodeFilter.zig"), @import("../webapi/Element.zig"), @import("../webapi/element/DOMStringMap.zig"), diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 4f993d8de..e6f736681 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -75,22 +75,20 @@ pub const PromiseResolver = struct { const context = self.context; const js_value = try context.zigValueToJs(value); - // resolver.resolve will return null if the promise isn't pending - const ok = self.resolver.resolve(context.v8_context, js_value) orelse return; - if (!ok) { + if (self.resolver.resolve(context.v8_context, js_value) == null) { return error.FailedToResolvePromise; } + self.runMicrotasks(); } pub fn reject(self: PromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value); - // resolver.reject will return null if the promise isn't pending - const ok = self.resolver.reject(context.v8_context, js_value) orelse return; - if (!ok) { + if (self.resolver.reject(context.v8_context, js_value) == null) { return error.FailedToRejectPromise; } + self.runMicrotasks(); } }; @@ -111,9 +109,7 @@ pub const PersistentPromiseResolver = struct { const js_value = try context.zigValueToJs(value, .{}); defer context.runMicrotasks(); - // resolver.resolve will return null if the promise isn't pending - const ok = self.resolver.castToPromiseResolver().resolve(context.v8_context, js_value) orelse return; - if (!ok) { + if (self.resolver.castToPromiseResolver().resolve(context.v8_context, js_value) == null) { return error.FailedToResolvePromise; } } @@ -124,8 +120,7 @@ pub const PersistentPromiseResolver = struct { defer context.runMicrotasks(); // resolver.reject will return null if the promise isn't pending - const ok = self.resolver.castToPromiseResolver().reject(context.v8_context, js_value) orelse return; - if (!ok) { + if (self.resolver.castToPromiseResolver().reject(context.v8_context, js_value) == null) { return error.FailedToRejectPromise; } } diff --git a/src/browser/tests/element/html/template.html b/src/browser/tests/element/html/template.html index bc6055846..52db20fdd 100644 --- a/src/browser/tests/element/html/template.html +++ b/src/browser/tests/element/html/template.html @@ -166,3 +166,43 @@

Hello Template

testing.expectEqual('First', inner1.textContent); } + + + + + + + + diff --git a/src/browser/tests/xmlserializer.html b/src/browser/tests/xmlserializer.html new file mode 100644 index 000000000..edbc60c88 --- /dev/null +++ b/src/browser/tests/xmlserializer.html @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 670935790..46a5afbe8 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -157,8 +157,8 @@ pub fn setOnUnhandledRejection(self: *Window, cb_: ?js.Function) !void { } } -pub fn fetch(_: *const Window, input: Fetch.Input, page: *Page) !js.Promise { - return Fetch.init(input, page); +pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.RequestInit, page: *Page) !js.Promise { + return Fetch.init(input, options, page); } pub fn setTimeout(self: *Window, cb: js.Function, delay_ms: ?u32, params: []js.Object, page: *Page) !u32 { diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig new file mode 100644 index 000000000..bbd89a800 --- /dev/null +++ b/src/browser/webapi/XMLSerializer.zig @@ -0,0 +1,56 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../js/js.zig"); + +const Page = @import("../Page.zig"); +const Node = @import("Node.zig"); +const dump = @import("../dump.zig"); + +const XMLSerializer = @This(); + +pub fn init() XMLSerializer { + return .{}; +} + +pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) ![]const u8 { + _ = self; + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page); + return buf.written(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(XMLSerializer); + + pub const Meta = struct { + pub const name = "XMLSerializer"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const constructor = bridge.constructor(XMLSerializer.init, .{}); + pub const serializeToString = bridge.function(XMLSerializer.serializeToString, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: XMLSerializer" { + try testing.htmlRunner("xmlserializer.html", .{}); +} diff --git a/src/browser/webapi/element/html/Template.zig b/src/browser/webapi/element/html/Template.zig index 4529230fa..8d8dc0b27 100644 --- a/src/browser/webapi/element/html/Template.zig +++ b/src/browser/webapi/element/html/Template.zig @@ -23,6 +23,21 @@ pub fn getContent(self: *Template) *DocumentFragment { return self._content; } +pub fn setInnerHTML(self: *Template, html: []const u8, page: *Page) !void { + return self._content.setInnerHTML(html, page); +} + +pub fn getOuterHTML(self: *Template, writer: *std.Io.Writer, page: *Page) !void { + const dump = @import("../../../dump.zig"); + const el = self.asElement(); + + try el.format(writer); + try dump.children(self._content.asNode(), .{ .shadow = .skip }, writer, page); + try writer.writeAll("'); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Template); @@ -33,6 +48,20 @@ pub const JsApi = struct { }; pub const content = bridge.accessor(Template.getContent, null, .{}); + pub const innerHTML = bridge.accessor(_getInnerHTML, Template.setInnerHTML, .{}); + pub const outerHTML = bridge.accessor(_getOuterHTML, null, .{}); + + fn _getInnerHTML(self: *Template, page: *Page) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self._content.getInnerHTML(&buf.writer, page); + return buf.written(); + } + + fn _getOuterHTML(self: *Template, page: *Page) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self.getOuterHTML(&buf.writer, page); + return buf.written(); + } }; pub const Build = struct { diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 35056eb1d..b00b3c7c0 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -40,10 +40,11 @@ _response: *Response, _resolver: js.PersistentPromiseResolver, pub const Input = Request.Input; +pub const RequestInit = Request.Options; // @ZIGDOM just enough to get campfire demo working -pub fn init(input: Input, page: *Page) !js.Promise { - const request = try Request.init(input, null, page); +pub fn init(input: Input, options: ?RequestInit, page: *Page) !js.Promise { + const request = try Request.init(input, options, page); const fetch = try page.arena.create(Fetch); fetch.* = .{ @@ -60,7 +61,6 @@ pub fn init(input: Input, page: *Page) !js.Promise { if (comptime IS_DEBUG) { log.debug(.http, "fetch", .{ .url = request._url }); } - std.debug.print("fetch: {s}\n", .{request._url}); try http_client.request(.{ .ctx = fetch, @@ -100,7 +100,6 @@ fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { fn httpDoneCallback(ctx: *anyopaque) !void { const self: *Fetch = @ptrCast(@alignCast(ctx)); self._response._body = self._buf.items; - std.debug.print("fetch-resolve: {s}\n", .{self._url}); return self._resolver.resolve(self._response); } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 9e79072ba..244475668 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -26,10 +26,19 @@ const Allocator = std.mem.Allocator; const Response = @This(); +pub const Type = enum { + basic, + cors, + @"error", + @"opaque", + opaqueredirect, +}; + _status: u16, _arena: Allocator, _headers: *Headers, _body: ?[]const u8, +_type: Type, const InitOpts = struct { status: u16 = 200, @@ -48,6 +57,7 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { ._status = opts.status, ._body = body, ._headers = opts.headers orelse try Headers.init(page), + ._type = .basic, // @ZIGDOM: todo }); } @@ -59,6 +69,10 @@ pub fn getHeaders(self: *const Response) *Headers { return self._headers; } +pub fn getType(self: *const Response) []const u8 { + return @tagName(self._type); +} + pub fn getBody(self: *const Response, page: *Page) !?*ReadableStream { const body = self._body orelse return null; @@ -106,6 +120,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Response.init, .{}); pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); + pub const @"type" = bridge.accessor(Response.getType, null, .{}); pub const text = bridge.function(Response.getText, .{}); pub const json = bridge.function(Response.getJson, .{}); pub const headers = bridge.accessor(Response.getHeaders, null, .{}); From 6a48f6df25f56cde49ce78ed0b70c0308a1ab95a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 07:01:14 +0800 Subject: [PATCH 119/257] Element.hasAttributes --- src/browser/tests/element/attributes.html | 36 +++++++++++++++++++++++ src/browser/webapi/Element.zig | 6 ++++ src/browser/webapi/element/Attribute.zig | 3 ++ 3 files changed, 45 insertions(+) diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html index 5fb92883c..e33af9ed1 100644 --- a/src/browser/tests/element/attributes.html +++ b/src/browser/tests/element/attributes.html @@ -129,3 +129,39 @@ testing.expectEqual(false, el1.hasAttribute('toggle-test')); } + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 8836770a5..12c4b686c 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -288,6 +288,11 @@ pub fn hasAttribute(self: *const Element, name: []const u8, page: *Page) !bool { return value != null; } +pub fn hasAttributes(self: *const Element) bool { + const attributes = self._attributes orelse return false; + return attributes.isEmpty() == false; +} + pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attribute { const attributes = self._attributes orelse return null; return attributes.getAttribute(name, self, page); @@ -952,6 +957,7 @@ pub const JsApi = struct { pub const style = bridge.accessor(Element.getStyle, null, .{}); pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{}); pub const hasAttribute = bridge.function(Element.hasAttribute, .{}); + pub const hasAttributes = bridge.function(Element.hasAttributes, .{}); pub const getAttribute = bridge.function(Element.getAttribute, .{}); pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{}); pub const setAttribute = bridge.function(Element.setAttribute, .{}); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 46e5705ed..b4b9a4ee7 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -120,6 +120,9 @@ pub const JsApi = struct { pub const List = struct { _list: std.DoublyLinkedList = .{}, + pub fn isEmpty(self: *const List) bool { + return self._list.first == null; + } pub fn get(self: *const List, name: []const u8, page: *Page) !?[]const u8 { const entry = (try self.getEntry(name, page)) orelse return null; return entry._value.str(); From fd391681068009c202b4185544b71ac853072d09 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 10:57:20 +0800 Subject: [PATCH 120/257] Range --- src/browser/js/bridge.zig | 1 + src/browser/tests/node/child_nodes.html | 4 + src/browser/tests/range.html | 377 +++++++++++++++ src/browser/webapi/Document.zig | 6 + src/browser/webapi/Node.zig | 55 +++ src/browser/webapi/Range.zig | 493 ++++++++++++++++++++ src/browser/webapi/collections/NodeList.zig | 19 + 7 files changed, 955 insertions(+) create mode 100644 src/browser/tests/range.html create mode 100644 src/browser/webapi/Range.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 0feec8a85..c6f899d7e 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -516,6 +516,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/DOMRect.zig"), @import("../webapi/DOMParser.zig"), @import("../webapi/XMLSerializer.zig"), + @import("../webapi/Range.zig"), @import("../webapi/NodeFilter.zig"), @import("../webapi/Element.zig"), @import("../webapi/element/DOMStringMap.zig"), diff --git a/src/browser/tests/node/child_nodes.html b/src/browser/tests/node/child_nodes.html index 7534eff44..3a6b67974 100644 --- a/src/browser/tests/node/child_nodes.html +++ b/src/browser/tests/node/child_nodes.html @@ -73,7 +73,11 @@ testing.expectEqual([0], Array.from(one.keys())); testing.expectEqual([p10], Array.from(one.values())); testing.expectEqual([[0, p10]], Array.from(one.entries())); + testing.expectEqual([p10], Array.from(one)); + let foreach = []; + one.forEach((p) => foreach.push(p)); + testing.expectEqual([p10], foreach); + +
+

First paragraph

+

Second paragraph

+ Span content +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 2643e26c0..6cea49870 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -177,6 +177,11 @@ pub fn createTextNode(_: *const Document, data: []const u8, page: *Page) !*Node return page.createTextNode(data); } +const Range = @import("Range.zig"); +pub fn createRange(_: *const Document, page: *Page) !*Range { + return Range.init(page); +} + pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") { const Event = @import("Event.zig"); @@ -290,6 +295,7 @@ pub const JsApi = struct { pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); pub const createComment = bridge.function(Document.createComment, .{}); pub const createTextNode = bridge.function(Document.createTextNode, .{}); + pub const createRange = bridge.function(Document.createRange, .{}); pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true }); pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{}); pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{}); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 9ae1ec480..ab0c28ec8 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -419,6 +419,61 @@ pub fn childrenIterator(self: *Node) NodeIterator { }; } +pub fn getLength(self: *Node) u32 { + switch (self._type) { + .cdata => |cdata| { + return @intCast(cdata.getData().len); + }, + .element, .document, .document_fragment => { + var count: u32 = 0; + var it = self.childrenIterator(); + while (it.next()) |_| { + count += 1; + } + return count; + }, + .document_type, .attribute => return 0, + } +} + +pub fn getChildIndex(self: *Node, target: *const Node) ?u32 { + var i: u32 = 0; + var it = self.childrenIterator(); + while (it.next()) |child| { + if (child == target) { + return i; + } + i += 1; + } + return null; +} + +pub fn getChildAt(self: *Node, index: u32) ?*Node { + var i: u32 = 0; + var it = self.childrenIterator(); + while (it.next()) |child| { + if (i == index) { + return child; + } + i += 1; + } + return null; +} + +pub fn getData(self: *const Node) []const u8 { + return switch (self._type) { + .cdata => |c| c.getData(), + else => "", + }; +} + +pub fn setData(self: *Node, data: []const u8) void { + switch (self._type) { + .cdata => |c| c._data = data, + else => {}, + } +} + pub fn className(self: *const Node) []const u8 { switch (self._type) { inline else => |c| return c.className(), diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig new file mode 100644 index 000000000..e9af36038 --- /dev/null +++ b/src/browser/webapi/Range.zig @@ -0,0 +1,493 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../js/js.zig"); + +const Page = @import("../Page.zig"); +const Node = @import("Node.zig"); +const DocumentFragment = @import("DocumentFragment.zig"); + +const Range = @This(); + +_end_offset: u32, +_start_offset: u32, +_end_container: *Node, +_start_container: *Node, + +pub fn init(page: *Page) !*Range { + // Per spec, a new range starts collapsed at the document's first position + const doc = page.document.asNode(); + return page._factory.create(Range{ + ._end_offset = 0, + ._start_offset = 0, + ._end_container = doc, + ._start_container = doc, + }); +} + +pub fn getStartContainer(self: *const Range) *Node { + return self._start_container; +} + +pub fn getStartOffset(self: *const Range) u32 { + return self._start_offset; +} + +pub fn getEndContainer(self: *const Range) *Node { + return self._end_container; +} + +pub fn getEndOffset(self: *const Range) u32 { + return self._end_offset; +} + +pub fn getCollapsed(self: *const Range) bool { + return self._start_container == self._end_container and + self._start_offset == self._end_offset; +} + +pub fn setStart(self: *Range, node: *Node, offset: u32) !void { + self._start_container = node; + self._start_offset = offset; + + // If start is now after end, collapse to start + if (self.isStartAfterEnd()) { + self._end_container = self._start_container; + self._end_offset = self._start_offset; + } +} + +pub fn setEnd(self: *Range, node: *Node, offset: u32) !void { + self._end_container = node; + self._end_offset = offset; + + // If end is now before start, collapse to end + if (self.isStartAfterEnd()) { + self._start_container = self._end_container; + self._start_offset = self._end_offset; + } +} + +pub fn setStartBefore(self: *Range, node: *Node) !void { + const parent = node.parentNode() orelse return error.InvalidNodeType; + const offset = parent.getChildIndex(node) orelse return error.NotFound; + try self.setStart(parent, offset); +} + +pub fn setStartAfter(self: *Range, node: *Node) !void { + const parent = node.parentNode() orelse return error.InvalidNodeType; + const offset = parent.getChildIndex(node) orelse return error.NotFound; + try self.setStart(parent, offset + 1); +} + +pub fn setEndBefore(self: *Range, node: *Node) !void { + const parent = node.parentNode() orelse return error.InvalidNodeType; + const offset = parent.getChildIndex(node) orelse return error.NotFound; + try self.setEnd(parent, offset); +} + +pub fn setEndAfter(self: *Range, node: *Node) !void { + const parent = node.parentNode() orelse return error.InvalidNodeType; + const offset = parent.getChildIndex(node) orelse return error.NotFound; + try self.setEnd(parent, offset + 1); +} + +pub fn selectNode(self: *Range, node: *Node) !void { + const parent = node.parentNode() orelse return error.InvalidNodeType; + const offset = parent.getChildIndex(node) orelse return error.NotFound; + try self.setStart(parent, offset); + try self.setEnd(parent, offset + 1); +} + +pub fn selectNodeContents(self: *Range, node: *Node) !void { + const length = node.getLength(); + try self.setStart(node, 0); + try self.setEnd(node, length); +} + +pub fn collapse(self: *Range, to_start: ?bool) void { + if (to_start orelse true) { + self._end_container = self._start_container; + self._end_offset = self._start_offset; + } else { + self._start_container = self._end_container; + self._start_offset = self._end_offset; + } +} + +pub fn cloneRange(self: *const Range, page: *Page) !*Range { + return page._factory.create(Range{ + ._end_offset = self._end_offset, + ._start_offset = self._start_offset, + ._end_container = self._end_container, + ._start_container = self._start_container, + }); +} + +pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { + // Insert node at the start of the range + const container = self._start_container; + const offset = self._start_offset; + + if (container.is(Node.CData)) |_| { + // If container is a text node, we need to split it + const parent = container.parentNode() orelse return error.InvalidNodeType; + + if (offset == 0) { + _ = try parent.insertBefore(node, container, page); + } else { + const text_data = container.getData(); + if (offset >= text_data.len) { + _ = try parent.insertBefore(node, container.nextSibling(), page); + } else { + // Split the text node into before and after parts + const before_text = text_data[0..offset]; + const after_text = text_data[offset..]; + + const before = try page.createTextNode(before_text); + const after = try page.createTextNode(after_text); + + _ = try parent.replaceChild(before, container, page); + _ = try parent.insertBefore(node, before.nextSibling(), page); + _ = try parent.insertBefore(after, node.nextSibling(), page); + } + } + } else { + // Container is an element, insert at offset + const ref_child = container.getChildAt(offset); + _ = try container.insertBefore(node, ref_child, page); + } + + // Update range to be after the inserted node + if (self._start_container == self._end_container) { + self._end_offset += 1; + } +} + +pub fn deleteContents(self: *Range, page: *Page) !void { + if (self.getCollapsed()) { + return; + } + + // Simple case: same container + if (self._start_container == self._end_container) { + if (self._start_container.is(Node.CData)) |_| { + // Delete part of text node + const text_data = self._start_container.getData(); + const new_text = try std.mem.concat( + page.arena, + u8, + &.{ text_data[0..self._start_offset], text_data[self._end_offset..] }, + ); + self._start_container.setData(new_text); + } else { + // Delete child nodes in range + var offset = self._start_offset; + while (offset < self._end_offset) : (offset += 1) { + if (self._start_container.getChildAt(self._start_offset)) |child| { + _ = try self._start_container.removeChild(child, page); + } + } + } + self.collapse(true); + return; + } + + // Complex case: different containers - simplified implementation + // Just collapse the range for now + self.collapse(true); +} + +pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment { + const fragment = try DocumentFragment.init(page); + + if (self.getCollapsed()) return fragment; + + // Simple case: same container + if (self._start_container == self._end_container) { + if (self._start_container.is(Node.CData)) |_| { + // Clone part of text node + const text_data = self._start_container.getData(); + if (self._start_offset < text_data.len and self._end_offset <= text_data.len) { + const cloned_text = text_data[self._start_offset..self._end_offset]; + const text_node = try page.createTextNode(cloned_text); + _ = try fragment.asNode().appendChild(text_node, page); + } + } else { + // Clone child nodes in range + var offset = self._start_offset; + while (offset < self._end_offset) : (offset += 1) { + if (self._start_container.getChildAt(offset)) |child| { + const cloned = try child.cloneNode(true, page); + _ = try fragment.asNode().appendChild(cloned, page); + } + } + } + } + + return fragment; +} + +pub fn extractContents(self: *Range, page: *Page) !*DocumentFragment { + const fragment = try self.cloneContents(page); + try self.deleteContents(page); + return fragment; +} + +pub fn surroundContents(self: *Range, new_parent: *Node, page: *Page) !void { + // Extract contents + const contents = try self.extractContents(page); + + // Insert the new parent + try self.insertNode(new_parent, page); + + // Move contents into new parent + _ = try new_parent.appendChild(contents.asNode(), page); + + // Select the new parent's contents + try self.selectNodeContents(new_parent); +} + +pub fn createContextualFragment(self: *const Range, html: []const u8, page: *Page) !*DocumentFragment { + var context_node = self._start_container; + + // If start container is a text node, use its parent as context + if (context_node.is(Node.CData)) |_| { + context_node = context_node.parentNode() orelse context_node; + } + + const fragment = try DocumentFragment.init(page); + + if (html.len == 0) { + return fragment; + } + + // Create a temporary element of the same type as the context for parsing + // This preserves the parsing context without modifying the original node + const temp_node = if (context_node.is(Node.Element)) |el| + try page.createElement(el._namespace.toUri(), el.getTagNameLower(), null) + else + try page.createElement(null, "div", null); + + try page.parseHtmlAsChildren(temp_node, html); + + // Move all parsed children to the fragment + // Keep removing first child until temp element is empty + const fragment_node = fragment.asNode(); + while (temp_node.firstChild()) |child| { + page.removeNode(temp_node, child, .{ .will_be_reconnected = true }); + try page.appendNode(fragment_node, child, .{ .child_already_connected = false }); + } + + return fragment; +} + +pub fn toString(self: *const Range, page: *Page) ![]const u8 { + // Simplified implementation: just extract text content + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self.writeTextContent(&buf.writer); + return buf.written(); +} + +fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { + if (self.getCollapsed()) { + return; + } + + if (self._start_container == self._end_container) { + if (self._start_container.is(Node.CData)) |cdata| { + const data = cdata.getData(); + if (self._start_offset < data.len and self._end_offset <= data.len) { + try writer.writeAll(data[self._start_offset..self._end_offset]); + } + } + // For elements, would need to iterate children + return; + } + + // Complex case: different containers - would need proper tree walking + // For now, just return empty +} + +fn isStartAfterEnd(self: *const Range) bool { + return compareBoundaryPoints( + self._start_container, + self._start_offset, + self._end_container, + self._end_offset, + ) == .after; +} + +const BoundaryComparison = enum { + before, + equal, + after, +}; + +/// Compare two boundary points in tree order +/// Returns whether (nodeA, offsetA) is before/equal/after (nodeB, offsetB) +fn compareBoundaryPoints( + node_a: *Node, + offset_a: u32, + node_b: *Node, + offset_b: u32, +) BoundaryComparison { + // If same container, just compare offsets + if (node_a == node_b) { + if (offset_a < offset_b) return .before; + if (offset_a > offset_b) return .after; + return .equal; + } + + // Check if one contains the other + if (isAncestorOf(node_a, node_b)) { + // A contains B, so A's position comes before B + // But we need to check if the offset in A comes after B + var child = node_b; + var parent = child.parentNode(); + while (parent) |p| { + if (p == node_a) { + const child_index = p.getChildIndex(child) orelse unreachable; + if (offset_a <= child_index) { + return .before; + } + return .after; + } + child = p; + parent = p.parentNode(); + } + unreachable; + } + + if (isAncestorOf(node_b, node_a)) { + // B contains A, so B's position comes before A + var child = node_a; + var parent = child.parentNode(); + while (parent) |p| { + if (p == node_b) { + const child_index = p.getChildIndex(child) orelse unreachable; + if (child_index < offset_b) { + return .before; + } + return .after; + } + child = p; + parent = p.parentNode(); + } + unreachable; + } + + // Neither contains the other, find their relative position in tree order + // Walk up from A to find all ancestors + var current = node_a; + var a_count: usize = 0; + var a_ancestors: [64]*Node = undefined; + while (a_count < 64) { + a_ancestors[a_count] = current; + a_count += 1; + current = current.parentNode() orelse break; + } + + // Walk up from B and find first common ancestor + current = node_b; + while (current.parentNode()) |parent| { + for (a_ancestors[0..a_count]) |ancestor| { + if (ancestor != parent) { + continue; + } + + // Found common ancestor + // Now compare positions of the children in this ancestor + const a_child = blk: { + var node = node_a; + while (node.parentNode()) |p| { + if (p == parent) break :blk node; + node = p; + } + unreachable; + }; + const b_child = current; + + const a_index = parent.getChildIndex(a_child) orelse unreachable; + const b_index = parent.getChildIndex(b_child) orelse unreachable; + + if (a_index < b_index) { + return .before; + } + if (a_index > b_index) { + return .after; + } + return .equal; + } + current = parent; + } + + // Should not reach here if nodes are in the same tree + return .before; +} + +fn isAncestorOf(potential_ancestor: *Node, node: *Node) bool { + var current = node.parentNode(); + while (current) |parent| { + if (parent == potential_ancestor) { + return true; + } + current = parent.parentNode(); + } + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Range); + + pub const Meta = struct { + pub const name = "Range"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(Range.init, .{}); + pub const startContainer = bridge.accessor(Range.getStartContainer, null, .{}); + pub const startOffset = bridge.accessor(Range.getStartOffset, null, .{}); + pub const endContainer = bridge.accessor(Range.getEndContainer, null, .{}); + pub const endOffset = bridge.accessor(Range.getEndOffset, null, .{}); + pub const collapsed = bridge.accessor(Range.getCollapsed, null, .{}); + pub const setStart = bridge.function(Range.setStart, .{}); + pub const setEnd = bridge.function(Range.setEnd, .{}); + pub const setStartBefore = bridge.function(Range.setStartBefore, .{}); + pub const setStartAfter = bridge.function(Range.setStartAfter, .{}); + pub const setEndBefore = bridge.function(Range.setEndBefore, .{}); + pub const setEndAfter = bridge.function(Range.setEndAfter, .{}); + pub const selectNode = bridge.function(Range.selectNode, .{}); + pub const selectNodeContents = bridge.function(Range.selectNodeContents, .{}); + pub const collapse = bridge.function(Range.collapse, .{}); + pub const cloneRange = bridge.function(Range.cloneRange, .{}); + pub const insertNode = bridge.function(Range.insertNode, .{}); + pub const deleteContents = bridge.function(Range.deleteContents, .{}); + pub const cloneContents = bridge.function(Range.cloneContents, .{}); + pub const extractContents = bridge.function(Range.extractContents, .{}); + pub const surroundContents = bridge.function(Range.surroundContents, .{}); + pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{}); + pub const toString = bridge.function(Range.toString, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Range" { + try testing.htmlRunner("range.html", .{}); +} diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index b49a29b6b..0e4a3c2e6 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -18,6 +18,7 @@ const std = @import("std"); +const log = @import("../../..//log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); @@ -63,6 +64,23 @@ pub fn entries(self: *NodeList, page: *Page) !*EntryIterator { return .init(.{ .list = self }, page); } +pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { + var i: i32 = 0; + var it = try self.values(page); + while (true) : (i += 1) { + const next = try it.next(page); + if (next.done) { + return; + } + + var result: js.Function.Result = undefined; + cb.tryCall(void, .{ next.value, i, self }, &result) catch { + log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack }); + return; + }; + } +} + const GenericIterator = @import("iterator.zig").Entry; pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); @@ -96,5 +114,6 @@ pub const JsApi = struct { pub const keys = bridge.function(NodeList.keys, .{}); pub const values = bridge.function(NodeList.values, .{}); pub const entries = bridge.function(NodeList.entries, .{}); + pub const forEach = bridge.function(NodeList.forEach, .{}); pub const symbol_iterator = bridge.iterator(NodeList.values, .{}); }; From 6a46a9ba47412adb9ca4143b38e34681ec6df7ef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 11:08:47 +0800 Subject: [PATCH 121/257] HTMLDataElement --- src/browser/Page.zig | 6 +++ src/browser/js/bridge.zig | 1 + src/browser/webapi/Element.zig | 4 ++ src/browser/webapi/element/Html.zig | 3 ++ src/browser/webapi/element/html/Data.zig | 56 ++++++++++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 src/browser/webapi/element/html/Data.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 7158231fb..0b4a4149b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1094,6 +1094,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined, ._tag_name = String.init(undefined, "main", .{}) catch unreachable, ._tag = .main }, ), + asUint("data") => return self.createHtmlElementT( + Element.Html.Data, + namespace, + attribute_iterator, + .{ ._proto = undefined }, + ), else => {}, }, 5 => switch (@as(u40, @bitCast(name[0..5].*))) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index c6f899d7e..a45693558 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -528,6 +528,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/BR.zig"), @import("../webapi/element/html/Button.zig"), @import("../webapi/element/html/Custom.zig"), + @import("../webapi/element/html/Data.zig"), @import("../webapi/element/html/Dialog.zig"), @import("../webapi/element/html/Div.zig"), @import("../webapi/element/html/Form.zig"), diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 12c4b686c..e37687e9d 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -129,6 +129,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .br => "br", .button => "button", .custom => |e| e._tag_name.str(), + .data => "data", .dialog => "dialog", .div => "div", .form => "form", @@ -174,6 +175,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .br => "BR", .button => "BUTTON", .custom => |e| upperTagName(&e._tag_name, buf), + .data => "DATA", .dialog => "DIALOG", .div => "DIV", .form => "FORM", @@ -793,6 +795,7 @@ pub fn getTag(self: *const Element) Tag { .form => .form, .p => .p, .custom => .custom, + .data => .data, .dialog => .dialog, .iframe => .iframe, .img => .img, @@ -835,6 +838,7 @@ pub const Tag = enum { button, circle, custom, + data, dialog, div, ellipse, diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index e6d748c8f..cefdaf659 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -42,6 +42,7 @@ pub const Custom = @import("html/Custom.zig"); pub const Script = @import("html/Script.zig"); pub const Anchor = @import("html/Anchor.zig"); pub const Button = @import("html/Button.zig"); +pub const Data = @import("html/Data.zig"); pub const Dialog = @import("html/Dialog.zig"); pub const Form = @import("html/Form.zig"); pub const Heading = @import("html/Heading.zig"); @@ -72,6 +73,7 @@ pub const Type = union(enum) { br: *BR, button: *Button, custom: *Custom, + data: *Data, dialog: *Dialog, div: *Div, form: *Form, @@ -121,6 +123,7 @@ pub fn className(self: *const HtmlElement) []const u8 { .form => "[object HTMLFormElement]", .p => "[object HtmlParagraphElement]", .custom => "[object CUSTOM-TODO]", + .data => "[object HTMLDataElement]", .dialog => "[object HTMLDialogElement]", .img => "[object HTMLImageElement]", .iframe => "[object HTMLIFrameElement]", diff --git a/src/browser/webapi/element/html/Data.zig b/src/browser/webapi/element/html/Data.zig new file mode 100644 index 000000000..08e779f8e --- /dev/null +++ b/src/browser/webapi/element/html/Data.zig @@ -0,0 +1,56 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); + +const Data = @This(); + +_proto: *HtmlElement, + +pub fn asElement(self: *Data) *Element { + return self._proto._proto; +} + +pub fn asNode(self: *Data) *Node { + return self.asElement().asNode(); +} + +pub fn getValue(self: *Data) []const u8 { + return self.asElement().getAttributeSafe("value") orelse ""; +} + +pub fn setValue(self: *Data, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("value", value, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Data); + + pub const Meta = struct { + pub const name = "HTMLDataElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const value = bridge.accessor(Data.getValue, Data.setValue, .{}); +}; From 3dd61aeb7104e2bc43e1b836fc48a970128bc544 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 11:14:06 +0800 Subject: [PATCH 122/257] css.zig -> CSS.zig --- src/browser/js/bridge.zig | 2 +- src/browser/webapi/{css.zig => CSS.zig} | 0 src/browser/webapi/Element.zig | 2 +- src/browser/webapi/Window.zig | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/browser/webapi/{css.zig => CSS.zig} (100%) diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index a45693558..1522c9d37 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -493,7 +493,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), - @import("../webapi/css.zig"), + @import("../webapi/CSS.zig"), @import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSStyleDeclaration.zig"), diff --git a/src/browser/webapi/css.zig b/src/browser/webapi/CSS.zig similarity index 100% rename from src/browser/webapi/css.zig rename to src/browser/webapi/CSS.zig diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index e37687e9d..9bf36ab3f 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -32,7 +32,7 @@ pub const Attribute = @import("element/Attribute.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMStringMap = @import("element/DOMStringMap.zig"); const DOMRect = @import("DOMRect.zig"); -const CSS = @import("css.zig"); +const CSS = @import("CSS.zig"); const ShadowRoot = @import("ShadowRoot.zig"); pub const Svg = @import("element/Svg.zig"); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 46a5afbe8..ad755b441 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -25,7 +25,7 @@ const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); const Crypto = @import("Crypto.zig"); -const CSS = @import("css.zig"); +const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); const Screen = @import("Screen.zig"); const Performance = @import("Performance.zig"); From abd3ee9c5d59b42848c00dc7c544908ad13e9696 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 11:24:26 +0800 Subject: [PATCH 123/257] Add ignore list for unkown global property This is for often-seen globals which we _know_ come from client-side libraries, e.g. litNonce. --- src/browser/polyfill/polyfill.zig | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/browser/polyfill/polyfill.zig b/src/browser/polyfill/polyfill.zig index cfb502a73..c14c75a2c 100644 --- a/src/browser/polyfill/polyfill.zig +++ b/src/browser/polyfill/polyfill.zig @@ -54,6 +54,28 @@ pub const Loader = struct { } if (comptime builtin.mode == .Debug) { + const ignored = std.StaticStringMap(void).initComptime(.{ + .{ "process", {} }, + .{ "ShadyDOM", {} }, + .{ "ShadyCSS", {} }, + + .{ "litNonce", {} }, + .{ "litHtmlVersions", {} }, + .{ "litHtmlPolyfillSupport", {} }, + .{ "litElementHydrateSupport", {} }, + + .{ "recaptcha", {} }, + .{ "grecaptcha", {} }, + .{ "___grecaptcha_cfg", {} }, + .{ "__recaptcha_api", {} }, + .{ "__google_recaptcha_client", {} }, + + .{ "CLOSURE_FLAGS", {} }, + }); + if (ignored.has(name)) { + return false; + } + log.debug(.unknown_prop, "unkown global property", .{ .info = "but the property can exist in pure JS", .stack = js_context.stackTrace() catch "???", From a61e87c5ddde3fb95c0287bc344aa9456a66e651 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 13:25:48 +0800 Subject: [PATCH 124/257] Don't break wait on scheduler callback error Allow recursive parsing --- src/browser/Page.zig | 7 +++---- src/browser/webapi/net/Fetch.zig | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0b4a4149b..7f8ad2bcf 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -629,8 +629,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { if (try_catch.hasCaught()) { const msg = (try try_catch.err(self.arena)) orelse "unknown"; - log.warn(.js, "page wait", .{ .err = msg, .src = "scheduler" }); - return error.JsError; + log.info(.js, "page wait", .{ .err = msg, .src = "scheduler" }); } const http_active = http_client.active; @@ -1648,9 +1647,9 @@ pub fn childListChange( // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { - std.debug.assert(self._parse_mode == .document); + const previous_parse_mode = self._parse_mode; self._parse_mode = .fragment; - defer self._parse_mode = .document; + defer self._parse_mode = previous_parse_mode; var parser = Parser.init(self.call_arena, node, self); parser.parseFragment(html); diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index b00b3c7c0..cea5a2132 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -67,8 +67,8 @@ pub fn init(input: Input, options: ?RequestInit, page: *Page) !js.Promise { .url = request._url, .method = .GET, .headers = headers, - .cookie_jar = &page._session.cookie_jar, .resource_type = .fetch, + .cookie_jar = &page._session.cookie_jar, .header_callback = httpHeaderDoneCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, From c90e9c165b4caada8f056273bbaa90e66ea98af1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 15:13:55 +0800 Subject: [PATCH 125/257] add Performance.Mark --- src/browser/tests/performance.html | 88 ++++++++++++++++++ src/browser/webapi/Performance.zig | 142 +++++++++++++++++++++++++++-- 2 files changed, 222 insertions(+), 8 deletions(-) diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html index 5aed2cc12..a26477920 100644 --- a/src/browser/tests/performance.html +++ b/src/browser/tests/performance.html @@ -43,3 +43,91 @@ } } + + + + + + + + + + diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index c659a7f89..e272f71a9 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -1,20 +1,22 @@ const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const datetime = @import("../../datetime.zig"); pub fn registerTypes() []const type { - return &.{ - Performance, - Entry, - }; + return &.{ Performance, Entry, Mark }; } +const std = @import("std"); + const Performance = @This(); _time_origin: u64, +_entries: std.ArrayListUnmanaged(*Entry) = .{}, pub fn init() Performance { return .{ ._time_origin = datetime.milliTimestamp(.monotonic), + ._entries = .{}, }; } @@ -28,6 +30,75 @@ pub fn getTimeOrigin(self: *const Performance) f64 { return @floatFromInt(self._time_origin); } +pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page) !*Mark { + const m = try Mark.init(name, _options, page); + try self._entries.append(page.arena, m._proto); + return m; +} + +pub fn clearMarks(self: *Performance, mark_name: ?[]const u8) void { + if (mark_name) |name| { + // Remove specific mark by name + var i: usize = 0; + while (i < self._entries.items.len) { + const entry = self._entries.items[i]; + if (entry._type == .mark and std.mem.eql(u8, entry._name, name)) { + _ = self._entries.orderedRemove(i); + } else { + i += 1; + } + } + } else { + // Remove all marks + var i: usize = 0; + while (i < self._entries.items.len) { + const entry = self._entries.items[i]; + if (entry._type == .mark) { + _ = self._entries.orderedRemove(i); + } else { + i += 1; + } + } + } +} + +pub fn getEntries(self: *const Performance) []*Entry { + return self._entries.items; +} + +pub fn getEntriesByType(self: *const Performance, entry_type: []const u8, page: *Page) ![]const *Entry { + var result: std.ArrayList(*Entry) = .empty; + + for (self._entries.items) |entry| { + if (std.mem.eql(u8, entry.getEntryType(), entry_type)) { + try result.append(page.call_arena, entry); + } + } + + return result.items; +} + +pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: ?[]const u8, page: *Page) ![]const *Entry { + var result: std.ArrayList(*Entry) = .empty; + + for (self._entries.items) |entry| { + if (!std.mem.eql(u8, entry._name, name)) { + continue; + } + + const et = entry_type orelse { + try result.append(page.call_arena, entry); + continue; + }; + + if (std.mem.eql(u8, entry.getEntryType(), et)) { + try result.append(page.call_arena, entry); + } + } + + return result.items; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Performance); @@ -38,16 +109,21 @@ pub const JsApi = struct { }; pub const now = bridge.function(Performance.now, .{}); + pub const mark = bridge.function(Performance.mark, .{}); + pub const clearMarks = bridge.function(Performance.clearMarks, .{}); + pub const getEntries = bridge.function(Performance.getEntries, .{}); + pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{}); + pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); }; pub const Entry = struct { _duration: f64 = 0.0, - _entry_type: Type, + _type: Type, _name: []const u8, _start_time: f64 = 0.0, - const Type = enum { + const Type = union(enum) { element, event, first_input, @@ -55,13 +131,13 @@ pub const Entry = struct { layout_shift, long_animation_frame, longtask, - mark, measure, navigation, paint, resource, taskattribution, visibility_state, + mark: *Mark, }; pub fn getDuration(self: *const Entry) f64 { @@ -69,7 +145,7 @@ pub const Entry = struct { } pub fn getEntryType(self: *const Entry) []const u8 { - return switch (self._entry_type) { + return switch (self._type) { .first_input => "first-input", .largest_contentful_paint => "largest-contentful-paint", .layout_shift => "layout-shift", @@ -95,8 +171,58 @@ pub const Entry = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + pub const name = bridge.accessor(Entry.getName, null, .{}); pub const duration = bridge.accessor(Entry.getDuration, null, .{}); pub const entryType = bridge.accessor(Entry.getEntryType, null, .{}); + pub const startTime = bridge.accessor(Entry.getStartTime, null, .{}); + }; +}; + +pub const Mark = struct { + _proto: *Entry, + _detail: ?js.Object, + + const Options = struct { + detail: ?js.Object = null, + startTime: ?f64 = null, + }; + + pub fn init(name: []const u8, _opts: ?Options, page: *Page) !*Mark { + const opts = _opts orelse Options{}; + const start_time = opts.startTime orelse page.window._performance.now(); + + if (start_time < 0.0) { + return error.TypeError; + } + + const detail = if (opts.detail) |d| try d.persist() else null; + const m = try page._factory.create(Mark{ + ._proto = undefined, + ._detail = detail, + }); + + const entry = try page._factory.create(Entry{ + ._start_time = start_time, + ._name = try page.dupeString(name), + ._type = .{ .mark = m }, + }); + m._proto = entry; + return m; + } + + pub fn getDetail(self: *const Mark) ?js.Object { + return self._detail; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(Mark); + + pub const Meta = struct { + pub const name = "PerformanceMark"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const detail = bridge.accessor(Mark.getDetail, null, .{}); }; }; From b5eceb52fbb052b274f7d2ce39d0be4ad35f9662 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 16:05:57 +0800 Subject: [PATCH 126/257] try safer http cleanup on page deinit --- src/browser/webapi/net/Fetch.zig | 7 +++++++ src/browser/webapi/net/XMLHttpRequest.zig | 7 +++++++ src/http/Client.zig | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index cea5a2132..0f6c37f32 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -77,6 +77,13 @@ pub fn init(input: Input, options: ?RequestInit, page: *Page) !js.Promise { return fetch._resolver.promise(); } +pub fn deinit(self: *Fetch) void { + if (self.transfer) |transfer| { + transfer.abort(); + self.transfer = null; + } +} + fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 3bb219e2f..4b0cdb9f9 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -74,6 +74,13 @@ pub fn init(page: *Page) !*XMLHttpRequest { }); } +pub fn deinit(self: *XMLHttpRequest) void { + if (self.transfer) |transfer| { + transfer.abort(); + self.transfer = null; + } +} + fn asEventTarget(self: *XMLHttpRequest) *EventTarget { return self._proto._proto; } diff --git a/src/http/Client.zig b/src/http/Client.zig index 65f310667..1a646ea00 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -153,7 +153,7 @@ pub fn abort(self: *Client) void { log.err(.http, "get private info", .{ .err = err, .source = "abort" }); continue; }; - transfer.abort(); + transfer.kill(); } std.debug.assert(self.active == 0); @@ -812,6 +812,15 @@ pub const Transfer = struct { self.deinit(); } + // internal, when the page is shutting down. Doesn't have the same ceremony + // as abort (doesn't send a notification, doesn't invoke an error callback) + fn kill(self: *Transfer) void { + if (self._handle != null) { + self.client.endTransfer(self); + } + self.deinit(); + } + // abortAuthChallenge is called when an auth chanllenge interception is // abort. We don't call self.client.endTransfer here b/c it has been done // before interception process. From 568a4428baac8681cb6edea089cceafa38774c17 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 22:19:58 +0800 Subject: [PATCH 127/257] custom element registry 'whenDefine' function --- src/browser/polyfill/polyfill.zig | 3 ++ .../tests/custom_elements/registry.html | 38 +++++++++++++++++++ src/browser/webapi/CustomElementRegistry.zig | 25 ++++++++++++ 3 files changed, 66 insertions(+) diff --git a/src/browser/polyfill/polyfill.zig b/src/browser/polyfill/polyfill.zig index c14c75a2c..bf6f92274 100644 --- a/src/browser/polyfill/polyfill.zig +++ b/src/browser/polyfill/polyfill.zig @@ -61,8 +61,11 @@ pub const Loader = struct { .{ "litNonce", {} }, .{ "litHtmlVersions", {} }, + .{ "litElementVersions", {} }, .{ "litHtmlPolyfillSupport", {} }, .{ "litElementHydrateSupport", {} }, + .{ "litElementPolyfillSupport", {} }, + .{ "reactiveElementVersions", {} }, .{ "recaptcha", {} }, .{ "grecaptcha", {} }, diff --git a/src/browser/tests/custom_elements/registry.html b/src/browser/tests/custom_elements/registry.html index 8ead6ae2a..064aa9f9e 100644 --- a/src/browser/tests/custom_elements/registry.html +++ b/src/browser/tests/custom_elements/registry.html @@ -81,3 +81,41 @@ testing.expectEqual('NO-HYPHEN-INVALID', el.tagName); } + + + + + + + diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 2f8a912cd..727e74edb 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -30,6 +30,7 @@ const CustomElementDefinition = @import("CustomElementDefinition.zig"); const CustomElementRegistry = @This(); _definitions: std.StringHashMapUnmanaged(*CustomElementDefinition) = .{}, +_when_defined: std.StringHashMapUnmanaged(js.PersistentPromiseResolver) = .{}, const DefineOptions = struct { extends: ?[]const u8 = null, @@ -103,6 +104,10 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu _ = page._undefined_custom_elements.swapRemove(idx); } + + if (self._when_defined.fetchRemove(name)) |entry| { + try entry.value.resolve(constructor); + } } pub fn get(self: *CustomElementRegistry, name: []const u8) ?js.Function { @@ -114,6 +119,25 @@ pub fn upgrade(self: *CustomElementRegistry, root: *Node, page: *Page) !void { try upgradeNode(self, root, page); } +pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page) !js.Promise { + if (self._definitions.get(name)) |definition| { + return page.js.resolvePromise(definition.constructor); + } + + const gop = try self._when_defined.getOrPut(page.arena, name); + if (gop.found_existing) { + return gop.value_ptr.promise(); + } + errdefer _ = self._when_defined.remove(name); + const owned_name = try page.dupeString(name); + + const resolver = try page.js.createPromiseResolver(.page); + gop.key_ptr.* = owned_name; + gop.value_ptr.* = resolver; + + return resolver.promise(); +} + fn upgradeNode(self: *CustomElementRegistry, node: *Node, page: *Page) !void { if (node.is(Element)) |element| { try upgradeElement(self, element, page); @@ -222,6 +246,7 @@ pub const JsApi = struct { pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true }); pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true }); pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{}); + pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{}); }; const testing = @import("../../testing.zig"); From c0da6994dab1a8615e211168d56aa479b559631d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 08:52:51 +0800 Subject: [PATCH 128/257] Element.setInnerText --- src/browser/dump.zig | 34 +++++++++++++++++++++++++++- src/browser/tests/element/inner.html | 33 +++++++++++++++++++++++++++ src/browser/webapi/Element.zig | 22 +++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 73ebe42b9..e1feb57f8 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -64,7 +64,7 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { switch (node._type) { - .cdata => |cd| try writer.writeAll(cd.getData()), + .cdata => |cd| try writeEscapedText(cd.getData(), writer), .element => |el| { if (shouldStripElement(el, opts)) { return; @@ -211,3 +211,35 @@ fn shouldStripElement(el: *const Node.Element, opts: Opts) bool { return false; } + +fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void { + // Fast path: if no special characters, write directly + const first_special = std.mem.indexOfAny(u8, text, "&<>") orelse { + return writer.writeAll(text); + }; + + try writer.writeAll(text[0..first_special]); + try writer.writeAll(switch (text[first_special]) { + '&' => "&", + '<' => "<", + '>' => ">", + else => unreachable, + }); + + // Process remaining text + var remaining = text[first_special + 1 ..]; + while (std.mem.indexOfAny(u8, remaining, "&<>")) |offset| { + try writer.writeAll(remaining[0..offset]); + try writer.writeAll(switch (remaining[offset]) { + '&' => "&", + '<' => "<", + '>' => ">", + else => unreachable, + }); + remaining = remaining[offset + 1 ..]; + } + + if (remaining.len > 0) { + try writer.writeAll(remaining); + } +} diff --git a/src/browser/tests/element/inner.html b/src/browser/tests/element/inner.html index da2aa5c62..b80231224 100644 --- a/src/browser/tests/element/inner.html +++ b/src/browser/tests/element/inner.html @@ -129,3 +129,36 @@ d1.innerHTML = '


'; testing.expectEqual('


', d1.innerHTML); + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 9bf36ab3f..402409539 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -229,6 +229,26 @@ pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { } } +pub fn setInnerText(self: *Element, text: []const u8, page: *Page) !void { + const parent = self.asNode(); + + // Remove all existing children + page.domChanged(); + var it = parent.childrenIterator(); + while (it.next()) |child| { + page.removeNode(parent, child, .{ .will_be_reconnected = false }); + } + + // Fast path: skip if text is empty + if (text.len == 0) { + return; + } + + // Create and append text node + const text_node = try page.createTextNode(text); + try page.appendNode(parent, text_node, .{ .child_already_connected = false }); +} + pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page); @@ -913,7 +933,7 @@ pub const JsApi = struct { } pub const namespaceURI = bridge.accessor(Element.getNamespaceURI, null, .{}); - pub const innerText = bridge.accessor(_innerText, null, .{}); + pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{}); fn _innerText(self: *Element, page: *const Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getInnerText(&buf.writer); From 2de0d4bc484a3c793a200dd836c23978c052661d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 09:59:55 +0800 Subject: [PATCH 129/257] Header case insensitive --- src/browser/tests/element/inner.html | 1 - src/browser/tests/net/headers.html | 138 +++++++++++++++++++- src/browser/webapi/collections/NodeList.zig | 2 +- src/browser/webapi/net/Headers.zig | 59 +++++++-- 4 files changed, 182 insertions(+), 18 deletions(-) diff --git a/src/browser/tests/element/inner.html b/src/browser/tests/element/inner.html index b80231224..c9bb08946 100644 --- a/src/browser/tests/element/inner.html +++ b/src/browser/tests/element/inner.html @@ -143,7 +143,6 @@ // innerText does NOT parse HTML (unlike innerHTML) d1.innerText = 'hello
world
!!'; testing.expectEqual('hello
world
!!', d1.innerText); - console.warn(d1.innerHTML); testing.expectEqual('hello <div>world</div><b>!!</b>', d1.innerHTML); // Setting empty string clears children diff --git a/src/browser/tests/net/headers.html b/src/browser/tests/net/headers.html index d0d1c35ea..07e967256 100644 --- a/src/browser/tests/net/headers.html +++ b/src/browser/tests/net/headers.html @@ -17,15 +17,145 @@ testing.expectEqual(null, headers.get('Content-Type')); testing.expectEqual(false, headers.has('Content-Type')); } + + + + diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 0e4a3c2e6..dae615098 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -75,7 +75,7 @@ pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { var result: js.Function.Result = undefined; cb.tryCall(void, .{ next.value, i, self }, &result) catch { - log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack }); + log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack, .source = "nodelist" }); return; }; } diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 9dea0b958..136207bd9 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -1,5 +1,6 @@ const std = @import("std"); const js = @import("../../js/js.zig"); +const log = @import("../../../log.zig"); const Page = @import("../../Page.zig"); const KeyValueList = @import("../KeyValueList.zig"); @@ -15,27 +16,58 @@ pub fn init(page: *Page) !*Headers { } pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - try self._list.append(page.arena, name, value); + const normalized_name = normalizeHeaderName(name, page); + try self._list.append(page.arena, normalized_name, value); } -pub fn delete(self: *Headers, name: []const u8) void { - self._list.delete(name, null); +pub fn delete(self: *Headers, name: []const u8, page: *Page) void { + const normalized_name = normalizeHeaderName(name, page); + self._list.delete(normalized_name, null); } -pub fn get(self: *const Headers, name: []const u8) ?[]const u8 { - return self._list.get(name); +pub fn get(self: *const Headers, name: []const u8, page: *Page) ?[]const u8 { + const normalized_name = normalizeHeaderName(name, page); + return self._list.get(normalized_name); } -pub fn getAll(self: *const Headers, name: []const u8, page: *Page) ![]const []const u8 { - return self._list.getAll(name, page); +pub fn has(self: *const Headers, name: []const u8, page: *Page) bool { + const normalized_name = normalizeHeaderName(name, page); + return self._list.has(normalized_name); } -pub fn has(self: *const Headers, name: []const u8) bool { - return self._list.has(name); +pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + const normalized_name = normalizeHeaderName(name, page); + try self._list.set(page.arena, normalized_name, value); } -pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - try self._list.set(page.arena, name, value); +pub fn keys(self: *Headers, page: *Page) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); +} + +pub fn values(self: *Headers, page: *Page) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); +} + +pub fn entries(self: *Headers, page: *Page) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); +} + +pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { + const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; + + for (self._list._entries.items) |entry| { + var result: js.Function.Result = undefined; + cb.tryCall(void, .{ entry.value.str(), entry.name.str(), self }, &result) catch { + log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack, .source = "headers" }); + }; + } +} + +fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { + if (name.len > page.buf.len) { + return name; + } + return std.ascii.lowerString(&page.buf, name); } pub const JsApi = struct { @@ -51,9 +83,12 @@ pub const JsApi = struct { pub const append = bridge.function(Headers.append, .{}); pub const delete = bridge.function(Headers.delete, .{}); pub const get = bridge.function(Headers.get, .{}); - pub const getAll = bridge.function(Headers.getAll, .{}); pub const has = bridge.function(Headers.has, .{}); pub const set = bridge.function(Headers.set, .{}); + pub const keys = bridge.function(Headers.keys, .{}); + pub const values = bridge.function(Headers.values, .{}); + pub const entries = bridge.function(Headers.entries, .{}); + pub const forEach = bridge.function(Headers.forEach, .{}); }; const testing = @import("../../../testing.zig"); From 63eeadad1d11d6cac03d85944604ddfaff376423 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 16:10:11 +0800 Subject: [PATCH 130/257] Fix comment dump, improve dump of shadowroot and slots --- src/browser/dump.zig | 45 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index e1feb57f8..03c9bd28c 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -63,25 +63,58 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { } pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { + return _deep(node, opts, false, writer, page); +} + +fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { switch (node._type) { - .cdata => |cd| try writeEscapedText(cd.getData(), writer), + .cdata => |cd| { + if (node.is(Node.CData.Comment)) |_| { + try writer.writeAll(""); + } else { + try writeEscapedText(cd.getData(), writer); + } + }, .element => |el| { if (shouldStripElement(el, opts)) { return; } - // Handle elements in rendered mode - if (opts.shadow == .rendered) { - if (el.is(Slot)) |slot| { - return dumpSlotContent(slot, opts, writer, page); + // When opts.shadow == .rendered, we normally skip any element with + // a slot attribute. Only the "active" element will get rendered into + // the . However, the `deep` function is itself used + // to render that "active" content, so when we're trying to render + // it, we don't want to skip it. + if ((comptime force_slot == false) and opts.shadow == .rendered) { + if (el.getAttributeSafe("slot")) |_| { + // Skip - will be rendered by the Slot if it's the active container + return; } } try el.format(writer); + if (opts.shadow == .rendered) { + if (el.is(Slot)) |slot| { + try dumpSlotContent(slot, opts, writer, page); + return writer.writeAll(""); + } + } if (opts.shadow != .skip) { if (page._element_shadow_roots.get(el)) |shadow| { try children(shadow.asNode(), opts, writer, page); + // In rendered mode, light DOM is only shown through slots, not directly + if (opts.shadow == .rendered) { + // Skip rendering light DOM children + if (!isVoidElement(el)) { + try writer.writeAll("'); + } + return; + } } } @@ -151,7 +184,7 @@ fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, page: *Page) if (assigned.len > 0) { for (assigned) |assigned_node| { - try deep(assigned_node, opts, writer, page); + try _deep(assigned_node, opts, true, writer, page); } } else { try children(slot.asNode(), opts, writer, page); From 2a4cbbe56943b9f82a19ff7e4781bd8af12bf7b6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 18:24:28 +0800 Subject: [PATCH 131/257] Performance.measure --- src/browser/tests/performance.html | 149 +++++++++++++++++++++++++++++ src/browser/webapi/Performance.zig | 122 ++++++++++++++++++----- 2 files changed, 249 insertions(+), 22 deletions(-) diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html index a26477920..5928bba93 100644 --- a/src/browser/tests/performance.html +++ b/src/browser/tests/performance.html @@ -131,3 +131,152 @@ testing.expectEqual(0, marks.length); } + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index e272f71a9..ce0707417 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -3,7 +3,7 @@ const Page = @import("../Page.zig"); const datetime = @import("../../datetime.zig"); pub fn registerTypes() []const type { - return &.{ Performance, Entry, Mark }; + return &.{ Performance, Entry, Mark, Measure }; } const std = @import("std"); @@ -36,28 +36,32 @@ pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: return m; } +pub fn measure(self: *Performance, name: []const u8, _options: ?Measure.Options, page: *Page) !*Measure { + const m = try Measure.init(name, _options, page); + try self._entries.append(page.arena, m._proto); + return m; +} + pub fn clearMarks(self: *Performance, mark_name: ?[]const u8) void { - if (mark_name) |name| { - // Remove specific mark by name - var i: usize = 0; - while (i < self._entries.items.len) { - const entry = self._entries.items[i]; - if (entry._type == .mark and std.mem.eql(u8, entry._name, name)) { - _ = self._entries.orderedRemove(i); - } else { - i += 1; - } + var i: usize = 0; + while (i < self._entries.items.len) { + const entry = self._entries.items[i]; + if (entry._type == .mark and (mark_name == null or std.mem.eql(u8, entry._name, mark_name.?))) { + _ = self._entries.orderedRemove(i); + } else { + i += 1; } - } else { - // Remove all marks - var i: usize = 0; - while (i < self._entries.items.len) { - const entry = self._entries.items[i]; - if (entry._type == .mark) { - _ = self._entries.orderedRemove(i); - } else { - i += 1; - } + } +} + +pub fn clearMeasures(self: *Performance, measure_name: ?[]const u8) void { + var i: usize = 0; + while (i < self._entries.items.len) { + const entry = self._entries.items[i]; + if (entry._type == .measure and (measure_name == null or std.mem.eql(u8, entry._name, measure_name.?))) { + _ = self._entries.orderedRemove(i); + } else { + i += 1; } } } @@ -99,6 +103,15 @@ pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: return result.items; } +fn getMarkTime(self: *const Performance, mark_name: []const u8) !f64 { + for (self._entries.items) |entry| { + if (entry._type == .mark and std.mem.eql(u8, entry._name, mark_name)) { + return entry._start_time; + } + } + return error.SyntaxError; // Mark not found +} + pub const JsApi = struct { pub const bridge = js.Bridge(Performance); @@ -110,7 +123,9 @@ pub const JsApi = struct { pub const now = bridge.function(Performance.now, .{}); pub const mark = bridge.function(Performance.mark, .{}); + pub const measure = bridge.function(Performance.measure, .{}); pub const clearMarks = bridge.function(Performance.clearMarks, .{}); + pub const clearMeasures = bridge.function(Performance.clearMeasures, .{}); pub const getEntries = bridge.function(Performance.getEntries, .{}); pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{}); pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); @@ -131,7 +146,7 @@ pub const Entry = struct { layout_shift, long_animation_frame, longtask, - measure, + measure: *Measure, navigation, paint, resource, @@ -226,6 +241,69 @@ pub const Mark = struct { }; }; +pub const Measure = struct { + _proto: *Entry, + _detail: ?js.Object, + + const Options = struct { + detail: ?js.Object = null, + start: ?[]const u8 = null, + end: ?[]const u8 = null, + duration: ?f64 = null, + }; + + pub fn init(name: []const u8, _opts: ?Options, page: *Page) !*Measure { + const opts = _opts orelse Options{}; + const perf = &page.window._performance; + + const start_time = if (opts.start) |start_mark| + try perf.getMarkTime(start_mark) + else + 0.0; + + const end_time = if (opts.end) |end_mark| + try perf.getMarkTime(end_mark) + else + perf.now(); + + const duration = opts.duration orelse (end_time - start_time); + + if (duration < 0.0) { + return error.TypeError; + } + + const detail = if (opts.detail) |d| try d.persist() else null; + const m = try page._factory.create(Measure{ + ._proto = undefined, + ._detail = detail, + }); + + const entry = try page._factory.create(Entry{ + ._start_time = start_time, + ._duration = duration, + ._name = try page.dupeString(name), + ._type = .{ .measure = m }, + }); + m._proto = entry; + return m; + } + + pub fn getDetail(self: *const Measure) ?js.Object { + return self._detail; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(Measure); + + pub const Meta = struct { + pub const name = "PerformanceMeasure"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const detail = bridge.accessor(Measure.getDetail, null, .{}); + }; +}; + const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); From 74ffc273eff21947233c471769354b78241e2f4e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 18:53:25 +0800 Subject: [PATCH 132/257] Add stack & line number to script eval failure --- src/browser/ScriptManager.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index d2c19150f..b3b2c0ef3 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -773,6 +773,8 @@ const Script = struct { log.warn(.js, "eval script", .{ .url = url, .err = msg, + .stack = try_catch.stack(page.call_arena) catch null, + .line = try_catch.sourceLineNumber() orelse 0, .cacheable = cacheable, }); From 9071d98cbebfa9ff1247f698692c34fba8c79019 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 1 Dec 2025 13:11:20 +0300 Subject: [PATCH 133/257] port `insertAdjacentHTML` --- src/browser/webapi/Element.zig | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 402409539..c9c9c31f2 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -360,6 +360,89 @@ pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowR return shadow_root; } +pub fn insertAdjacentHTML( + self: *Element, + position: []const u8, + /// TODO: Add support for XML parsing. + html_or_xml: []const u8, + page: *Page, +) !void { + // Create a new HTMLDocument. + const doc = try page._factory.document(@import("HTMLDocument.zig"){ + ._proto = undefined, + }); + const doc_node = doc.asNode(); + + const Parser = @import("../parser/Parser.zig"); + var parser = Parser.init(page.call_arena, doc_node, page); + parser.parseFragment(html_or_xml); + // Check if there's parsing error. + if (parser.err) |_| return error.Invalid; + + // We always get it wrapped like so: + // { ... } + // None of the following can be null. + const maybe_html_node = doc_node.firstChild(); + std.debug.assert(maybe_html_node != null); + const html_node = maybe_html_node orelse return; + + const maybe_body_node = html_node.lastChild(); + std.debug.assert(maybe_body_node != null); + const body = maybe_body_node orelse return; + + const self_node = self.asNode(); + // * `target_node` is `*Node` (where we actually insert), + // * `prev_node` is `?*Node`. + const target_node, const prev_node = blk: { + // Prefer case-sensitive match. + // "beforeend" was the most common case in my tests; we might adjust the order + // depending on which ones websites prefer most. + if (std.mem.eql(u8, position, "beforeend")) { + break :blk .{ self_node, null }; + } + + if (std.mem.eql(u8, position, "afterbegin")) { + // Get the first child; null indicates there are no children. + break :blk .{ self_node, self_node.firstChild() }; + } + + if (std.mem.eql(u8, position, "beforebegin")) { + // The node must have a parent node in order to use this variant. + const parent_node = self_node.parentNode() orelse return error.NoModificationAllowed; + // Parent cannot be Document. + switch (parent_node._type) { + .document, .document_fragment => return error.NoModificationAllowed, + else => {}, + } + + break :blk .{ parent_node, self_node }; + } + + if (std.mem.eql(u8, position, "afterend")) { + // The node must have a parent node in order to use this variant. + const parent_node = self_node.parentNode() orelse return error.NoModificationAllowed; + // Parent cannot be Document. + switch (parent_node._type) { + .document, .document_fragment => return error.NoModificationAllowed, + else => {}, + } + + // Get the next sibling or null; null indicates our node is the only one. + break :blk .{ parent_node, self_node.nextSibling() }; + } + + // Returned if: + // * position is not one of the four listed values. + // * The input is XML that is not well-formed. + return error.Syntax; + }; + + var iter = body.childrenIterator(); + while (iter.next()) |child_node| { + _ = try target_node.insertBefore(child_node, prev_node, page); + } +} + pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attribute { if (attr._element) |el| { if (el == self) { @@ -992,6 +1075,7 @@ pub const JsApi = struct { pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true }); pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true }); + pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true }); const ShadowRootInit = struct { mode: []const u8, From dc040dfc3767c9c23b9ad607a62bba9379626527 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 1 Dec 2025 15:37:08 +0300 Subject: [PATCH 134/257] add `insertAdjacentElement` and `insertAdjacentText` --- src/browser/webapi/Element.zig | 72 ++++++++++++---------------------- src/browser/webapi/Node.zig | 48 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c9c9c31f2..4eb08af70 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -375,7 +375,7 @@ pub fn insertAdjacentHTML( const Parser = @import("../parser/Parser.zig"); var parser = Parser.init(page.call_arena, doc_node, page); - parser.parseFragment(html_or_xml); + parser.parse(html_or_xml); // Check if there's parsing error. if (parser.err) |_| return error.Invalid; @@ -390,52 +390,7 @@ pub fn insertAdjacentHTML( std.debug.assert(maybe_body_node != null); const body = maybe_body_node orelse return; - const self_node = self.asNode(); - // * `target_node` is `*Node` (where we actually insert), - // * `prev_node` is `?*Node`. - const target_node, const prev_node = blk: { - // Prefer case-sensitive match. - // "beforeend" was the most common case in my tests; we might adjust the order - // depending on which ones websites prefer most. - if (std.mem.eql(u8, position, "beforeend")) { - break :blk .{ self_node, null }; - } - - if (std.mem.eql(u8, position, "afterbegin")) { - // Get the first child; null indicates there are no children. - break :blk .{ self_node, self_node.firstChild() }; - } - - if (std.mem.eql(u8, position, "beforebegin")) { - // The node must have a parent node in order to use this variant. - const parent_node = self_node.parentNode() orelse return error.NoModificationAllowed; - // Parent cannot be Document. - switch (parent_node._type) { - .document, .document_fragment => return error.NoModificationAllowed, - else => {}, - } - - break :blk .{ parent_node, self_node }; - } - - if (std.mem.eql(u8, position, "afterend")) { - // The node must have a parent node in order to use this variant. - const parent_node = self_node.parentNode() orelse return error.NoModificationAllowed; - // Parent cannot be Document. - switch (parent_node._type) { - .document, .document_fragment => return error.NoModificationAllowed, - else => {}, - } - - // Get the next sibling or null; null indicates our node is the only one. - break :blk .{ parent_node, self_node.nextSibling() }; - } - - // Returned if: - // * position is not one of the four listed values. - // * The input is XML that is not well-formed. - return error.Syntax; - }; + const target_node, const prev_node = try self.asNode().findAdjacentNodes(position); var iter = body.childrenIterator(); while (iter.next()) |child_node| { @@ -443,6 +398,27 @@ pub fn insertAdjacentHTML( } } +pub fn insertAdjacentElement( + self: *Element, + position: []const u8, + element: *Element, + page: *Page, +) !void { + const target_node, const prev_node = try self.asNode().findAdjacentNodes(position); + _ = try target_node.insertBefore(element.asNode(), prev_node, page); +} + +pub fn insertAdjacentText( + self: *Element, + where: []const u8, + data: []const u8, + page: *Page, +) !void { + const text_node = try page.createTextNode(data); + const target_node, const prev_node = try self.asNode().findAdjacentNodes(where); + _ = try target_node.insertBefore(text_node, prev_node, page); +} + pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attribute { if (attr._element) |el| { if (el == self) { @@ -1076,6 +1052,8 @@ pub const JsApi = struct { pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true }); pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true }); + pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true }); + pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .dom_exception = true }); const ShadowRootInit = struct { mode: []const u8, diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index ab0c28ec8..1b686c869 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -115,6 +115,54 @@ pub fn is(self: *Node, comptime T: type) ?*T { return null; } +/// Given a position, returns target and previous nodes required for +/// insertAdjacentHTML, insertAdjacentElement and insertAdjacentText. +/// * `target_node` is `*Node` (where we actually insert), +/// * `previous_node` is `?*Node`. +pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*Node } { + // Prefer case-sensitive match. + // "beforeend" was the most common case in my tests; we might adjust the order + // depending on which ones websites prefer most. + if (std.mem.eql(u8, position, "beforeend")) { + return .{ self, null }; + } + + if (std.mem.eql(u8, position, "afterbegin")) { + // Get the first child; null indicates there are no children. + return .{ self, self.firstChild() }; + } + + if (std.mem.eql(u8, position, "beforebegin")) { + // The node must have a parent node in order to use this variant. + const parent_node = self.parentNode() orelse return error.NoModificationAllowed; + // Parent cannot be Document. + switch (parent_node._type) { + .document, .document_fragment => return error.NoModificationAllowed, + else => {}, + } + + return .{ parent_node, self }; + } + + if (std.mem.eql(u8, position, "afterend")) { + // The node must have a parent node in order to use this variant. + const parent_node = self.parentNode() orelse return error.NoModificationAllowed; + // Parent cannot be Document. + switch (parent_node._type) { + .document, .document_fragment => return error.NoModificationAllowed, + else => {}, + } + + // Get the next sibling or null; null indicates our node is the only one. + return .{ parent_node, self.nextSibling() }; + } + + // Returned if: + // * position is not one of the four listed values. + // * The input is XML that is not well-formed. + return error.Syntax; +} + pub fn firstChild(self: *const Node) ?*Node { const children = self._children orelse return null; return children.first(); From 45e74d3336d89dcbebc7ce5b5db572da98a114f4 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 1 Dec 2025 15:37:36 +0300 Subject: [PATCH 135/257] add `insertAdjacentElement` and `insertAdjacentHTML` tests --- .../document/insert_adjacent_element.html | 54 +++++++++++++++++++ .../tests/document/insert_adjacent_html.html | 44 +++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/browser/tests/document/insert_adjacent_element.html create mode 100644 src/browser/tests/document/insert_adjacent_html.html diff --git a/src/browser/tests/document/insert_adjacent_element.html b/src/browser/tests/document/insert_adjacent_element.html new file mode 100644 index 000000000..7f897cb1f --- /dev/null +++ b/src/browser/tests/document/insert_adjacent_element.html @@ -0,0 +1,54 @@ + + + Test Document Title + + + + + +
+
+ +

content

+
+
+ + + diff --git a/src/browser/tests/document/insert_adjacent_html.html b/src/browser/tests/document/insert_adjacent_html.html new file mode 100644 index 000000000..cd8d1b19d --- /dev/null +++ b/src/browser/tests/document/insert_adjacent_html.html @@ -0,0 +1,44 @@ + + + Test Document Title + + + + + +
+
+ +

content

+
+
+ + + From b6420f75e29432730768ff09124f3a9c60596df2 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 1 Dec 2025 16:03:28 +0300 Subject: [PATCH 136/257] add `insertAdjacentText` test --- .../tests/document/insert_adjacent_text.html | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/browser/tests/document/insert_adjacent_text.html diff --git a/src/browser/tests/document/insert_adjacent_text.html b/src/browser/tests/document/insert_adjacent_text.html new file mode 100644 index 000000000..c8f9f3371 --- /dev/null +++ b/src/browser/tests/document/insert_adjacent_text.html @@ -0,0 +1,49 @@ + + + Test Document Title + + + + + +
+
+ +

content

+
+
+ + + From 60c1f19581989570a3544aa1cff3e9d61f773a9e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 20:04:07 +0800 Subject: [PATCH 137/257] add TextTrackCue and VTTCue (for reddit) --- src/browser/EventManager.zig | 2 +- src/browser/Factory.zig | 9 ++ src/browser/js/bridge.zig | 2 + src/browser/tests/media/vttcue.html | 71 +++++++++ src/browser/webapi/EventTarget.zig | 2 + src/browser/webapi/media/TextTrackCue.zig | 118 ++++++++++++++ src/browser/webapi/media/VTTCue.zig | 182 ++++++++++++++++++++++ 7 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/media/vttcue.html create mode 100644 src/browser/webapi/media/TextTrackCue.zig create mode 100644 src/browser/webapi/media/VTTCue.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index a138c44fc..408d67446 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -117,7 +117,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list, .message_port => { + .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 2a4a06276..91e011abb 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -277,6 +277,15 @@ pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { ).create(allocator, child); } +pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + const TextTrackCue = @import("webapi/media/TextTrackCue.zig"); + + return try AutoPrototypeChain( + &.{ EventTarget, TextTrackCue, @TypeOf(child) }, + ).create(allocator, child); +} + fn hasChainRoot(comptime T: type) bool { // Check if this is a root if (@hasDecl(T, "_prototype_root")) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 1522c9d37..c3cb095d2 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -565,6 +565,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), + @import("../webapi/media/TextTrackCue.zig"), + @import("../webapi/media/VTTCue.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), diff --git a/src/browser/tests/media/vttcue.html b/src/browser/tests/media/vttcue.html new file mode 100644 index 000000000..ad1d2cd40 --- /dev/null +++ b/src/browser/tests/media/vttcue.html @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 70aacb833..9792f2b39 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -36,6 +36,7 @@ pub const Type = union(enum) { abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), message_port: *@import("MessagePort.zig"), + text_track_cue: *@import("media/TextTrackCue.zig"), }; pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { @@ -104,6 +105,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), .message_port => writer.writeAll(""), + .text_track_cue => writer.writeAll(""), }; } diff --git a/src/browser/webapi/media/TextTrackCue.zig b/src/browser/webapi/media/TextTrackCue.zig new file mode 100644 index 000000000..e590fa7f5 --- /dev/null +++ b/src/browser/webapi/media/TextTrackCue.zig @@ -0,0 +1,118 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const EventTarget = @import("../EventTarget.zig"); + +const TextTrackCue = @This(); + +_type: Type, +_proto: *EventTarget, +_id: []const u8 = "", +_start_time: f64 = 0, +_end_time: f64 = 0, +_pause_on_exit: bool = false, +_on_enter: ?js.Function = null, +_on_exit: ?js.Function = null, + +pub const Type = union(enum) { + vtt: *@import("VTTCue.zig"), +}; + +pub fn asEventTarget(self: *TextTrackCue) *EventTarget { + return self._proto; +} + +pub fn getId(self: *const TextTrackCue) []const u8 { + return self._id; +} + +pub fn setId(self: *TextTrackCue, value: []const u8, page: *Page) !void { + self._id = try page.dupeString(value); +} + +pub fn getStartTime(self: *const TextTrackCue) f64 { + return self._start_time; +} + +pub fn setStartTime(self: *TextTrackCue, value: f64) void { + self._start_time = value; +} + +pub fn getEndTime(self: *const TextTrackCue) f64 { + return self._end_time; +} + +pub fn setEndTime(self: *TextTrackCue, value: f64) void { + self._end_time = value; +} + +pub fn getPauseOnExit(self: *const TextTrackCue) bool { + return self._pause_on_exit; +} + +pub fn setPauseOnExit(self: *TextTrackCue, value: bool) void { + self._pause_on_exit = value; +} + +pub fn getOnEnter(self: *const TextTrackCue) ?js.Function { + return self._on_enter; +} + +pub fn setOnEnter(self: *TextTrackCue, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_enter = try cb.withThis(self); + } else { + self._on_enter = null; + } +} + +pub fn getOnExit(self: *const TextTrackCue) ?js.Function { + return self._on_exit; +} + +pub fn setOnExit(self: *TextTrackCue, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_exit = try cb.withThis(self); + } else { + self._on_exit = null; + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextTrackCue); + + pub const Meta = struct { + pub const name = "TextTrackCue"; + + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const Prototype = EventTarget; + + pub const id = bridge.accessor(TextTrackCue.getId, TextTrackCue.setId, .{}); + pub const startTime = bridge.accessor(TextTrackCue.getStartTime, TextTrackCue.setStartTime, .{}); + pub const endTime = bridge.accessor(TextTrackCue.getEndTime, TextTrackCue.setEndTime, .{}); + pub const pauseOnExit = bridge.accessor(TextTrackCue.getPauseOnExit, TextTrackCue.setPauseOnExit, .{}); + pub const onenter = bridge.accessor(TextTrackCue.getOnEnter, TextTrackCue.setOnEnter, .{}); + pub const onexit = bridge.accessor(TextTrackCue.getOnExit, TextTrackCue.setOnExit, .{}); +}; diff --git a/src/browser/webapi/media/VTTCue.zig b/src/browser/webapi/media/VTTCue.zig new file mode 100644 index 000000000..de796a27b --- /dev/null +++ b/src/browser/webapi/media/VTTCue.zig @@ -0,0 +1,182 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const TextTrackCue = @import("TextTrackCue.zig"); + +const VTTCue = @This(); + +_proto: *TextTrackCue, +_text: []const u8 = "", +_region: ?js.Object = null, +_vertical: []const u8 = "", +_snap_to_lines: bool = true, +_line: ?f64 = null, // null represents "auto" +_position: ?f64 = null, // null represents "auto" +_size: f64 = 100, +_align: []const u8 = "center", + +pub fn constructor(start_time: f64, end_time: f64, text: []const u8, page: *Page) !*VTTCue { + const cue = try page._factory.textTrackCue(VTTCue{ + ._proto = undefined, + ._text = try page.dupeString(text), + ._region = null, + ._vertical = "", + ._snap_to_lines = true, + ._line = null, // "auto" + ._position = null, // "auto" + ._size = 100, + ._align = "center", + }); + + cue._proto._start_time = start_time; + cue._proto._end_time = end_time; + + return cue; +} + +pub fn asTextTrackCue(self: *VTTCue) *TextTrackCue { + return self._proto; +} + +pub fn getText(self: *const VTTCue) []const u8 { + return self._text; +} + +pub fn setText(self: *VTTCue, value: []const u8, page: *Page) !void { + self._text = try page.dupeString(value); +} + +pub fn getRegion(self: *const VTTCue) ?js.Object { + return self._region; +} + +pub fn setRegion(self: *VTTCue, value: ?js.Object) !void { + if (value) |v| { + self._region = try v.persist(); + } else { + self._region = null; + } +} + +pub fn getVertical(self: *const VTTCue) []const u8 { + return self._vertical; +} + +pub fn setVertical(self: *VTTCue, value: []const u8, page: *Page) !void { + // Valid values: "", "rl", "lr" + self._vertical = try page.dupeString(value); +} + +pub fn getSnapToLines(self: *const VTTCue) bool { + return self._snap_to_lines; +} + +pub fn setSnapToLines(self: *VTTCue, value: bool) void { + self._snap_to_lines = value; +} + +pub const LineAndPositionSetting = union(enum) { + number: f64, + auto: []const u8, +}; + +pub fn getLine(self: *const VTTCue) LineAndPositionSetting { + if (self._line) |num| { + return .{ .number = num }; + } + return .{ .auto = "auto" }; +} + +pub fn setLine(self: *VTTCue, value: LineAndPositionSetting) void { + switch (value) { + .number => |num| self._line = num, + .auto => self._line = null, + } +} + +pub fn getPosition(self: *const VTTCue) LineAndPositionSetting { + if (self._position) |num| { + return .{ .number = num }; + } + return .{ .auto = "auto" }; +} + +pub fn setPosition(self: *VTTCue, value: LineAndPositionSetting) void { + switch (value) { + .number => |num| self._position = num, + .auto => self._position = null, + } +} + +pub fn getSize(self: *const VTTCue) f64 { + return self._size; +} + +pub fn setSize(self: *VTTCue, value: f64) void { + self._size = value; +} + +pub fn getAlign(self: *const VTTCue) []const u8 { + return self._align; +} + +pub fn setAlign(self: *VTTCue, value: []const u8, page: *Page) !void { + // Valid values: "start", "center", "end", "left", "right" + self._align = try page.dupeString(value); +} + +pub fn getCueAsHTML(self: *const VTTCue, page: *Page) !js.Object { + // Minimal implementation: return a document fragment + // In a full implementation, this would parse the VTT text into HTML nodes + _ = self; + _ = page; + return error.NotImplemented; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(VTTCue); + + pub const Meta = struct { + pub const name = "VTTCue"; + + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const Prototype = TextTrackCue; + + pub const constructor = bridge.constructor(VTTCue.constructor, .{}); + pub const text = bridge.accessor(VTTCue.getText, VTTCue.setText, .{}); + pub const region = bridge.accessor(VTTCue.getRegion, VTTCue.setRegion, .{}); + pub const vertical = bridge.accessor(VTTCue.getVertical, VTTCue.setVertical, .{}); + pub const snapToLines = bridge.accessor(VTTCue.getSnapToLines, VTTCue.setSnapToLines, .{}); + pub const line = bridge.accessor(VTTCue.getLine, VTTCue.setLine, .{}); + pub const position = bridge.accessor(VTTCue.getPosition, VTTCue.setPosition, .{}); + pub const size = bridge.accessor(VTTCue.getSize, VTTCue.setSize, .{}); + pub const @"align" = bridge.accessor(VTTCue.getAlign, VTTCue.setAlign, .{}); + pub const getCueAsHTML = bridge.function(VTTCue.getCueAsHTML, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: VTTCue" { + try testing.htmlRunner("media/vttcue.html", .{}); +} From 7cb06f3e5840d6ffa1d61638157676b600df41fd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 3 Dec 2025 22:29:45 +0800 Subject: [PATCH 138/257] MediaError and :scope pseudoclass --- src/browser/js/bridge.zig | 1 + .../tests/element/query_selector_scope.html | 127 ++++++++++++++++++ src/browser/tests/media/mediaerror.html | 12 ++ src/browser/webapi/media/MediaError.zig | 64 +++++++++ src/browser/webapi/selector/List.zig | 77 ++++++----- src/browser/webapi/selector/Parser.zig | 1 + src/browser/webapi/selector/Selector.zig | 3 +- 7 files changed, 248 insertions(+), 37 deletions(-) create mode 100644 src/browser/tests/element/query_selector_scope.html create mode 100644 src/browser/tests/media/mediaerror.html create mode 100644 src/browser/webapi/media/MediaError.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index c3cb095d2..e20d9b7fb 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -565,6 +565,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), + @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), @import("../webapi/EventTarget.zig"), diff --git a/src/browser/tests/element/query_selector_scope.html b/src/browser/tests/element/query_selector_scope.html new file mode 100644 index 000000000..ba18615a9 --- /dev/null +++ b/src/browser/tests/element/query_selector_scope.html @@ -0,0 +1,127 @@ + + + +
+
+ Grandchild 1 + Grandchild 2 +
+
+ Grandchild 3 +
+
+ + + + + +
+
+
+ Inner text +
+
+ Other text +
+
+
+ + + +
+
Box 1
+
Box 2
+ Box 3 +
+ + + +
+
+
Child 1
+
Child 2
+
+
+
Child 3
+
+
+ + diff --git a/src/browser/tests/media/mediaerror.html b/src/browser/tests/media/mediaerror.html new file mode 100644 index 000000000..928860fb4 --- /dev/null +++ b/src/browser/tests/media/mediaerror.html @@ -0,0 +1,12 @@ + + + + diff --git a/src/browser/webapi/media/MediaError.zig b/src/browser/webapi/media/MediaError.zig new file mode 100644 index 000000000..5e1f15b40 --- /dev/null +++ b/src/browser/webapi/media/MediaError.zig @@ -0,0 +1,64 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const MediaError = @This(); + +_code: u16, +_message: []const u8 = "", + +pub fn init(code: u16, message: []const u8, page: *Page) !*MediaError { + return page.arena.create(MediaError{ + ._code = code, + ._message = try page.dupeString(message), + }); +} + +pub fn getCode(self: *const MediaError) u16 { + return self._code; +} + +pub fn getMessage(self: *const MediaError) []const u8 { + return self._message; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MediaError); + + pub const Meta = struct { + pub const name = "MediaError"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + // Error code constants + pub const MEDIA_ERR_ABORTED = bridge.property(1); + pub const MEDIA_ERR_NETWORK = bridge.property(2); + pub const MEDIA_ERR_DECODE = bridge.property(3); + pub const MEDIA_ERR_SRC_NOT_SUPPORTED = bridge.property(4); + + pub const code = bridge.accessor(MediaError.getCode, null, .{}); + pub const message = bridge.accessor(MediaError.getMessage, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: MediaError" { + try testing.htmlRunner("media/mediaerror.html", .{}); +} diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 8ce759a84..06d2045ed 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -54,7 +54,7 @@ pub fn collect( } while (tw.next()) |node| { - if (matches(node, result.selector, page)) { + if (matches(node, result.selector, root, page)) { try nodes.put(allocator, node, {}); } } @@ -66,12 +66,11 @@ pub fn initOne(root: *Node, selector: Selector.Selector, page: *Page) ?*Node { const result = optimizeSelector(root, &selector, page) orelse return null; var tw = TreeWalker.init(result.root, .{}); - const optimized_selector = result.selector; if (result.exclude_root) { _ = tw.next(); } while (tw.next()) |node| { - if (matches(node, optimized_selector, page)) { + if (matches(node, result.selector, root, page)) { return node; } } @@ -89,10 +88,12 @@ const OptimizeResult = struct { exclude_root: bool, selector: Selector.Selector, }; + fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page) ?OptimizeResult { const anchor = findIdSelector(selector) orelse return .{ .root = root, .selector = selector.*, + // Always exclude root - querySelector only returns descendants .exclude_root = true, }; @@ -173,7 +174,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page .segments = selector.segments[0 .. seg_idx + 1], }; - if (!matches(id_node, prefix_selector, page)) { + if (!matches(id_node, prefix_selector, id_node, page)) { return null; } @@ -248,23 +249,23 @@ fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { return null; } -pub fn matches(node: *Node, selector: Selector.Selector, page: *Page) bool { +pub fn matches(node: *Node, selector: Selector.Selector, scope: *Node, page: *Page) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { - return matchesCompound(el, selector.first, page); + return matchesCompound(el, selector.first, scope, page); } const last_segment = selector.segments[selector.segments.len - 1]; - if (!matchesCompound(el, last_segment.compound, page)) { + if (!matchesCompound(el, last_segment.compound, scope, page)) { return false; } - return matchSegments(node, selector, selector.segments.len - 1, null, page); + return matchSegments(node, selector, selector.segments.len - 1, null, scope, page); } // Match segments backward, with support for backtracking on subsequent_sibling -fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node, page: *Page) bool { +fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node, scope: *Node, page: *Page) bool { const segment = selector.segments[segment_index]; const target_compound = if (segment_index == 0) selector.first @@ -272,9 +273,9 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, selector.segments[segment_index - 1].compound; const matched: ?*Node = switch (segment.combinator) { - .descendant => matchDescendant(node, target_compound, root, page), - .child => matchChild(node, target_compound, root, page), - .next_sibling => matchNextSibling(node, target_compound, page), + .descendant => matchDescendant(node, target_compound, root, scope, page), + .child => matchChild(node, target_compound, root, scope, page), + .next_sibling => matchNextSibling(node, target_compound, scope, page), .subsequent_sibling => { // For subsequent_sibling, try all matching siblings with backtracking var sibling = node.previousSibling(); @@ -284,13 +285,13 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, continue; }; - if (matchesCompound(sibling_el, target_compound, page)) { + if (matchesCompound(sibling_el, target_compound, scope, page)) { // If we're at the first segment, we found a match if (segment_index == 0) { return true; } // Try to match remaining segments from this sibling - if (matchSegments(s, selector, segment_index - 1, root, page)) { + if (matchSegments(s, selector, segment_index - 1, root, scope, page)) { return true; } // This sibling didn't work, try the next one @@ -307,7 +308,7 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, if (segment_index == 0) { return true; } - return matchSegments(current, selector, segment_index - 1, root, page); + return matchSegments(current, selector, segment_index - 1, root, scope, page); } // subsequent_sibling already handled its recursion above @@ -315,12 +316,12 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, } // Find an ancestor that matches the compound (any distance up the tree) -fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { +fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, scope: *Node, page: *Page) ?*Node { var current = node._parent; while (current) |ancestor| { if (ancestor.is(Node.Element)) |ancestor_el| { - if (matchesCompound(ancestor_el, compound, page)) { + if (matchesCompound(ancestor_el, compound, scope, page)) { return ancestor; } } @@ -339,7 +340,7 @@ fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, page: } // Find the direct parent if it matches the compound -fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { +fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, scope: *Node, page: *Page) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary @@ -352,7 +353,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Pag const parent_el = parent.is(Node.Element) orelse return null; - if (matchesCompound(parent_el, compound, page)) { + if (matchesCompound(parent_el, compound, scope, page)) { return parent; } @@ -360,7 +361,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Pag } // Find the immediately preceding sibling if it matches the compound -fn matchNextSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { +fn matchNextSibling(node: *Node, compound: Selector.Compound, scope: *Node, page: *Page) ?*Node { var sibling = node.previousSibling(); // For next_sibling (+), we need the immediately preceding element sibling @@ -372,7 +373,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Nod }; // Found an element - check if it matches - if (matchesCompound(sibling_el, compound, page)) { + if (matchesCompound(sibling_el, compound, scope, page)) { return s; } // we found an element, it wasn't a match, we're done @@ -383,7 +384,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Nod } // Find any preceding sibling that matches the compound -fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { +fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, scope: *Node, page: *Page) ?*Node { var sibling = node.previousSibling(); // For subsequent_sibling (~), check all preceding element siblings @@ -394,7 +395,7 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, page: *Page) continue; }; - if (matchesCompound(sibling_el, compound, page)) { + if (matchesCompound(sibling_el, compound, scope, page)) { return s; } @@ -404,17 +405,17 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, page: *Page) return null; } -fn matchesCompound(el: *Node.Element, compound: Selector.Compound, page: *Page) bool { +fn matchesCompound(el: *Node.Element, compound: Selector.Compound, scope: *Node, page: *Page) bool { // For compound selectors, ALL parts must match for (compound.parts) |part| { - if (!matchesPart(el, part, page)) { + if (!matchesPart(el, part, scope, page)) { return false; } } return true; } -fn matchesPart(el: *Node.Element, part: Part, page: *Page) bool { +fn matchesPart(el: *Node.Element, part: Part, scope: *Node, page: *Page) bool { switch (part) { .id => |id| { const element_id = el.getAttributeSafe("id") orelse return false; @@ -435,7 +436,7 @@ fn matchesPart(el: *Node.Element, part: Part, page: *Page) bool { return std.mem.eql(u8, element_tag, tag_name); }, .universal => return true, - .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo, page), + .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo, scope, page), .attribute => |attr| return matchesAttribute(el, attr), } } @@ -495,7 +496,7 @@ fn attributeContainsWord(value: []const u8, word: []const u8) bool { return false; } -fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Page) bool { +fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *Node, page: *Page) bool { const node = el.asNode(); switch (pseudo) { // State pseudo-classes @@ -565,6 +566,10 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa const parent = node.parentNode() orelse return false; return parent._type == .document; }, + .scope => { + // :scope matches the reference element (querySelector root) + return node == scope; + }, .empty => { return node.firstChild() == null; }, @@ -591,7 +596,7 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa .lang => return false, .not => |selectors| { for (selectors) |selector| { - if (matches(node, selector, page)) { + if (matches(node, selector, scope, page)) { return false; } } @@ -599,7 +604,7 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa }, .is => |selectors| { for (selectors) |selector| { - if (matches(node, selector, page)) { + if (matches(node, selector, scope, page)) { return true; } } @@ -607,7 +612,7 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa }, .where => |selectors| { for (selectors) |selector| { - if (matches(node, selector, page)) { + if (matches(node, selector, scope, page)) { return true; } } @@ -622,11 +627,11 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa continue; }; - if (matches(child_el.asNode(), selector, page)) { + if (matches(child_el.asNode(), selector, scope, page)) { return true; } - if (matchesHasDescendant(child_el, selector, page)) { + if (matchesHasDescendant(child_el, selector, scope, page)) { return true; } @@ -638,7 +643,7 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Pa } } -fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, page: *Page) bool { +fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, scope: *Node, page: *Page) bool { var child = el.asNode().firstChild(); while (child) |c| { const child_el = c.is(Node.Element) orelse { @@ -646,11 +651,11 @@ fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, page: *P continue; }; - if (matches(child_el.asNode(), selector, page)) { + if (matches(child_el.asNode(), selector, scope, page)) { return true; } - if (matchesHasDescendant(child_el, selector, page)) { + if (matchesHasDescendant(child_el, selector, scope, page)) { return true; } diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index a793e7c82..02d9e1c74 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -504,6 +504,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (fastEql(name, "modal")) return .modal; if (fastEql(name, "hover")) return .hover; if (fastEql(name, "focus")) return .focus; + if (fastEql(name, "scope")) return .scope; if (fastEql(name, "empty")) return .empty; if (fastEql(name, "valid")) return .valid; }, diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 5360cd3fe..44d7c4387 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -82,7 +82,7 @@ pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { - if (List.matches(el.asNode(), selector, page)) { + if (List.matches(el.asNode(), selector, el.asNode(), page)) { return true; } } @@ -165,6 +165,7 @@ pub const PseudoClass = union(enum) { // Tree structural root, + scope, empty, first_child, last_child, From c9882e10a49de77b58e50789244dd9913d6648e1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 4 Dec 2025 14:39:15 +0800 Subject: [PATCH 139/257] Properly handle insertion of DocumentFragment Add various CData methods XHR and Fetch request headers Animation mocks --- src/browser/Page.zig | 18 + src/browser/js/Object.zig | 6 +- src/browser/js/bridge.zig | 1 + src/browser/tests/cdata/character_data.html | 730 ++++++++++++++++++ .../tests/document_fragment/insertion.html | 238 ++++++ src/browser/tests/net/request.html | 6 + src/browser/webapi/CData.zig | 133 ++++ src/browser/webapi/DOMException.zig | 4 + src/browser/webapi/Element.zig | 21 +- src/browser/webapi/KeyValueList.zig | 31 + src/browser/webapi/Node.zig | 6 +- src/browser/webapi/Window.zig | 2 +- src/browser/webapi/animation/Animation.zig | 49 ++ src/browser/webapi/net/Fetch.zig | 10 +- src/browser/webapi/net/Headers.zig | 31 +- src/browser/webapi/net/Request.zig | 14 +- src/browser/webapi/net/Response.zig | 2 +- src/browser/webapi/net/URLSearchParams.zig | 21 +- src/browser/webapi/net/XMLHttpRequest.zig | 13 +- 19 files changed, 1288 insertions(+), 48 deletions(-) create mode 100644 src/browser/tests/cdata/character_data.html create mode 100644 src/browser/tests/document_fragment/insertion.html create mode 100644 src/browser/webapi/animation/Animation.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 7f8ad2bcf..6c94e4530 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1431,6 +1431,24 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void { } } +pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, target: *Node, ref_node: *Node) !void { + self.domChanged(); + const dest_connected = target.isConnected(); + + var it = fragment.childrenIterator(); + while (it.next()) |child| { + // Check if child was connected BEFORE removing it from fragment + const child_was_connected = child.isConnected(); + self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected }); + try self.insertNodeRelative( + target, + child, + .{ .before = ref_node }, + .{ .child_already_connected = child_was_connected }, + ); + } +} + fn _appendNode(self: *Page, comptime from_parser: bool, parent: *Node, child: *Node, opts: InsertNodeOpts) !void { self._insertNodeRelative(from_parser, parent, child, .append, opts); } diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 222f2b752..9ab35fe12 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -135,7 +135,7 @@ pub fn isNullOrUndefined(self: Object) bool { return self.js_obj.toValue().isNullOrUndefined(); } -pub fn nameIterator(self: Object, allocator: Allocator) NameIterator { +pub fn nameIterator(self: Object) NameIterator { const context = self.context; const js_obj = self.js_obj; @@ -145,7 +145,6 @@ pub fn nameIterator(self: Object, allocator: Allocator) NameIterator { return .{ .count = count, .context = context, - .allocator = allocator, .js_obj = array.castTo(v8.Object), }; } @@ -158,7 +157,6 @@ pub const NameIterator = struct { count: u32, idx: u32 = 0, js_obj: v8.Object, - allocator: Allocator, context: *const Context, pub fn next(self: *NameIterator) !?[]const u8 { @@ -170,6 +168,6 @@ pub const NameIterator = struct { const context = self.context; const js_val = try self.js_obj.getAtIndex(context.v8_context, idx); - return try context.valueToString(js_val, .{ .allocator = self.allocator }); + return try context.valueToString(js_val, .{}); } }; diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index e20d9b7fb..372d260aa 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -568,6 +568,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), + @import("../webapi/animation/Animation.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), diff --git a/src/browser/tests/cdata/character_data.html b/src/browser/tests/cdata/character_data.html new file mode 100644 index 000000000..85513b00f --- /dev/null +++ b/src/browser/tests/cdata/character_data.html @@ -0,0 +1,730 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/document_fragment/insertion.html b/src/browser/tests/document_fragment/insertion.html new file mode 100644 index 000000000..f110766c5 --- /dev/null +++ b/src/browser/tests/document_fragment/insertion.html @@ -0,0 +1,238 @@ + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index c0028cf83..437c26301 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -45,6 +45,11 @@ const req = new Request('https://example.com/api', { headers }); testing.expectEqual('value', req.headers.get('X-Custom')); } + +{ + const req = new Request('https://example.com/api', {headers: {over: '9000!'}}); + testing.expectEqual('9000!', req.headers.get('over')); +} + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index cb39a70ba..f02d51cad 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -79,6 +79,119 @@ pub fn format(self: *const CData, writer: *std.io.Writer) !void { }; } +pub fn getLength(self: *const CData) usize { + return self._data.len; +} + +pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { + const new_data = try std.mem.concat(page.arena, u8, &.{ self._data, data }); + try self.setData(new_data, page); +} + +pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { + if (offset > self._data.len) return error.IndexSizeError; + const end = @min(offset + count, self._data.len); + + // Just slice - original data stays in arena + const old_value = self._data; + if (offset == 0) { + self._data = self._data[end..]; + } else if (end >= self._data.len) { + self._data = self._data[0..offset]; + } else { + self._data = try std.mem.concat(page.arena, u8, &.{ + self._data[0..offset], + self._data[end..], + }); + } + page.characterDataChange(self.asNode(), old_value); +} + +pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { + if (offset > self._data.len) return error.IndexSizeError; + const new_data = try std.mem.concat(page.arena, u8, &.{ + self._data[0..offset], + data, + self._data[offset..], + }); + try self.setData(new_data, page); +} + +pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { + if (offset > self._data.len) return error.IndexSizeError; + const end = @min(offset + count, self._data.len); + const new_data = try std.mem.concat(page.arena, u8, &.{ + self._data[0..offset], + data, + self._data[end..], + }); + try self.setData(new_data, page); +} + +pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 { + if (offset > self._data.len) return error.IndexSizeError; + const end = @min(offset + count, self._data.len); + return self._data[offset..end]; +} + +pub fn remove(self: *CData, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + _ = try parent.removeChild(node, page); +} + +pub fn before(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.insertBefore(child, node, page); + } +} + +pub fn after(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + const next = node.nextSibling(); + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.insertBefore(child, next, page); + } +} + +pub fn replaceWith(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + const next = node.nextSibling(); + + _ = try parent.removeChild(node, page); + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.insertBefore(child, next, page); + } +} + +pub fn nextElementSibling(self: *CData) ?*Node.Element { + var maybe_sibling = self.asNode().nextSibling(); + while (maybe_sibling) |sibling| { + if (sibling.is(Node.Element)) |el| return el; + maybe_sibling = sibling.nextSibling(); + } + return null; +} + +pub fn previousElementSibling(self: *CData) ?*Node.Element { + var maybe_sibling = self.asNode().previousSibling(); + while (maybe_sibling) |sibling| { + if (sibling.is(Node.Element)) |el| return el; + maybe_sibling = sibling.previousSibling(); + } + return null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(CData); @@ -89,4 +202,24 @@ pub const JsApi = struct { }; pub const data = bridge.accessor(CData.getData, CData.setData, .{}); + pub const length = bridge.accessor(CData.getLength, null, .{}); + + pub const appendData = bridge.function(CData.appendData, .{}); + pub const deleteData = bridge.function(CData.deleteData, .{ .dom_exception = true }); + pub const insertData = bridge.function(CData.insertData, .{ .dom_exception = true }); + pub const replaceData = bridge.function(CData.replaceData, .{ .dom_exception = true }); + pub const substringData = bridge.function(CData.substringData, .{ .dom_exception = true }); + + pub const remove = bridge.function(CData.remove, .{}); + pub const before = bridge.function(CData.before, .{}); + pub const after = bridge.function(CData.after, .{}); + pub const replaceWith = bridge.function(CData.replaceWith, .{}); + + pub const nextElementSibling = bridge.accessor(CData.nextElementSibling, null, .{}); + pub const previousElementSibling = bridge.accessor(CData.previousElementSibling, null, .{}); }; + +const testing = @import("../../testing.zig"); +test "WebApi: CData" { + try testing.htmlRunner("cdata", .{}); +} diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 2f1cc789f..72d795595 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -33,6 +33,7 @@ pub fn fromError(err: anyerror) ?DOMException { error.NotFound => .{ ._code = .not_found }, error.NotSupported => .{ ._code = .not_supported }, error.HierarchyError => .{ ._code = .hierarchy_error }, + error.IndexSizeError => .{ ._code = .index_size_error }, else => null, }; } @@ -45,6 +46,7 @@ pub fn getName(self: *const DOMException) []const u8 { return switch (self._code) { .none => "Error", .invalid_character_error => "InvalidCharacterError", + .index_size_error => "IndexSizeErorr", .syntax_error => "SyntaxError", .not_found => "NotFoundError", .not_supported => "NotSupportedError", @@ -56,6 +58,7 @@ pub fn getMessage(self: *const DOMException) []const u8 { return switch (self._code) { .none => "", .invalid_character_error => "Invalid Character", + .index_size_error => "IndexSizeError: Index or size is negative or greater than the allowed amount", .syntax_error => "Syntax Error", .not_supported => "Not Supported", .not_found => "Not Found", @@ -65,6 +68,7 @@ pub fn getMessage(self: *const DOMException) []const u8 { const Code = enum(u8) { none = 0, + index_size_error = 1, hierarchy_error = 3, invalid_character_error = 5, not_found = 8, diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 402409539..b3847bc16 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -26,17 +26,18 @@ const Page = @import("../Page.zig"); const reflect = @import("../reflect.zig"); const Node = @import("Node.zig"); +const CSS = @import("CSS.zig"); +const DOMRect = @import("DOMRect.zig"); +const ShadowRoot = @import("ShadowRoot.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); -pub const Attribute = @import("element/Attribute.zig"); +const Animation = @import("animation/Animation.zig"); +const DOMStringMap = @import("element/DOMStringMap.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); -pub const DOMStringMap = @import("element/DOMStringMap.zig"); -const DOMRect = @import("DOMRect.zig"); -const CSS = @import("CSS.zig"); -const ShadowRoot = @import("ShadowRoot.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); +pub const Attribute = @import("element/Attribute.zig"); const Element = @This(); @@ -587,6 +588,14 @@ pub fn querySelectorAll(self: *Element, input: []const u8, page: *Page) !*Select return Selector.querySelectorAll(self.asNode(), input, page); } +pub fn getAnimations(_: *const Element) []*Animation { + return &.{}; +} + +pub fn animate(_: *Element, _: js.Object, _: js.Object) !Animation { + return Animation.init(); +} + pub fn closest(self: *Element, selector: []const u8, page: *Page) !?*Element { if (selector.len == 0) { return error.SyntaxError; @@ -1012,6 +1021,8 @@ pub const JsApi = struct { pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true }); pub const closest = bridge.function(Element.closest, .{ .dom_exception = true }); + pub const getAnimations = bridge.function(Element.getAnimations, .{}); + pub const animate = bridge.function(Element.animate, .{}); pub const checkVisibility = bridge.function(Element.checkVisibility, .{}); pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{}); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index c9eb70c8d..4ec85f203 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -41,9 +41,40 @@ pub const empty: KeyValueList = .{ ._entries = .empty, }; +pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { + var list = KeyValueList.init(); + try list.ensureTotalCapacity(arena, original.len()); + for (original._entries.items) |entry| { + try list.appendAssumeCapacity(arena, entry.name.str(), entry.value.str()); + } + return list; +} + +pub fn fromJsObject(arena: Allocator, js_obj: js.Object) !KeyValueList { + var it = js_obj.nameIterator(); + var list = KeyValueList.init(); + try list.ensureTotalCapacity(arena, it.count); + + while (try it.next()) |name| { + const js_value = try js_obj.get(name); + const value = try js_value.toString(arena); + + try list._entries.append(arena, .{ + .name = try String.init(arena, name, .{}), + .value = try String.init(arena, value, .{}), + }); + } + + return list; +} + pub const Entry = struct { name: String, value: String, + + pub fn format(self: Entry, writer: *std.Io.Writer) !void { + return writer.print("{f}: {f}", .{ self.name, self.value }); + } }; pub fn init() KeyValueList { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index ab0c28ec8..8e65a5e9a 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -143,7 +143,6 @@ pub fn parentElement(self: *const Node) ?*Element { } pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node { - // Special case: DocumentFragment - append all its children instead if (child.is(DocumentFragment)) |_| { try page.appendAllChildren(child, self); return child; @@ -338,6 +337,11 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page return error.NotFound; } + if (new_node.is(DocumentFragment)) |_| { + try page.insertAllChildrenBefore(new_node, self, ref_node); + return new_node; + } + const child_already_connected = new_node.isConnected(); page.domChanged(); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ad755b441..250abb268 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -157,7 +157,7 @@ pub fn setOnUnhandledRejection(self: *Window, cb_: ?js.Function) !void { } } -pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.RequestInit, page: *Page) !js.Promise { +pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, page: *Page) !js.Promise { return Fetch.init(input, options, page); } diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig new file mode 100644 index 000000000..2fecfa954 --- /dev/null +++ b/src/browser/webapi/animation/Animation.zig @@ -0,0 +1,49 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Animation = @This(); + +pub fn init() !Animation { + return .{}; +} + +pub fn play(_: *Animation) void {} +pub fn pause(_: *Animation) void {} +pub fn cancel(_: *Animation) void {} +pub fn finish(_: *Animation) void {} +pub fn reverse(_: *Animation) void {} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Animation); + + pub const Meta = struct { + pub const name = "Animation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const play = bridge.function(Animation.play, .{}); + pub const pause = bridge.function(Animation.pause, .{}); + pub const cancel = bridge.function(Animation.cancel, .{}); + pub const finish = bridge.function(Animation.finish, .{}); + pub const reverse = bridge.function(Animation.reverse, .{}); +}; diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 0f6c37f32..50d44270e 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -40,10 +40,10 @@ _response: *Response, _resolver: js.PersistentPromiseResolver, pub const Input = Request.Input; -pub const RequestInit = Request.Options; +pub const InitOpts = Request.InitOpts; // @ZIGDOM just enough to get campfire demo working -pub fn init(input: Input, options: ?RequestInit, page: *Page) !js.Promise { +pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const fetch = try page.arena.create(Fetch); @@ -56,7 +56,11 @@ pub fn init(input: Input, options: ?RequestInit, page: *Page) !js.Promise { }; const http_client = page._session.browser.http_client; - const headers = try http_client.newHeaders(); + var headers = try http_client.newHeaders(); + if (request._headers) |h| { + try h.populateHttpHeader(page.call_arena, &headers); + } + try page.requestCookie(.{}).headersForRequest(page.arena, request._url, &headers); if (comptime IS_DEBUG) { log.debug(.http, "fetch", .{ .url = request._url }); diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 136207bd9..633771791 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -5,16 +5,34 @@ const log = @import("../../../log.zig"); const Page = @import("../../Page.zig"); const KeyValueList = @import("../KeyValueList.zig"); +const Allocator = std.mem.Allocator; + const Headers = @This(); _list: KeyValueList, -pub fn init(page: *Page) !*Headers { +pub const InitOpts = union(enum) { + obj: *Headers, + js_obj: js.Object, +}; + +pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { + const list = if (opts_) |opts| switch (opts) { + .obj => |obj| try KeyValueList.copy(page.arena, obj._list), + .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj), + } else KeyValueList.init(); + return page._factory.create(Headers{ - ._list = KeyValueList.init(), + ._list = list, }); } +// pub fn fromJsObject(js_obj: js.Object, page: *Page) !*Headers { +// return page._factory.create(Headers{ +// ._list = try KeyValueList.fromJsObject(page.arena, js_obj), +// }); +// } + pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const normalized_name = normalizeHeaderName(name, page); try self._list.append(page.arena, normalized_name, value); @@ -63,6 +81,15 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { } } +// TODO: do we really need 2 different header structs?? +const Http = @import("../../../http/Http.zig"); +pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *Http.Headers) !void { + for (self._list._entries.items) |entry| { + const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0); + try http_headers.add(merged); + } +} + fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { if (name.len > page.buf.len) { return name; diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index d1524afe1..9ca84c418 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -37,19 +37,19 @@ pub const Input = union(enum) { url: [:0]const u8, }; -pub const Options = struct { +pub const InitOpts = struct { method: ?[]const u8 = null, - headers: ?*Headers = null, + headers: ?Headers.InitOpts = null, }; -pub fn init(input: Input, opts_: ?Options, page: *Page) !*Request { +pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { const arena = page.arena; const url = switch (input) { .url => |u| try URL.resolve(arena, page.url, u, .{ .always_dupe = true }), .request => |r| try arena.dupeZ(u8, r._url), }; - const opts = opts_ orelse Options{}; + const opts = opts_ orelse InitOpts{}; const method = if (opts.method) |m| try parseMethod(m, page) else switch (input) { @@ -57,8 +57,8 @@ pub fn init(input: Input, opts_: ?Options, page: *Page) !*Request { .request => |r| r._method, }; - const headers = if (opts.headers) |h| - h + const headers = if (opts.headers) |header_init| + try Headers.init(header_init, page) else switch (input) { .url => null, .request => |r| r._headers, @@ -103,7 +103,7 @@ pub fn getHeaders(self: *Request, page: *Page) !*Headers { return headers; } - const headers = try Headers.init(page); + const headers = try Headers.init(null, page); self._headers = headers; return headers; } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 244475668..fe4643209 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -56,7 +56,7 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { ._arena = page.arena, ._status = opts.status, ._body = body, - ._headers = opts.headers orelse try Headers.init(page), + ._headers = opts.headers orelse try Headers.init(null, page), ._type = .basic, // @ZIGDOM: todo }); } diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 9bdecd2ec..73e5e1101 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -45,7 +45,7 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), .value => |js_val| { if (js_val.isObject()) { - break :blk try paramsFromObject(arena, js_val.toObject()); + break :blk try KeyValueList.fromJsObject(arena, js_val.toObject()); } if (js_val.isString()) { break :blk try paramsFromString(arena, try js_val.toString(arena), &page.buf); @@ -187,25 +187,6 @@ fn paramsFromString(allocator: Allocator, input_: []const u8, buf: []u8) !KeyVal return params; } -fn paramsFromObject(arena: Allocator, js_obj: js.Object) !KeyValueList { - var it = js_obj.nameIterator(arena); - - var params = KeyValueList.init(); - try params.ensureTotalCapacity(arena, it.count); - - while (try it.next()) |name| { - const js_value = try js_obj.get(name); - const value = try js_value.toString(arena); - - try params._entries.append(arena, .{ - .name = try String.init(arena, name, .{}), - .value = try String.init(arena, value, .{}), - }); - } - - return params; -} - fn unescape(arena: Allocator, value: []const u8, buf: []u8) !String { if (value.len == 0) { return String.init(undefined, "", .{}); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 4b0cdb9f9..4959d5638 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -26,6 +26,7 @@ const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); +const Headers = @import("Headers.zig"); const EventTarget = @import("../EventTarget.zig"); const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); @@ -40,6 +41,7 @@ _transfer: ?*Http.Transfer = null, _url: [:0]const u8 = "", _method: Http.Method = .GET, +_request_headers: *Headers, _request_body: ?[]const u8 = null, _response: std.ArrayList(u8) = .empty, @@ -71,6 +73,7 @@ pub fn init(page: *Page) !*XMLHttpRequest { ._page = page, ._proto = undefined, ._arena = page.arena, + ._request_headers = try Headers.init(null, page), }); } @@ -129,6 +132,10 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void try self.stateChanged(.opened, self._page); } +pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, page: *Page) !void { + return self._request_headers.append(name, value, page); +} + pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { if (comptime IS_DEBUG) { log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url }); @@ -143,10 +150,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { const page = self._page; const http_client = page._session.browser.http_client; var headers = try http_client.newHeaders(); - // @ZIGDOM - // for (self._headers.items) |hdr| { - // try headers.add(hdr); - // } + try self._request_headers.populateHttpHeader(page.call_arena, &headers); try page.requestCookie(.{}).headersForRequest(self._arena, self._url, &headers); try http_client.request(.{ @@ -351,6 +355,7 @@ pub const JsApi = struct { pub const responseType = bridge.accessor(XMLHttpRequest.getResponseType, XMLHttpRequest.setResponseType, .{}); pub const status = bridge.accessor(XMLHttpRequest.getStatus, null, .{}); pub const response = bridge.accessor(XMLHttpRequest.getResponse, null, .{}); + pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{}); }; const testing = @import("../../../testing.zig"); From aa3a402f70a4901a7d8f57885a37c15c7edc74f8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 4 Dec 2025 15:38:47 +0800 Subject: [PATCH 140/257] Link get/set rel Include stack trace on console.error Don't unnecessarily copy request header on fetch --- src/browser/webapi/Console.zig | 18 ++++++++++++------ src/browser/webapi/element/html/Link.zig | 9 +++++++++ src/browser/webapi/net/Request.zig | 7 ++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index 3563f1c57..81fdc54b5 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const logger = @import("../../log.zig"); const Console = @This(); @@ -26,25 +27,30 @@ _pad: bool = false, pub const init: Console = .{}; -pub fn log(_: *const Console, values: []js.Object) void { - logger.info(.js, "console.log", .{ValueWriter{ .values = values }}); +pub fn log(_: *const Console, values: []js.Object, page: *Page) void { + logger.info(.js, "console.log", .{ValueWriter{ .page = page, .values = values }}); } -pub fn warn(_: *const Console, values: []js.Object) void { - logger.warn(.js, "console.warn", .{ValueWriter{ .values = values }}); +pub fn warn(_: *const Console, values: []js.Object, page: *Page) void { + logger.warn(.js, "console.warn", .{ValueWriter{ .page = page, .values = values }}); } -pub fn @"error"(_: *const Console, values: []js.Object) void { - logger.warn(.js, "console.error", .{ValueWriter{ .values = values }}); +pub fn @"error"(_: *const Console, values: []js.Object, page: *Page) void { + logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }}); } const ValueWriter = struct { + page: *Page, values: []js.Object, + include_stack: bool = false, pub fn format(self: ValueWriter, writer: *std.io.Writer) !void { for (self.values, 1..) |value, i| { try writer.print("\n arg({d}): {f}", .{ i, value }); } + if (self.include_stack) { + try writer.print("\n stack: {s}", .{self.page.js.stackTrace() catch |err| @errorName(err) orelse "???"}); + } } pub fn jsonStringify(self: ValueWriter, writer: *std.json.Stringify) !void { try writer.beginArray(); diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 65e879179..b9db1e53c 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -43,6 +43,14 @@ pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe("href", value, page); } +pub fn getRel(self: *Link) []const u8 { + return self.asElement().getAttributeSafe("rel") orelse return ""; +} + +pub fn setRel(self: *Link, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("rel", value, page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Link); @@ -52,5 +60,6 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const rel = bridge.accessor(Link.getRel, Link.setRel, .{}); pub const href = bridge.accessor(Link.getHref, Link.setHref, .{}); }; diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 9ca84c418..8dea853f9 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -57,9 +57,10 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { .request => |r| r._method, }; - const headers = if (opts.headers) |header_init| - try Headers.init(header_init, page) - else switch (input) { + const headers = if (opts.headers) |headers_init| switch (headers_init) { + .obj => |h| h, + else => try Headers.init(headers_init, page), + } else switch (input) { .url => null, .request => |r| r._headers, }; From ff9f9bae1db3c98a963b014c2d61507cdf9a1d50 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 4 Dec 2025 16:10:56 +0800 Subject: [PATCH 141/257] fetch with body --- src/browser/webapi/net/Fetch.zig | 3 ++- src/browser/webapi/net/Request.zig | 34 ++++++++++++++++++++---------- src/http/Http.zig | 2 ++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 50d44270e..88c961d73 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -69,7 +69,8 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { try http_client.request(.{ .ctx = fetch, .url = request._url, - .method = .GET, + .method = request._method, + .body = request._body, .headers = headers, .resource_type = .fetch, .cookie_jar = &page._session.cookie_jar, diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 8dea853f9..d053b4347 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); +const Http = @import("../../../http/Http.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); @@ -28,8 +29,9 @@ const Allocator = std.mem.Allocator; const Request = @This(); _url: [:0]const u8, -_method: std.http.Method, +_method: Http.Method, _headers: ?*Headers, +_body: ?[]const u8, _arena: Allocator, pub const Input = union(enum) { @@ -40,6 +42,7 @@ pub const Input = union(enum) { pub const InitOpts = struct { method: ?[]const u8 = null, headers: ?Headers.InitOpts = null, + body: ?[]const u8 = null, }; pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { @@ -65,30 +68,39 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { .request => |r| r._headers, }; + const body = if (opts.body) |b| + try arena.dupe(u8, b) + else switch (input) { + .url => null, + .request => |r| r._body, + }; + return page._factory.create(Request{ ._url = url, ._arena = arena, ._method = method, ._headers = headers, + ._body = body, }); } -fn parseMethod(method: []const u8, page: *Page) !std.http.Method { +fn parseMethod(method: []const u8, page: *Page) !Http.Method { if (method.len > "options".len) { return error.InvalidMethod; } const lower = std.ascii.lowerString(&page.buf, method); - if (std.mem.eql(u8, lower, "get")) return .GET; - if (std.mem.eql(u8, lower, "post")) return .POST; - if (std.mem.eql(u8, lower, "delete")) return .DELETE; - if (std.mem.eql(u8, lower, "put")) return .PUT; - if (std.mem.eql(u8, lower, "patch")) return .PATCH; - if (std.mem.eql(u8, lower, "head")) return .HEAD; - if (std.mem.eql(u8, lower, "options")) return .OPTIONS; - - return error.InvalidMethod; + const method_lookup = std.StaticStringMap(Http.Method).initComptime(.{ + .{ "get", .GET }, + .{ "post", .POST }, + .{ "delete", .DELETE }, + .{ "put", .PUT }, + .{ "patch", .PATCH }, + .{ "head", .HEAD }, + .{ "options", .OPTIONS }, + }); + return method_lookup.get(lower) orelse return error.InvalidMethod; } pub fn getUrl(self: *const Request) []const u8 { diff --git a/src/http/Http.zig b/src/http/Http.zig index e5be87ee2..1a5580293 100644 --- a/src/http/Http.zig +++ b/src/http/Http.zig @@ -222,6 +222,7 @@ pub const Connection = struct { .DELETE => "DELETE", .HEAD => "HEAD", .OPTIONS => "OPTIONS", + .PATCH => "PATCH", }; try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_CUSTOMREQUEST, m.ptr)); } @@ -360,6 +361,7 @@ pub const Method = enum(u8) { DELETE = 3, HEAD = 4, OPTIONS = 5, + PATCH = 6, }; // TODO: on BSD / Linux, we could just read the PEM file directly. From dd3781a1ea43064ef91b76a33c039abe5f68335f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 5 Dec 2025 16:09:00 +0800 Subject: [PATCH 142/257] Higher performance.now() precision (closer to FFs behavior) Much better v8 object debugging/printing in debug mode Window.requestIdleCallback and cancelIdleCallback Don't prematurely close stream on empty read - queue promises. --- src/browser/js/Caller.zig | 22 ++--- src/browser/js/Context.zig | 99 +++++++++++-------- src/browser/js/Object.zig | 10 +- src/browser/js/js.zig | 29 +++++- .../tests/streams/readable_stream.html | 12 --- src/browser/webapi/CustomElementRegistry.zig | 2 +- src/browser/webapi/Performance.zig | 20 +++- src/browser/webapi/Window.zig | 20 ++++ src/browser/webapi/net/Fetch.zig | 6 +- .../ReadableStreamDefaultController.zig | 43 +++++++- .../streams/ReadableStreamDefaultReader.zig | 9 +- src/datetime.zig | 2 +- 12 files changed, 177 insertions(+), 97 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index f4b32ddbe..d705e01f0 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -475,23 +475,15 @@ fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, } fn serializeFunctionArgs(self: *Caller, info: v8.FunctionCallbackInfo) ![]const u8 { - const separator = log.separator(); - const js_parameter_count = info.length(); - const context = self.context; - var arr: std.ArrayListUnmanaged(u8) = .{}; - for (0..js_parameter_count) |i| { - const js_value = info.getArg(@intCast(i)); - const value_string = try context.valueToDetailString(js_value); - const value_type = try context.jsStringToZig(try js_value.typeOf(self.isolate), .{}); - try std.fmt.format(arr.writer(context.call_arena), "{s}{d}: {s} ({s})", .{ - separator, - i + 1, - value_string, - value_type, - }); + var buf = std.Io.Writer.Allocating.init(context.call_arena); + + const separator = log.separator(); + for (0..info.length()) |i| { + try buf.writer.print("{s}{d} - ", .{ separator, i + 1 }); + try context.debugValue(info.getArg(@intCast(i)), &buf.writer); } - return arr.items; + return buf.written(); } // Takes a function, and returns a tuple for its argument. Used when we diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index ce497e487..24f52decc 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1062,67 +1062,84 @@ pub fn jsStringToZigZ(self: *const Context, str: v8.String, opts: JsStringToZigO return buf; } -pub fn valueToDetailString(self: *const Context, value: v8.Value) ![]u8 { - var str: ?v8.String = null; - const v8_context = self.v8_context; - - if (value.isObject() and !value.isFunction()) blk: { - str = v8.Json.stringify(v8_context, value, null) catch break :blk; +pub fn debugValue(self: *const Context, js_val: v8.Value, writer: *std.Io.Writer) !void { + var seen: std.AutoHashMapUnmanaged(u32, void) = .empty; + return _debugValue(self, js_val, &seen, 0, writer) catch error.WriteFailed; +} - if (str.?.lenUtf8(self.isolate) == 2) { - // {} isn't useful, null this so that we can get the toDetailString - // (which might also be useless, but maybe not) - str = null; - } +fn _debugValue(self: *const Context, js_val: v8.Value, seen: *std.AutoHashMapUnmanaged(u32, void), depth: usize, writer: *std.Io.Writer) !void { + if (js_val.isNull()) { + // I think null can sometimes appear as an object, so check this and + // handle it first. + return writer.writeAll("null"); } - if (str == null) { - str = try value.toDetailString(v8_context); + if (!js_val.isObject()) { + // handle these explicitly, so we don't include the type (we only want to include + // it when there's some ambiguity, e.g. the string "true") + if (js_val.isUndefined()) { + return writer.writeAll("undefined"); + } + if (js_val.isTrue()) { + return writer.writeAll("true"); + } + if (js_val.isFalse()) { + return writer.writeAll("false"); + } + // TODO: KARL wait for v8 build to work again, this works with + // the latest version of zig-v8-fork, I just can't build it right now + // APPLY THIS change to valueToString and valueToStringz + // if (js_val.isSymbol()) { + // const js_sym = v8.Symbol{.handle = js_val.handle}; + // const js_sym_desc = js_sym.getDescription(self.isolate); + // const js_sym_str = try self.valueToString(js_sym_desc, .{}); + // return writer.print("{s} (symbol)", .{js_sym_str}); + // } + const js_type = try self.jsStringToZig(try js_val.typeOf(self.isolate), .{}); + const js_val_str = try self.valueToString(js_val, .{}); + if (js_val_str.len > 2000) { + try writer.writeAll(js_val_str[0..2000]); + try writer.writeAll(" ... (truncated)"); + } else { + try writer.writeAll(js_val_str); + } + return writer.print(" ({s})", .{js_type}); } - const s = try self.jsStringToZig(str.?, .{}); - if (comptime builtin.mode == .Debug) { - if (std.mem.eql(u8, s, "[object Object]")) { - if (self.debugValueToString(value.castTo(v8.Object))) |ds| { - return ds; - } else |err| { - log.err(.js, "debug serialize value", .{ .err = err }); - } + const js_obj = js_val.castTo(v8.Object); + { + // explicit scope because gop will become invalid in recursive call + const gop = try seen.getOrPut(self.call_arena, js_obj.getIdentityHash()); + if (gop.found_existing) { + return writer.writeAll("\n"); } + gop.value_ptr.* = {}; } - return s; -} -fn debugValueToString(self: *const Context, js_obj: v8.Object) ![]u8 { - if (comptime builtin.mode != .Debug) { - @compileError("debugValue can only be called in debug mode"); - } const v8_context = self.v8_context; - const names_arr = js_obj.getOwnPropertyNames(v8_context); const names_obj = names_arr.castTo(v8.Object); const len = names_arr.length(); - var arr: std.ArrayListUnmanaged(u8) = .empty; - var writer = arr.writer(self.call_arena); - try writer.writeAll("(JSON.stringify failed, dumping top-level fields)\n"); + if (depth > 20) { + return writer.writeAll("...deeply nested object..."); + } + + try writer.print("({d}/{d})", .{ js_obj.getOwnPropertyNames(v8_context).length(), js_obj.getPropertyNames(v8_context).length() }); for (0..len) |i| { + if (i == 0) { + try writer.writeByte('\n'); + } const field_name = try names_obj.getAtIndex(v8_context, @intCast(i)); - const field_value = try js_obj.getValue(v8_context, field_name); const name = try self.valueToString(field_name, .{}); - const value = try self.valueToString(field_value, .{}); + try writer.splatByteAll(' ', depth); try writer.writeAll(name); try writer.writeAll(": "); - if (std.mem.indexOfAny(u8, value, &std.ascii.whitespace) == null) { - try writer.writeAll(value); - } else { - try writer.writeByte('"'); - try writer.writeAll(value); - try writer.writeByte('"'); + try self._debugValue(try js_obj.getValue(v8_context, field_name), seen, depth + 1, writer); + if (i != len - 1) { + try writer.writeByte('\n'); } - try writer.writeByte(' '); } - return arr.items; } pub fn stackTrace(self: *const Context) !?[]const u8 { diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 9ab35fe12..2e77a54af 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -20,6 +20,8 @@ const std = @import("std"); const js = @import("js.zig"); const v8 = js.v8; +const IS_DEBUG = @import("builtin").mode == .Debug; + const Caller = @import("Caller.zig"); const Context = @import("Context.zig"); const PersistentObject = v8.Persistent(v8.Object); @@ -74,12 +76,10 @@ pub fn toString(self: Object) ![]const u8 { return self.context.valueToString(js_value, .{}); } -pub fn toDetailString(self: Object) ![]const u8 { - const js_value = self.js_obj.toValue(); - return self.context.valueToDetailString(js_value); -} - pub fn format(self: Object, writer: *std.Io.Writer) !void { + if (comptime IS_DEBUG) { + return self.context.debugValue(self.js_obj.toValue(), writer); + } const str = self.toString() catch return error.WriteFailed; return writer.writeAll(str); } diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index e6f736681..99828f53c 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -71,7 +71,12 @@ pub const PromiseResolver = struct { return self.resolver.getPromise(); } - pub fn resolve(self: PromiseResolver, value: anytype) !void { + pub fn resolve(self: PromiseResolver, comptime source: []const u8, value: anytype) void { + self._resolve(value) catch |err| { + log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = false }); + }; + } + fn _resolve(self: PromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value); @@ -81,7 +86,12 @@ pub const PromiseResolver = struct { self.runMicrotasks(); } - pub fn reject(self: PromiseResolver, value: anytype) !void { + pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype) void { + self._reject(value) catch |err| { + log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = false }); + }; + } + fn _reject(self: PromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value); @@ -104,7 +114,12 @@ pub const PersistentPromiseResolver = struct { return self.resolver.castToPromiseResolver().getPromise(); } - pub fn resolve(self: PersistentPromiseResolver, value: anytype) !void { + pub fn resolve(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void { + self._resolve(value) catch |err| { + log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = true }); + }; + } + fn _resolve(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value, .{}); defer context.runMicrotasks(); @@ -114,7 +129,13 @@ pub const PersistentPromiseResolver = struct { } } - pub fn reject(self: PersistentPromiseResolver, value: anytype) !void { + pub fn reject(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void { + self._reject(value) catch |err| { + log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = true }); + }; + } + + fn _reject(self: PersistentPromiseResolver, value: anytype) !void { const context = self.context; const js_value = try context.zigValueToJs(value, .{}); defer context.runMicrotasks(); diff --git a/src/browser/tests/streams/readable_stream.html b/src/browser/tests/streams/readable_stream.html index 2c74697b2..3e0744bbe 100644 --- a/src/browser/tests/streams/readable_stream.html +++ b/src/browser/tests/streams/readable_stream.html @@ -21,18 +21,6 @@ } - - + + diff --git a/src/browser/tests/element/svg/svg.html b/src/browser/tests/element/svg/svg.html index 77f29c316..b981089ca 100644 --- a/src/browser/tests/element/svg/svg.html +++ b/src/browser/tests/element/svg/svg.html @@ -6,23 +6,61 @@ + + + + OVER 9000!! + + + + + OVER 9000!!! + + + diff --git a/src/browser/tests/node/normalize.html b/src/browser/tests/node/normalize.html index 45c2a0bb5..ead599a6b 100644 --- a/src/browser/tests/node/normalize.html +++ b/src/browser/tests/node/normalize.html @@ -28,3 +28,22 @@ testing.expectEqual('a

b', container.innerHTML); testing.expectEqual(3, container.childNodes.length); + +"puppeteer " +

Leto + + + Atreides

+ diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index eadcff3d7..758bb1f24 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -343,7 +343,7 @@ pub fn getOrCreateAttributeList(self: *Element, page: *Page) !*Attribute.List { pub fn createAttributeList(self: *Element, page: *Page) !*Attribute.List { std.debug.assert(self._attributes == null); const a = try page.arena.create(Attribute.List); - a.* = .{.normalize = self._namespace == .html}; + a.* = .{ .normalize = self._namespace == .html }; self._attributes = a; return a; } diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index b4b9a4ee7..1919a24e6 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -118,6 +118,7 @@ pub const JsApi = struct { // in our Entry? Because that would require an extra 8 bytes for every single // attribute in the DOM, and, again, we expect that to almost always be null. pub const List = struct { + normalize: bool, _list: std.DoublyLinkedList = .{}, pub fn isEmpty(self: *const List) bool { @@ -273,7 +274,9 @@ pub const List = struct { entry: ?*Entry, }; fn getEntryAndNormalizedName(self: *const List, name: []const u8, page: *Page) !NormalizeAndEntry { - const normalized = try normalizeNameForLookup(name, page); + const normalized = + if (self.normalize) try normalizeNameForLookup(name, page) else name; + return .{ .normalized = normalized, .entry = self.getEntryWithNormalizedName(normalized), From 8e16c587c820390e8446667db1e9f7eb4c2d2010 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 5 Dec 2025 18:02:27 +0800 Subject: [PATCH 145/257] encode property as u32 whenever possible --- src/browser/js/js.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 99828f53c..203b3b248 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -256,6 +256,9 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo .bool => return v8.getValue(if (value) v8.initTrue(isolate) else v8.initFalse(isolate)), .int => |n| switch (n.signedness) { .signed => { + if (value > 0 and value <= 4_294_967_295) { + return v8.Integer.initU32(isolate, @intCast(value)).toValue(); + } if (value >= -2_147_483_648 and value <= 2_147_483_647) { return v8.Integer.initI32(isolate, @intCast(value)).toValue(); } From 637a105e5d860eb713faff9b3307eb8784adbd40 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 5 Dec 2025 18:11:45 +0800 Subject: [PATCH 146/257] getRootNode composed support --- src/browser/tests/node/node.html | 21 +++++++++++++++++++++ src/browser/webapi/Node.zig | 15 ++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/browser/tests/node/node.html b/src/browser/tests/node/node.html index 9305bf875..72c51748e 100644 --- a/src/browser/tests/node/node.html +++ b/src/browser/tests/node/node.html @@ -189,3 +189,24 @@ testing.expectEqual(8, Node.COMMENT_NODE); testing.expectEqual(11, Node.DOCUMENT_FRAGMENT_NODE); + +
+ diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 8e65a5e9a..596dc5549 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -259,14 +259,23 @@ const GetRootNodeOpts = struct { }; pub fn getRootNode(self: *const Node, opts_: ?GetRootNodeOpts) *const Node { const opts = opts_ orelse GetRootNodeOpts{}; - if (opts.composed) { - log.warn(.not_implemented, "Node.getRootNode", .{ .feature = "composed" }); - } var root = self; while (root._parent) |parent| { root = parent; } + + // If composed is true, traverse through shadow boundaries + if (opts.composed) { + while (true) { + const shadow_root = @constCast(root).is(ShadowRoot) orelse break; + root = shadow_root.getHost().asNode(); + while (root._parent) |parent| { + root = parent; + } + } + } + return root; } From e41d53019f22a2ae9035fea362f6d62027ccb7fc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 5 Dec 2025 18:18:46 +0800 Subject: [PATCH 147/257] CompositionEvent --- src/browser/js/bridge.zig | 1 + src/browser/tests/event/composition.html | 36 +++++++++ src/browser/webapi/Event.zig | 1 + src/browser/webapi/event/CompositionEvent.zig | 73 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 src/browser/tests/event/composition.html create mode 100644 src/browser/webapi/event/CompositionEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 372d260aa..fa6229759 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -559,6 +559,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/encoding/TextDecoder.zig"), @import("../webapi/encoding/TextEncoder.zig"), @import("../webapi/Event.zig"), + @import("../webapi/event/CompositionEvent.zig"), @import("../webapi/event/CustomEvent.zig"), @import("../webapi/event/ErrorEvent.zig"), @import("../webapi/event/MessageEvent.zig"), diff --git a/src/browser/tests/event/composition.html b/src/browser/tests/event/composition.html new file mode 100644 index 000000000..b5a6a7100 --- /dev/null +++ b/src/browser/tests/event/composition.html @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 579ad418a..dafb0204c 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -55,6 +55,7 @@ pub const Type = union(enum) { custom_event: *@import("event/CustomEvent.zig"), message_event: *@import("event/MessageEvent.zig"), progress_event: *@import("event/ProgressEvent.zig"), + composition_event: *@import("event/CompositionEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig new file mode 100644 index 000000000..7fa701bd0 --- /dev/null +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -0,0 +1,73 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); + +const Page = @import("../../Page.zig"); +const Event = @import("../Event.zig"); + +const CompositionEvent = @This(); + +_proto: *Event, +_data: []const u8 = "", + +pub const InitOptions = struct { + data: ?[]const u8 = null, + bubbles: bool = false, + cancelable: bool = false, +}; + +pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*CompositionEvent { + const opts = opts_ orelse InitOptions{}; + + const event = try page._factory.event(typ, CompositionEvent{ + ._proto = undefined, + ._data = if (opts.data) |str| try page.dupeString(str) else "", + }); + + event._proto._bubbles = opts.bubbles; + event._proto._cancelable = opts.cancelable; + + return event; +} + +pub fn asEvent(self: *CompositionEvent) *Event { + return self._proto; +} + +pub fn getData(self: *const CompositionEvent) []const u8 { + return self._data; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CompositionEvent); + + pub const Meta = struct { + pub const name = "CompositionEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(CompositionEvent.init, .{}); + pub const data = bridge.accessor(CompositionEvent.getData, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CompositionEvent" { + try testing.htmlRunner("event/composition.html", .{}); +} From f5d3dede6b6aab06a5758ec847f9b29cda95f652 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 6 Dec 2025 18:19:44 +0100 Subject: [PATCH 148/257] node: textContent must ignore comments for elements --- src/browser/tests/node/text_content.html | 17 +++++++++++++---- src/browser/webapi/Node.zig | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/browser/tests/node/text_content.html b/src/browser/tests/node/text_content.html index d6250320e..fc6a0de9a 100644 --- a/src/browser/tests/node/text_content.html +++ b/src/browser/tests/node/text_content.html @@ -1,6 +1,12 @@
d1

hello

+
+ + + This is a
+ text +
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 596dc5549..2cb0d323d 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -171,7 +171,20 @@ pub fn childNodes(self: *const Node, page: *Page) !*collections.ChildNodes { pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void { switch (self._type) { - .element => |el| return el.getInnerText(writer), + .element => { + var it = self.childrenIterator(); + while (it.next()) |child| { + // ignore comments and TODO processing instructions. + switch (child._type) { + .cdata => |c| switch (c._type) { + .comment => continue, + .text => {}, + }, + else => {}, + } + try child.getTextContent(writer); + } + }, .cdata => |c| try writer.writeAll(c.getData()), .document => {}, .document_type => {}, @@ -719,7 +732,7 @@ pub const JsApi = struct { switch (self._type) { .element => |el| { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try el.getInnerText(&buf.writer); + try el.asNode().getTextContent(&buf.writer); return buf.written(); }, .cdata => |cdata| return cdata.getData(), From a673eb89b658ca7071c01e74bec868c7debf2dd4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 6 Dec 2025 19:09:20 +0100 Subject: [PATCH 149/257] element: innerText which must return rendered text --- src/browser/tests/element/inner.html | 13 ++++++++++ src/browser/webapi/Element.zig | 36 +++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/inner.html b/src/browser/tests/element/inner.html index c9bb08946..2c93a7178 100644 --- a/src/browser/tests/element/inner.html +++ b/src/browser/tests/element/inner.html @@ -1,6 +1,12 @@
hello world
+
+ + + This is a
+ text +
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 758bb1f24..83f971a6d 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -223,10 +223,44 @@ pub fn getNamespaceURI(self: *const Element) []const u8 { return self._namespace.toUri(); } +// innerText represents the **rendered** text content of a node and its +// descendants. pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { var it = self.asNode().childrenIterator(); while (it.next()) |child| { - try child.getTextContent(writer); + switch (child._type) { + .element => |e| switch (e._type) { + .html => |he| switch (he._type) { + .br => try writer.writeByte('\n'), + .script, .style, .template => continue, + else => try e.getInnerText(writer), // TODO check if elt is hidden. + }, + .svg => {}, + }, + .cdata => |c| switch (c._type) { + .comment => continue, + .text => { + const data = c.getData(); + if (std.mem.trim(u8, data, &std.ascii.whitespace).len != 0) { + // Trim all whitespaces except spaces. + // TODO this is not the correct way to render text, this is + // a temp approximation. + const text = std.mem.trim(u8, data, &[_]u8{ + '\t', + '\n', + '\r', + std.ascii.control_code.vt, + std.ascii.control_code.ff, + }); + try writer.writeAll(text); + } + }, + }, + .document => {}, + .document_type => {}, + .document_fragment => {}, + .attribute => |attr| try writer.writeAll(attr._value), + } } } From 08d7f544ddfe668ed86470e23bfc0c08a8d75b49 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 6 Dec 2025 19:15:24 +0100 Subject: [PATCH 150/257] fix comment formatting --- src/browser/webapi/CData.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index f02d51cad..8bcb673ae 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -75,7 +75,7 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { pub fn format(self: *const CData, writer: *std.io.Writer) !void { return switch (self._type) { .text => writer.print("{s}", .{self._data}), - .comment => writer.print("{s}", .{self._data}), + .comment => writer.print("", .{self._data}), }; } From 240e8b35022570926fd004815b5eebe1ca07a39d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 7 Dec 2025 09:52:59 +0100 Subject: [PATCH 151/257] use a better comparison to detect comment --- src/browser/webapi/Node.zig | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 2cb0d323d..ef6de4a7d 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -175,12 +175,8 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo var it = self.childrenIterator(); while (it.next()) |child| { // ignore comments and TODO processing instructions. - switch (child._type) { - .cdata => |c| switch (c._type) { - .comment => continue, - .text => {}, - }, - else => {}, + if (child.is(CData.Comment) != null) { + continue; } try child.getTextContent(writer); } From 9370e298d2286991cff83b7306870d37f84b19f5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 8 Dec 2025 09:07:56 +0800 Subject: [PATCH 152/257] improve HTMLOption and HTMLOptionCollection --- src/browser/tests/element/html/select.html | 23 ++++++++++++++ .../collections/HTMLOptionsCollection.zig | 24 +++++++++++---- src/browser/webapi/element/html/Option.zig | 30 ++++++++----------- src/browser/webapi/element/html/Select.zig | 21 +++++++++---- 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/browser/tests/element/html/select.html b/src/browser/tests/element/html/select.html index ceb46c16b..4c4e6ccde 100644 --- a/src/browser/tests/element/html/select.html +++ b/src/browser/tests/element/html/select.html @@ -158,6 +158,7 @@ { const sel = $('#select1') const opts = sel.options + testing.expectEqual(3, sel.length) testing.expectEqual(3, opts.length) testing.expectEqual('HTMLOptionsCollection', opts.constructor.name) @@ -165,6 +166,9 @@ testing.expectEqual('val1', opts[0].value) testing.expectEqual('val2', opts[1].value) testing.expectEqual('val3', opts[2].value) + testing.expectEqual('val1', opts.item(0).value); + testing.expectEqual('val2', opts.item(1).value); + testing.expectEqual('val3', opts.item(2).value); } @@ -224,6 +228,12 @@ testing.expectEqual(2, opts.length) testing.expectEqual('zero', opts[0].value) testing.expectEqual('b', opts[1].value) + + opts.add(opt1, 0) + testing.expectEqual(3, opts.length) + testing.expectEqual('a', opts[0].value) + testing.expectEqual('zero', opts[1].value) + testing.expectEqual('b', opts[2].value) } @@ -364,3 +374,16 @@ testing.expectTrue(select.outerHTML.includes('size="7"')) } + + + diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig index 4c9d59c44..6a0cadc95 100644 --- a/src/browser/webapi/collections/HTMLOptionsCollection.zig +++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const HTMLCollection = @import("HTMLCollection.zig"); const NodeLive = @import("node_live.zig").NodeLive; @@ -59,17 +60,28 @@ pub fn setSelectedIndex(self: *HTMLOptionsCollection, index: i32) !void { const Option = @import("../element/html/Option.zig"); +const AddBeforeOption = union(enum) { + option: *Option, + index: u32, +}; + // Add a new option element -pub fn add(self: *HTMLOptionsCollection, element: *Option, before: ?*Option, page: *Page) !void { +pub fn add(self: *HTMLOptionsCollection, element: *Option, before_: ?AddBeforeOption, page: *Page) !void { const select_node = self._select.asNode(); const element_node = element.asElement().asNode(); - if (before) |before_option| { - const before_node = before_option.asElement().asNode(); - _ = try select_node.insertBefore(element_node, before_node, page); - } else { - _ = try select_node.appendChild(element_node, page); + var before_node: ?*Node = null; + if (before_) |before| { + switch (before) { + .index => |idx| { + if (self.getAtIndex(idx, page)) |el| { + before_node = el.asNode(); + } + }, + .option => |before_option| before_node = before_option.asNode(), + } } + _ = try select_node.insertBefore(element_node, before_node, page); } // Remove an option element by index diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index b5718a1ec..207f4aaf4 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -28,7 +28,6 @@ const Option = @This(); _proto: *HtmlElement, _value: ?[]const u8 = null, -_text: ?[]const u8 = null, _selected: bool = false, _default_selected: bool = false, _disabled: bool = false, @@ -43,9 +42,15 @@ pub fn asNode(self: *Option) *Node { return self.asElement().asNode(); } -pub fn getValue(self: *const Option) []const u8 { - // If value attribute exists, use that; otherwise use text content - return self._value orelse self._text orelse ""; +pub fn getValue(self: *Option, page: *Page) []const u8 { + // If value attribute exists, use that; otherwise use text content (stripped) + if (self._value) |v| { + return v; + } + + const node = self.asNode(); + const text = node.getTextContentAlloc(page.call_arena) catch return ""; + return std.mem.trim(u8, text, &std.ascii.whitespace); } pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { @@ -55,7 +60,9 @@ pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { } pub fn getText(self: *const Option) []const u8 { - return self._text orelse ""; + const node: *Node = @constCast(self.asConstElement().asConstNode()); + const allocator = std.heap.page_allocator; // TODO: use proper allocator + return node.getTextContentAlloc(allocator) catch ""; } pub fn getSelected(self: *const Option) bool { @@ -112,8 +119,6 @@ pub const JsApi = struct { }; pub const Build = struct { - const CData = @import("../../CData.zig"); - pub fn created(node: *Node, _: *Page) !void { var self = node.as(Option); const element = self.asElement(); @@ -129,17 +134,6 @@ pub const Build = struct { self._disabled = element.getAttributeSafe("disabled") != null; } - pub fn complete(node: *Node, _: *const Page) !void { - var self = node.as(Option); - - // Get text content - if (node.firstChild()) |child| { - if (child.is(CData.Text)) |txt| { - self._text = txt.getWholeText(); - } - } - } - pub fn attributeChange(element: *Element, name: []const u8, value: []const u8, _: *Page) !void { const attribute = std.meta.stringToEnum(enum { value, selected }, name) orelse return; const self = element.as(Option); diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index beb00ee5c..5b3c0b9ec 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -44,7 +44,7 @@ pub fn asConstNode(self: *const Select) *const Node { return self.asConstElement().asConstNode(); } -pub fn getValue(self: *Select) []const u8 { +pub fn getValue(self: *Select, page: *Page) []const u8 { // Return value of first selected option, or first option if none selected var first_option: ?*Option = null; var iter = self.asNode().childrenIterator(); @@ -54,25 +54,24 @@ pub fn getValue(self: *Select) []const u8 { first_option = option; } if (option.getSelected()) { - return option.getValue(); + return option.getValue(page); } } // No explicitly selected option, return first option's value if (first_option) |opt| { - return opt.getValue(); + return opt.getValue(page); } return ""; } pub fn setValue(self: *Select, value: []const u8, page: *Page) !void { - _ = page; // Find option with matching value and select it // Note: This updates the current state (_selected), not the default state (attribute) // Setting value always deselects all others, even for multiple selects var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; - option._selected = std.mem.eql(u8, option.getValue(), value); + option._selected = std.mem.eql(u8, option.getValue(page), value); } } @@ -196,6 +195,17 @@ pub fn getOptions(self: *Select, page: *Page) !*collections.HTMLOptionsCollectio }); } +pub fn getLength(self: *Select) u32 { + var i: u32 = 0; + var it = self.asNode().childrenIterator(); + while (it.next()) |child| { + if (child.is(Option) != null) { + i += 1; + } + } + return i; +} + pub fn getSelectedOptions(self: *Select, page: *Page) !collections.NodeLive(.selected_options) { return collections.NodeLive(.selected_options).init(null, self.asNode(), {}, page); } @@ -243,6 +253,7 @@ pub const JsApi = struct { pub const selectedOptions = bridge.accessor(Select.getSelectedOptions, null, .{}); pub const form = bridge.accessor(Select.getForm, null, .{}); pub const size = bridge.accessor(Select.getSize, Select.setSize, .{}); + pub const length = bridge.accessor(Select.getLength, null, .{}); }; pub const Build = struct { From 57ce4e16a95783cba08000037c972165bd169bbc Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 6 Dec 2025 17:03:34 +1100 Subject: [PATCH 153/257] feat: support listening on ipv6 --- src/main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 1f7bd57e3..b43ea92cf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -101,7 +101,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { switch (args.mode) { .serve => |opts| { log.debug(.app, "startup", .{ .mode = "serve" }); - const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| { + const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| { log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); return args.printUsageAndExit(false); }; From 0beae3b1a67d5f19491763edc6727b4247860286 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 8 Dec 2025 14:22:24 +0800 Subject: [PATCH 154/257] Various legacy document tests document.embeds, document.plugins, document.anchor, document.getElementsByName getElementsByClassName support for multiple class names various document getters --- src/browser/Page.zig | 6 + src/browser/js/bridge.zig | 1 + src/browser/tests/document/children.html | 28 ++++ src/browser/tests/document/collections.html | 3 +- .../tests/document/document-title.html | 34 +++++ src/browser/tests/document/document.html | 129 ++++++++++++++++++ .../get_elements_by_class_name-multiple.html | 48 +++++++ .../document/get_elements_by_class_name.html | 4 +- .../tests/document/get_elements_by_name.html | 60 ++++++++ .../get_elements_by_tag_name-wildcard.html | 39 ++++++ src/browser/tests/legacy/dom/document.html | 33 +++-- src/browser/tests/legacy/html/document.html | 2 +- src/browser/webapi/Document.zig | 67 ++++++++- src/browser/webapi/DocumentFragment.zig | 2 +- src/browser/webapi/Element.zig | 36 +++-- src/browser/webapi/HTMLDocument.zig | 33 ++++- .../webapi/collections/HTMLCollection.zig | 20 +++ src/browser/webapi/collections/node_live.zig | 54 ++++++-- src/browser/webapi/element/Attribute.zig | 7 + src/browser/webapi/element/Html.zig | 49 +++---- src/browser/webapi/element/html/Anchor.zig | 12 ++ src/browser/webapi/element/html/Embed.zig | 42 ++++++ src/browser/webapi/element/html/Select.zig | 4 +- 23 files changed, 638 insertions(+), 75 deletions(-) create mode 100644 src/browser/tests/document/children.html create mode 100644 src/browser/tests/document/document-title.html create mode 100644 src/browser/tests/document/get_elements_by_class_name-multiple.html create mode 100644 src/browser/tests/document/get_elements_by_name.html create mode 100644 src/browser/tests/document/get_elements_by_tag_name-wildcard.html create mode 100644 src/browser/webapi/element/html/Embed.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0845b72b1..17f25adef 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1120,6 +1120,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined }, ), + asUint("embed") => return self.createHtmlElementT( + Element.Html.Embed, + namespace, + attribute_iterator, + .{ ._proto = undefined }, + ), else => {}, }, 6 => switch (@as(u48, @bitCast(name[0..6].*))) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index fa6229759..faa61a2a3 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -531,6 +531,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Data.zig"), @import("../webapi/element/html/Dialog.zig"), @import("../webapi/element/html/Div.zig"), + @import("../webapi/element/html/Embed.zig"), @import("../webapi/element/html/Form.zig"), @import("../webapi/element/html/Generic.zig"), @import("../webapi/element/html/Head.zig"), diff --git a/src/browser/tests/document/children.html b/src/browser/tests/document/children.html new file mode 100644 index 000000000..b8a45d35c --- /dev/null +++ b/src/browser/tests/document/children.html @@ -0,0 +1,28 @@ + + + + + Test + + +
Content
+ + + + + diff --git a/src/browser/tests/document/collections.html b/src/browser/tests/document/collections.html index c9d4c14c1..b1c575858 100644 --- a/src/browser/tests/document/collections.html +++ b/src/browser/tests/document/collections.html @@ -5,7 +5,7 @@ - + + + + + + + + + + + + + diff --git a/src/browser/tests/document/document.html b/src/browser/tests/document/document.html index 5b2e40006..cc5ee34e3 100644 --- a/src/browser/tests/document/document.html +++ b/src/browser/tests/document/document.html @@ -40,3 +40,132 @@ const emptyText = document.createTextNode(''); testing.expectEqual('', emptyText.nodeValue); + + + + + + +Link 1 +Link 2 +Anchor 1 +Anchor 2 +Both href and name +No attributes + + + + + + + + diff --git a/src/browser/tests/document/get_elements_by_class_name-multiple.html b/src/browser/tests/document/get_elements_by_class_name-multiple.html new file mode 100644 index 000000000..9f1b0f5b9 --- /dev/null +++ b/src/browser/tests/document/get_elements_by_class_name-multiple.html @@ -0,0 +1,48 @@ + + + +
Div 1
+
Div 2
+
Div 3
+
Div 4
+
Div 5
+
Div 6
+ + + + + + + + diff --git a/src/browser/tests/document/get_elements_by_class_name.html b/src/browser/tests/document/get_elements_by_class_name.html index 1175fa554..3e291dce4 100644 --- a/src/browser/tests/document/get_elements_by_class_name.html +++ b/src/browser/tests/document/get_elements_by_class_name.html @@ -20,8 +20,8 @@ + + + + + +Section 1 +User Link + + + + + + diff --git a/src/browser/tests/document/get_elements_by_tag_name-wildcard.html b/src/browser/tests/document/get_elements_by_tag_name-wildcard.html new file mode 100644 index 000000000..685278df3 --- /dev/null +++ b/src/browser/tests/document/get_elements_by_tag_name-wildcard.html @@ -0,0 +1,39 @@ + + + + + Test + + +
+ Text +
+

Paragraph

+ + + diff --git a/src/browser/tests/legacy/dom/document.html b/src/browser/tests/legacy/dom/document.html index 950daaab6..822134d00 100644 --- a/src/browser/tests/legacy/dom/document.html +++ b/src/browser/tests/legacy/dom/document.html @@ -25,7 +25,6 @@ testing.expectEqual(true, newdoc.compatMode === document.compatMode); testing.expectEqual(true, newdoc.characterSet === document.characterSet); testing.expectEqual(true, newdoc.charset === document.charset); - testing.expectEqual(true, newdoc.contentType === document.contentType); testing.expectEqual('HTML', document.documentElement.tagName); @@ -35,8 +34,8 @@ testing.expectEqual('CSS1Compat', document.compatMode); testing.expectEqual('text/html', document.contentType); - testing.expectEqual('http://localhost:9582/src/tests/dom/document.html', document.documentURI); - testing.expectEqual('http://localhost:9582/src/tests/dom/document.html', document.URL); + testing.expectEqual('http://localhost:9589/dom/document.html', document.documentURI); + testing.expectEqual('http://localhost:9589/dom/document.html', document.URL); testing.expectEqual(document.body, document.activeElement); @@ -61,7 +60,7 @@ let byTagNameAll = document.getElementsByTagName('*'); // If you add a script block (or change the HTML in any other way on this // page), this test will break. Adjust it accordingly. - testing.expectEqual(21, byTagNameAll.length); + testing.expectEqual(12, byTagNameAll.length); testing.expectEqual('html', byTagNameAll.item(0).localName); testing.expectEqual('SCRIPT', byTagNameAll.item(11).tagName); @@ -170,21 +169,21 @@ diff --git a/src/browser/tests/legacy/html/document.html b/src/browser/tests/legacy/html/document.html index cc02f7c64..003ee8205 100644 --- a/src/browser/tests/legacy/html/document.html +++ b/src/browser/tests/legacy/html/document.html @@ -12,7 +12,7 @@ testing.expectEqual('Document', document.__proto__.__proto__.constructor.name); testing.expectEqual('body', document.body.localName); - testing.expectEqual('localhost:9582', document.domain); + testing.expectEqual('localhost', document.domain); testing.expectEqual('', document.referrer); testing.expectEqual('', document.title); testing.expectEqual('body', document.body.localName); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 6cea49870..928b97c0a 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -21,6 +21,7 @@ const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const URL = @import("../URL.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); @@ -79,6 +80,29 @@ pub fn getURL(_: *const Document, page: *const Page) [:0]const u8 { return page.url; } +pub fn getContentType(self: *const Document) []const u8 { + return switch (self._type) { + .html => "text/html", + .generic => "application/xml", + }; +} + +pub fn getCharacterSet(_: *const Document) []const u8 { + return "UTF-8"; +} + +pub fn getCompatMode(_: *const Document) []const u8 { + return "CSS1Compat"; +} + +pub fn getReferrer(_: *const Document) []const u8 { + return ""; +} + +pub fn getDomain(_: *const Document, page: *const Page) []const u8 { + return URL.getHostname(page.url); +} + const CreateElementOptions = struct { is: ?[]const u8 = null, }; @@ -109,6 +133,7 @@ pub fn getElementById(self: *const Document, id_: ?[]const u8) ?*Element { const GetElementsByTagNameResult = union(enum) { tag: collections.NodeLive(.tag), tag_name: collections.NodeLive(.tag_name), + all_elements: collections.NodeLive(.all_elements), }; pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult { if (tag_name.len > 256) { @@ -116,23 +141,47 @@ pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) return error.InvalidTagName; } + // Handle wildcard '*' - return all elements + if (std.mem.eql(u8, tag_name, "*")) { + return .{ + .all_elements = collections.NodeLive(.all_elements).init(self.asNode(), {}, page), + }; + } + const lower = std.ascii.lowerString(&page.buf, tag_name); if (Node.Element.Tag.parseForMatch(lower)) |known| { // optimized for known tag names, comparis return .{ - .tag = collections.NodeLive(.tag).init(null, self.asNode(), known, page), + .tag = collections.NodeLive(.tag).init(self.asNode(), known, page), }; } const arena = page.arena; const filter = try String.init(arena, lower, .{}); - return .{ .tag_name = collections.NodeLive(.tag_name).init(arena, self.asNode(), filter, page) }; + return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) }; } pub fn getElementsByClassName(self: *Document, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) { const arena = page.arena; - const filter = try arena.dupe(u8, class_name); - return collections.NodeLive(.class_name).init(arena, self.asNode(), filter, page); + + // Parse space-separated class names + var class_names: std.ArrayList([]const u8) = .empty; + var it = std.mem.tokenizeAny(u8, class_name, &std.ascii.whitespace); + while (it.next()) |name| { + try class_names.append(arena, try page.dupeString(name)); + } + + return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page); +} + +pub fn getElementsByName(self: *Document, name: []const u8, page: *Page) !collections.NodeLive(.name) { + const arena = page.arena; + const filter = try arena.dupe(u8, name); + return collections.NodeLive(.name).init(self.asNode(), filter, page); +} + +pub fn getChildren(self: *Document, page: *Page) !collections.NodeLive(.child_elements) { + return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn getDocumentElement(self: *Document) ?*Element { @@ -285,11 +334,20 @@ pub const JsApi = struct { } pub const URL = bridge.accessor(Document.getURL, null, .{}); + pub const documentURI = bridge.accessor(Document.getURL, null, .{}); pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{}); + pub const children = bridge.accessor(Document.getChildren, null, .{}); pub const readyState = bridge.accessor(Document.getReadyState, null, .{}); pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); + pub const contentType = bridge.accessor(Document.getContentType, null, .{}); + pub const characterSet = bridge.accessor(Document.getCharacterSet, null, .{}); + pub const charset = bridge.accessor(Document.getCharacterSet, null, .{}); + pub const inputEncoding = bridge.accessor(Document.getCharacterSet, null, .{}); + pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{}); + pub const referrer = bridge.accessor(Document.getReferrer, null, .{}); + pub const domain = bridge.accessor(Document.getDomain, null, .{}); pub const createElement = bridge.function(Document.createElement, .{}); pub const createElementNS = bridge.function(Document.createElementNS, .{}); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); @@ -304,6 +362,7 @@ pub const JsApi = struct { pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{}); + pub const getElementsByName = bridge.function(Document.getElementsByName, .{}); pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 6c712f556..5e94f3abe 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -94,7 +94,7 @@ pub fn querySelectorAll(self: *DocumentFragment, input: []const u8, page: *Page) } pub fn getChildren(self: *DocumentFragment, page: *Page) !collections.NodeLive(.child_elements) { - return collections.NodeLive(.child_elements).init(null, self.asNode(), {}, page); + return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn firstElementChild(self: *DocumentFragment) ?*Element { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 758bb1f24..b958f552f 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -133,6 +133,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .data => "data", .dialog => "dialog", .div => "div", + .embed => "embed", .form => "form", .generic => |e| e._tag_name.str(), .heading => |e| e._tag_name.str(), @@ -179,6 +180,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .data => "DATA", .dialog => "DIALOG", .div => "DIV", + .embed => "EMBED", .form => "FORM", .generic => |e| upperTagName(&e._tag_name, buf), .heading => |e| upperTagName(&e._tag_name, buf), @@ -305,12 +307,22 @@ pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]con return attributes.get(name, page); } +pub fn getAttributeSafe(self: *const Element, name: []const u8) ?[]const u8 { + const attributes = self._attributes orelse return null; + return attributes.getSafe(name); +} + pub fn hasAttribute(self: *const Element, name: []const u8, page: *Page) !bool { const attributes = self._attributes orelse return false; const value = try attributes.get(name, page); return value != null; } +pub fn hasAttributeSafe(self: *const Element, name: []const u8) bool { + const attributes = self._attributes orelse return false; + return attributes.hasSafe(name); +} + pub fn hasAttributes(self: *const Element) bool { const attributes = self._attributes orelse return false; return attributes.isEmpty() == false; @@ -321,11 +333,6 @@ pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attrib return attributes.getAttribute(name, self, page); } -pub fn getAttributeSafe(self: *const Element, name: []const u8) ?[]const u8 { - const attributes = self._attributes orelse return null; - return attributes.getSafe(name); -} - pub fn setAttribute(self: *Element, name: []const u8, value: []const u8, page: *Page) !void { const attributes = try self.getOrCreateAttributeList(page); _ = try attributes.put(name, value, self, page); @@ -506,7 +513,7 @@ pub fn blur(self: *Element, page: *Page) !void { } pub fn getChildren(self: *Element, page: *Page) !collections.NodeLive(.child_elements) { - return collections.NodeLive(.child_elements).init(null, self.asNode(), {}, page); + return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn append(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { @@ -750,19 +757,26 @@ pub fn getElementsByTagName(self: *Element, tag_name: []const u8, page: *Page) ! if (Tag.parseForMatch(lower)) |known| { // optimized for known tag names return .{ - .tag = collections.NodeLive(.tag).init(null, self.asNode(), known, page), + .tag = collections.NodeLive(.tag).init(self.asNode(), known, page), }; } const arena = page.arena; const filter = try String.init(arena, lower, .{}); - return .{ .tag_name = collections.NodeLive(.tag_name).init(arena, self.asNode(), filter, page) }; + return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) }; } pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) { const arena = page.arena; - const filter = try arena.dupe(u8, class_name); - return collections.NodeLive(.class_name).init(arena, self.asNode(), filter, page); + + // Parse space-separated class names + var class_names: std.ArrayList([]const u8) = .empty; + var it = std.mem.tokenizeAny(u8, class_name, &std.ascii.whitespace); + while (it.next()) |name| { + try class_names.append(arena, name); + } + + return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page); } pub fn cloneElement(self: *Element, deep: bool, page: *Page) !*Node { @@ -819,6 +833,7 @@ pub fn getTag(self: *const Element) Tag { .html => |he| switch (he._type) { .anchor => .anchor, .div => .div, + .embed => .embed, .form => .form, .p => .p, .custom => .custom, @@ -868,6 +883,7 @@ pub const Tag = enum { data, dialog, div, + embed, ellipse, em, form, diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 5e22ecff1..16914b7bb 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -18,6 +18,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const String = @import("../../string.zig").String; const Page = @import("../Page.zig"); const Node = @import("Node.zig"); @@ -91,22 +92,40 @@ pub fn setTitle(self: *HTMLDocument, title: []const u8, page: *Page) !void { return title_element.asElement().replaceChildren(&.{.{ .text = title }}, page); } } + + const title_node = try page.createElement(null, "title", null); + const title_element = title_node.as(Element); + try title_element.replaceChildren(&.{.{ .text = title }}, page); + _ = try head.asNode().appendChild(title_node, page); } pub fn getImages(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { - return collections.NodeLive(.tag).init(null, self.asNode(), .img, page); + return collections.NodeLive(.tag).init(self.asNode(), .img, page); } pub fn getScripts(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { - return collections.NodeLive(.tag).init(null, self.asNode(), .script, page); + return collections.NodeLive(.tag).init(self.asNode(), .script, page); +} + +pub fn getLinks(self: *HTMLDocument, page: *Page) !collections.NodeLive(.links) { + return collections.NodeLive(.links).init(self.asNode(), {}, page); } -pub fn getLinks(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { - return collections.NodeLive(.tag).init(null, self.asNode(), .anchor, page); +pub fn getAnchors(self: *HTMLDocument, page: *Page) !collections.NodeLive(.anchors) { + return collections.NodeLive(.anchors).init(self.asNode(), {}, page); } pub fn getForms(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { - return collections.NodeLive(.tag).init(null, self.asNode(), .form, page); + return collections.NodeLive(.tag).init(self.asNode(), .form, page); +} + +pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { + return collections.NodeLive(.tag).init(self.asNode(), .embed, page); +} + +const applet_string = String.init(undefined, "applet", .{}) catch unreachable; +pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) { + return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page); } pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script { @@ -143,7 +162,11 @@ pub const JsApi = struct { pub const images = bridge.accessor(HTMLDocument.getImages, null, .{}); pub const scripts = bridge.accessor(HTMLDocument.getScripts, null, .{}); pub const links = bridge.accessor(HTMLDocument.getLinks, null, .{}); + pub const anchors = bridge.accessor(HTMLDocument.getAnchors, null, .{}); pub const forms = bridge.accessor(HTMLDocument.getForms, null, .{}); + pub const embeds = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); + pub const applets = bridge.accessor(HTMLDocument.getApplets, null, .{}); + pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{}); pub const location = bridge.accessor(HTMLDocument.getLocation, null, .{ .cache = "location" }); pub const all = bridge.accessor(HTMLDocument.getAll, null, .{}); diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 54c99bffa..3160524da 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -28,9 +28,13 @@ const Mode = enum { tag, tag_name, class_name, + name, + all_elements, child_elements, child_tag, selected_options, + links, + anchors, }; const HTMLCollection = @This(); @@ -39,9 +43,13 @@ data: union(Mode) { tag: NodeLive(.tag), tag_name: NodeLive(.tag_name), class_name: NodeLive(.class_name), + name: NodeLive(.name), + all_elements: NodeLive(.all_elements), child_elements: NodeLive(.child_elements), child_tag: NodeLive(.child_tag), selected_options: NodeLive(.selected_options), + links: NodeLive(.links), + anchors: NodeLive(.anchors), }, pub fn length(self: *HTMLCollection, page: *const Page) u32 { @@ -69,9 +77,13 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .tag => |*impl| .{ .tag = impl._tw.clone() }, .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, + .name => |*impl| .{ .name = impl._tw.clone() }, + .all_elements => |*impl| .{ .all_elements = impl._tw.clone() }, .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, + .links => |*impl| .{ .links = impl._tw.clone() }, + .anchors => |*impl| .{ .anchors = impl._tw.clone() }, }, }, page); } @@ -83,9 +95,13 @@ pub const Iterator = GenericIterator(struct { tag: TreeWalker.FullExcludeSelf, tag_name: TreeWalker.FullExcludeSelf, class_name: TreeWalker.FullExcludeSelf, + name: TreeWalker.FullExcludeSelf, + all_elements: TreeWalker.FullExcludeSelf, child_elements: TreeWalker.Children, child_tag: TreeWalker.Children, selected_options: TreeWalker.Children, + links: TreeWalker.FullExcludeSelf, + anchors: TreeWalker.FullExcludeSelf, }, pub fn next(self: *@This(), _: *Page) ?*Element { @@ -93,9 +109,13 @@ pub const Iterator = GenericIterator(struct { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .class_name => |*impl| impl.nextTw(&self.tw.class_name), + .name => |*impl| impl.nextTw(&self.tw.name), + .all_elements => |*impl| impl.nextTw(&self.tw.all_elements), .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), + .links => |*impl| impl.nextTw(&self.tw.links), + .anchors => |*impl| impl.nextTw(&self.tw.anchors), }; } }, null); diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index ee123b4c4..f3f4dd1a1 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -35,18 +35,26 @@ const Mode = enum { tag, tag_name, class_name, + name, + all_elements, child_elements, child_tag, selected_options, + links, + anchors, }; const Filters = union(Mode) { tag: Element.Tag, tag_name: String, - class_name: []const u8, + class_name: [][]const u8, + name: []const u8, + all_elements, child_elements, child_tag: Element.Tag, selected_options, + links, + anchors, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -74,7 +82,7 @@ const Filters = union(Mode) { pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { - .tag, .tag_name, .class_name => TreeWalker.FullExcludeSelf, + .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors => TreeWalker.FullExcludeSelf, .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { @@ -83,16 +91,11 @@ pub fn NodeLive(comptime mode: Mode) type { _last_index: usize, _last_length: ?u32, _cached_version: usize, - // NodeLive doesn't use an arena directly, but the filter might have - // used it (to own the string). So we take ownership of the arena so that - // we can free it when we're freed.s - _arena: ?Allocator, const Self = @This(); - pub fn init(arena: ?Allocator, root: *Node, filter: Filter, page: *Page) Self { + pub fn init(root: *Node, filter: Filter, page: *Page) Self { return .{ - ._arena = arena, ._last_index = 0, ._last_length = null, ._filter = filter, @@ -212,10 +215,25 @@ pub fn NodeLive(comptime mode: Mode) type { return std.mem.eql(u8, element_tag, self._filter.str()); }, .class_name => { + if (self._filter.len == 0) { + return false; + } + const el = node.is(Element) orelse return false; const class_attr = el.getAttributeSafe("class") orelse return false; - return Selector.classAttributeContains(class_attr, self._filter); + for (self._filter) |class_name| { + if (!Selector.classAttributeContains(class_attr, class_name)) { + return false; + } + } + return true; + }, + .name => { + const el = node.is(Element) orelse return false; + const name_attr = el.getAttributeSafe("name") orelse return false; + return std.mem.eql(u8, name_attr, self._filter); }, + .all_elements => return node._type == .element, .child_elements => return node._type == .element, .child_tag => { const el = node.is(Element) orelse return false; @@ -227,6 +245,20 @@ pub fn NodeLive(comptime mode: Mode) type { const opt = el.is(Option) orelse return false; return opt.getSelected(); }, + .links => { + // Links are elements with href attribute (TODO: also when implemented) + const el = node.is(Element) orelse return false; + const Anchor = Element.Html.Anchor; + if (el.is(Anchor) == null) return false; + return el.hasAttributeSafe("href"); + }, + .anchors => { + // Anchors are elements with name attribute + const el = node.is(Element) orelse return false; + const Anchor = Element.Html.Anchor; + if (el.is(Anchor) == null) return false; + return el.hasAttributeSafe("name"); + }, } } @@ -249,9 +281,13 @@ pub fn NodeLive(comptime mode: Mode) type { .tag => HTMLCollection{ .data = .{ .tag = self } }, .tag_name => HTMLCollection{ .data = .{ .tag_name = self } }, .class_name => HTMLCollection{ .data = .{ .class_name = self } }, + .name => HTMLCollection{ .data = .{ .name = self } }, + .all_elements => HTMLCollection{ .data = .{ .all_elements = self } }, .child_elements => HTMLCollection{ .data = .{ .child_elements = self } }, .child_tag => HTMLCollection{ .data = .{ .child_tag = self } }, .selected_options => HTMLCollection{ .data = .{ .selected_options = self } }, + .links => HTMLCollection{ .data = .{ .links = self } }, + .anchors => HTMLCollection{ .data = .{ .anchors = self } }, }; return page._factory.create(collection); } diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 1919a24e6..b5d45a612 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -135,6 +135,11 @@ pub const List = struct { return entry._value.str(); } + // meant for internal usage, where the name is known to be properly cased + pub fn hasSafe(self: *const List, name: []const u8) bool { + return self.getEntryWithNormalizedName(name) != null; + } + pub fn getAttribute(self: *const List, name: []const u8, element: ?*Element, page: *Page) !?*Attribute { const entry = (try self.getEntry(name, page)) orelse return null; const gop = try page._attribute_lookup.getOrPut(page.arena, @intFromPtr(entry)); @@ -184,6 +189,7 @@ pub const List = struct { }; try page.addElementId(parent, element, entry._value.str()); } + page.domChanged(); page.attributeChange(element, result.normalized, entry._value.str(), old_value); return entry; } @@ -242,6 +248,7 @@ pub const List = struct { page.removeElementId(element, entry._value.str()); } + page.domChanged(); page.attributeRemove(element, result.normalized, old_value); _ = page._attribute_lookup.remove(@intFromPtr(entry)); self._list.remove(&entry._node); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index cefdaf659..341fbc949 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -23,38 +23,39 @@ const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); -pub const BR = @import("html/BR.zig"); -pub const HR = @import("html/HR.zig"); -pub const LI = @import("html/LI.zig"); -pub const OL = @import("html/OL.zig"); -pub const UL = @import("html/UL.zig"); -pub const Div = @import("html/Div.zig"); -pub const Html = @import("html/Html.zig"); -pub const Head = @import("html/Head.zig"); -pub const Meta = @import("html/Meta.zig"); -pub const Body = @import("html/Body.zig"); -pub const Link = @import("html/Link.zig"); -pub const Image = @import("html/Image.zig"); -pub const Input = @import("html/Input.zig"); -pub const Title = @import("html/Title.zig"); -pub const Style = @import("html/Style.zig"); -pub const Custom = @import("html/Custom.zig"); -pub const Script = @import("html/Script.zig"); pub const Anchor = @import("html/Anchor.zig"); +pub const Body = @import("html/Body.zig"); +pub const BR = @import("html/BR.zig"); pub const Button = @import("html/Button.zig"); +pub const Custom = @import("html/Custom.zig"); pub const Data = @import("html/Data.zig"); pub const Dialog = @import("html/Dialog.zig"); +pub const Div = @import("html/Div.zig"); +pub const Embed = @import("html/Embed.zig"); pub const Form = @import("html/Form.zig"); -pub const Heading = @import("html/Heading.zig"); -pub const Unknown = @import("html/Unknown.zig"); pub const Generic = @import("html/Generic.zig"); -pub const Template = @import("html/Template.zig"); -pub const TextArea = @import("html/TextArea.zig"); +pub const Head = @import("html/Head.zig"); +pub const Heading = @import("html/Heading.zig"); +pub const HR = @import("html/HR.zig"); +pub const Html = @import("html/Html.zig"); +pub const IFrame = @import("html/IFrame.zig"); +pub const Image = @import("html/Image.zig"); +pub const Input = @import("html/Input.zig"); +pub const LI = @import("html/LI.zig"); +pub const Link = @import("html/Link.zig"); +pub const Meta = @import("html/Meta.zig"); +pub const OL = @import("html/OL.zig"); +pub const Option = @import("html/Option.zig"); pub const Paragraph = @import("html/Paragraph.zig"); +pub const Script = @import("html/Script.zig"); pub const Select = @import("html/Select.zig"); pub const Slot = @import("html/Slot.zig"); -pub const Option = @import("html/Option.zig"); -pub const IFrame = @import("html/IFrame.zig"); +pub const Style = @import("html/Style.zig"); +pub const Template = @import("html/Template.zig"); +pub const TextArea = @import("html/TextArea.zig"); +pub const Title = @import("html/Title.zig"); +pub const UL = @import("html/UL.zig"); +pub const Unknown = @import("html/Unknown.zig"); const HtmlElement = @This(); @@ -76,6 +77,7 @@ pub const Type = union(enum) { data: *Data, dialog: *Dialog, div: *Div, + embed: *Embed, form: *Form, generic: *Generic, heading: *Heading, @@ -120,6 +122,7 @@ pub fn className(self: *const HtmlElement) []const u8 { return switch (self._type) { .anchor => "[object HtmlAnchorElement]", .div => "[object HtmlDivElement]", + .embed => "[object HtmlEmbedElement]", .form => "[object HTMLFormElement]", .p => "[object HtmlParagraphElement]", .custom => "[object CUSTOM-TODO]", diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 47cbe9d4a..d6b85c46f 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -31,6 +31,9 @@ _proto: *HtmlElement, pub fn asElement(self: *Anchor) *Element { return self._proto._proto; } +pub fn asConstElement(self: *const Anchor) *const Element { + return self._proto._proto; +} pub fn asNode(self: *Anchor) *Node { return self.asElement().asNode(); } @@ -193,6 +196,14 @@ pub fn setType(self: *Anchor, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe("type", value, page); } +pub fn getName(self: *const Anchor) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Anchor, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", value, page); +} + pub fn getText(self: *Anchor, page: *Page) ![:0]const u8 { return self.asNode().getTextContentAlloc(page.call_arena); } @@ -235,6 +246,7 @@ pub const JsApi = struct { pub const href = bridge.accessor(Anchor.getHref, Anchor.setHref, .{}); pub const target = bridge.accessor(Anchor.getTarget, Anchor.setTarget, .{}); + pub const name = bridge.accessor(Anchor.getName, Anchor.setName, .{}); pub const origin = bridge.accessor(Anchor.getOrigin, null, .{}); pub const host = bridge.accessor(Anchor.getHost, Anchor.setHost, .{}); pub const hostname = bridge.accessor(Anchor.getHostname, Anchor.setHostname, .{}); diff --git a/src/browser/webapi/element/html/Embed.zig b/src/browser/webapi/element/html/Embed.zig new file mode 100644 index 000000000..a3292cb10 --- /dev/null +++ b/src/browser/webapi/element/html/Embed.zig @@ -0,0 +1,42 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); + +const Embed = @This(); +_proto: *HtmlElement, + +pub fn asElement(self: *Embed) *Element { + return self._proto._proto; +} +pub fn asNode(self: *Embed) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Embed); + + pub const Meta = struct { + pub const name = "HTMLEmbedElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 5b3c0b9ec..8a5ef4d25 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -185,7 +185,7 @@ pub fn setRequired(self: *Select, required: bool, page: *Page) !void { pub fn getOptions(self: *Select, page: *Page) !*collections.HTMLOptionsCollection { // For options, we use the child_tag mode to filter only
+

Hello World

+

Hello + World

diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index a22a0d6cd..cb17aaad5 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -71,7 +71,8 @@ pub const RenderOpts = struct { }; // Replace successives whitespaces with one withespace. // Trims left and right according to the options. -pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !void { +// Returns true if the string ends with a trimmed whitespace. +pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !bool { var start: usize = 0; var prev_w: ?bool = null; var is_w: bool = undefined; @@ -110,11 +111,15 @@ pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !voi // If the string contains only whitespaces, don't write it. if (start > 0 and opts.trim_right == false) { try writer.writeByte(' '); + } else { + return true; } } else { // last chunk is non whitespaces. try writer.writeAll(s[start..]); } + + return false; } pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { @@ -288,19 +293,20 @@ test "WebApi: CData.render" { const TestCase = struct { value: []const u8, expected: []const u8, + result: bool = false, opts: RenderOpts = .{}, }; const test_cases = [_]TestCase{ - .{ .value = " ", .expected = "" }, - .{ .value = " ", .expected = "", .opts = .{ .trim_left = false, .trim_right = false } }, + .{ .value = " ", .expected = "", .result = true }, + .{ .value = " ", .expected = "", .opts = .{ .trim_left = false, .trim_right = false }, .result = true }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = " foo bar", .expected = "foo bar" }, - .{ .value = "foo bar ", .expected = "foo bar" }, - .{ .value = " foo bar ", .expected = "foo bar" }, + .{ .value = "foo bar ", .expected = "foo bar", .result = true }, + .{ .value = " foo bar ", .expected = "foo bar", .result = true }, .{ .value = "foo\n\tbar", .expected = "foo bar" }, - .{ .value = "\tfoo bar baz \t\n yeah\r\n", .expected = "foo bar baz yeah" }, + .{ .value = "\tfoo bar baz \t\n yeah\r\n", .expected = "foo bar baz yeah", .result = true }, .{ .value = " foo bar", .expected = " foo bar", .opts = .{ .trim_left = false } }, .{ .value = "foo bar ", .expected = "foo bar ", .opts = .{ .trim_right = false } }, .{ .value = " foo bar ", .expected = " foo bar ", .opts = .{ .trim_left = false, .trim_right = false } }, @@ -317,8 +323,9 @@ test "WebApi: CData.render" { ._data = test_case.value, }; - try cdata.render(&buffer.writer, test_case.opts); + const result = try cdata.render(&buffer.writer, test_case.opts); try std.testing.expectEqualStrings(test_case.expected, buffer.written()); + try std.testing.expect(result == test_case.result); } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 915600107..bf37dfd8b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -228,21 +228,43 @@ pub fn getNamespaceURI(self: *const Element) []const u8 { // innerText represents the **rendered** text content of a node and its // descendants. pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { + var state = innerTextState{}; + return try self._getInnerText(writer, &state); +} +const innerTextState = struct { + pre_w: bool = false, + trim_left: bool = true, +}; +fn _getInnerText(self: *Element, writer: *std.Io.Writer, state: *innerTextState) !void { var it = self.asNode().childrenIterator(); while (it.next()) |child| { switch (child._type) { .element => |e| switch (e._type) { .html => |he| switch (he._type) { - .br => try writer.writeByte('\n'), - .script, .style, .template => continue, - else => try e.getInnerText(writer), // TODO check if elt is hidden. + .br => { + try writer.writeByte('\n'); + state.pre_w = false; // prevent a next pre space. + state.trim_left = true; + }, + .script, .style, .template => { + state.pre_w = false; // prevent a next pre space. + state.trim_left = true; + }, + else => try e._getInnerText(writer, state), // TODO check if elt is hidden. }, .svg => {}, }, .cdata => |c| switch (c._type) { - .comment => continue, - .text => try c.render(writer, .{ .trim_right = false, .trim_left = false }), - .cdata_section => try writer.writeAll(c._data), + .comment => { + state.pre_w = false; // prevent a next pre space. + state.trim_left = true; + }, + .text => { + if (state.pre_w) try writer.writeByte(' '); + state.pre_w = try c.render(writer, .{ .trim_left = state.trim_left }); + // if we had a pre space, trim left next one. + state.trim_left = state.pre_w; + }, }, .document => {}, .document_type => {}, From 3538c77b7851d3d47867de4721ffb6565f628166 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 8 Dec 2025 17:45:53 +0100 Subject: [PATCH 166/257] innerText: ignore CDATA section --- src/browser/webapi/Element.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index bf37dfd8b..f8ab2501d 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -265,6 +265,9 @@ fn _getInnerText(self: *Element, writer: *std.Io.Writer, state: *innerTextState) // if we had a pre space, trim left next one. state.trim_left = state.pre_w; }, + // CDATA sections should not be used within HTML. They are + // considered comments and are not displayed. + .cdata_section => {}, }, .document => {}, .document_type => {}, From 9132bc23757512e774aa6185ccdcc68fee208c18 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 9 Dec 2025 11:50:33 +0800 Subject: [PATCH 167/257] re-enable CDP node registry --- src/browser/Page.zig | 2 +- src/browser/tests/cdp/registry1.html | 1 + src/browser/tests/cdp/registry2.html | 1 + src/browser/tests/cdp/registry3.html | 1 + src/browser/webapi/Element.zig | 20 +- src/browser/webapi/Navigator.zig | 1 - src/browser/webapi/Node.zig | 21 +- src/browser/webapi/NodeFilter.zig | 2 +- src/browser/webapi/net/Fetch.zig | 1 - src/cdp/Node.zig | 1153 +++++++++++++------------- src/cdp/cdp.zig | 49 +- src/cdp/testing.zig | 3 +- src/testing.zig | 22 + 13 files changed, 653 insertions(+), 624 deletions(-) create mode 100644 src/browser/tests/cdp/registry1.html create mode 100644 src/browser/tests/cdp/registry2.html create mode 100644 src/browser/tests/cdp/registry3.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index fb70f9d8c..9bfd17cfb 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1276,7 +1276,7 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac const node = element.asNode(); if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { @call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| { - log.err(.page, "build.created", .{ .tag = node.getNodeName(self), .err = err }); + log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err }); return err; }; } diff --git a/src/browser/tests/cdp/registry1.html b/src/browser/tests/cdp/registry1.html new file mode 100644 index 000000000..b603ad249 --- /dev/null +++ b/src/browser/tests/cdp/registry1.html @@ -0,0 +1 @@ +
link1

other

diff --git a/src/browser/tests/cdp/registry2.html b/src/browser/tests/cdp/registry2.html new file mode 100644 index 000000000..136680aa7 --- /dev/null +++ b/src/browser/tests/cdp/registry2.html @@ -0,0 +1 @@ + diff --git a/src/browser/tests/cdp/registry3.html b/src/browser/tests/cdp/registry3.html new file mode 100644 index 000000000..a5b16fc5e --- /dev/null +++ b/src/browser/tests/cdp/registry3.html @@ -0,0 +1 @@ +
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index ecaf35b95..cd6b003ea 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -225,6 +225,15 @@ pub fn getNamespaceURI(self: *const Element) []const u8 { return self._namespace.toUri(); } +pub fn getLocalName(self: *Element) []const u8 { + const name = self.getTagNameLower(); + if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { + return name[pos + 1 ..]; + } + + return name; +} + // innerText represents the **rendered** text content of a node and its // descendants. pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { @@ -1091,16 +1100,7 @@ pub const JsApi = struct { return null; } - pub const localName = bridge.accessor(_localName, null, .{}); - fn _localName(self: *Element) []const u8 { - const name = self.getTagNameLower(); - if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { - return name[pos + 1 ..]; - } - - return name; - } - + pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, null, .{}); diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 3fa8154f1..23efd49f6 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -98,7 +98,6 @@ pub const JsApi = struct { pub const name = "Navigator"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - // ZIGDOM (currently no optimization for empty types) pub const empty_with_no_proto = true; }; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 67245cb34..619518f74 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -256,9 +256,9 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { } } -pub fn getNodeName(self: *const Node, page: *Page) []const u8 { +pub fn getNodeName(self: *const Node, buf: []u8) []const u8 { return switch (self._type) { - .element => |el| el.getTagNameSpec(&page.buf), + .element => |el| el.getTagNameSpec(buf), .cdata => |cd| switch (cd._type) { .text => "#text", .cdata_section => "#cdata-section", @@ -271,7 +271,7 @@ pub fn getNodeName(self: *const Node, page: *Page) []const u8 { }; } -pub fn nodeType(self: *const Node) u8 { +pub fn getNodeType(self: *const Node) u8 { return switch (self._type) { .element => 1, .attribute => 2, @@ -491,6 +491,13 @@ pub fn childrenIterator(self: *Node) NodeIterator { }; } +pub fn getChildrenCount(self: *Node) usize { + return switch (self._type) { + .element, .document, .document_fragment => self.getLength(), + .document_type, .attribute, .cdata => return 0, + }; +} + pub fn getLength(self: *Node) u32 { switch (self._type) { .cdata => |cdata| { @@ -770,8 +777,12 @@ pub const JsApi = struct { pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10); pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20); - pub const nodeName = bridge.accessor(Node.getNodeName, null, .{}); - pub const nodeType = bridge.accessor(Node.nodeType, null, .{}); + pub const nodeName = bridge.accessor(struct{ + fn wrap(self: *const Node, page: *Page) []const u8 { + return self.getNodeName(&page.buf); + } + }.wrap, null, .{}); + pub const nodeType = bridge.accessor(Node.getNodeType, null, .{}); pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{}); fn _textContext(self: *Node, page: *const Page) !?[]const u8 { diff --git a/src/browser/webapi/NodeFilter.zig b/src/browser/webapi/NodeFilter.zig index 232355dc5..bdb523a80 100644 --- a/src/browser/webapi/NodeFilter.zig +++ b/src/browser/webapi/NodeFilter.zig @@ -72,7 +72,7 @@ pub fn shouldShow(node: *const Node, what_to_show: u32) bool { // TODO: Test this mapping thoroughly! // nodeType values (1=ELEMENT, 3=TEXT, 9=DOCUMENT, etc.) need to map to // SHOW_* bitmask positions (0x1, 0x4, 0x100, etc.) - const node_type_value = node.nodeType(); + const node_type_value = node.getNodeType(); const bit_position = node_type_value - 1; const node_type_bit: u32 = @as(u32, 1) << @intCast(bit_position); return (what_to_show & node_type_bit) != 0; diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 7f85741fa..9f708e0a2 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -42,7 +42,6 @@ _resolver: js.PersistentPromiseResolver, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; -// @ZIGDOM just enough to get campfire demo working pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index c51093128..ca7c43ca3 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -1,587 +1,592 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) -// +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) + // Francis Bouvier // Pierre Tachoire -// + // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. -// + // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. -// + // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// @ZIGDOM -// const std = @import("std"); -// const Allocator = std.mem.Allocator; - -// const log = @import("../log.zig"); -// const parser = @import("../browser/netsurf.zig"); - -// pub const Id = u32; - -// const Node = @This(); - -// id: Id, -// _node: *parser.Node, -// set_child_nodes_event: bool, - -// // Whenever we send a node to the client, we register it here for future lookup. -// // We maintain a node -> id and id -> node lookup. -// pub const Registry = struct { -// node_id: u32, -// allocator: Allocator, -// arena: std.heap.ArenaAllocator, -// node_pool: std.heap.MemoryPool(Node), -// lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), -// lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage), - -// pub fn init(allocator: Allocator) Registry { -// return .{ -// .node_id = 1, -// .lookup_by_id = .{}, -// .lookup_by_node = .{}, -// .allocator = allocator, -// .arena = std.heap.ArenaAllocator.init(allocator), -// .node_pool = std.heap.MemoryPool(Node).init(allocator), -// }; -// } - -// pub fn deinit(self: *Registry) void { -// const allocator = self.allocator; -// self.lookup_by_id.deinit(allocator); -// self.lookup_by_node.deinit(allocator); -// self.node_pool.deinit(); -// self.arena.deinit(); -// } - -// pub fn reset(self: *Registry) void { -// self.lookup_by_id.clearRetainingCapacity(); -// self.lookup_by_node.clearRetainingCapacity(); -// _ = self.arena.reset(.{ .retain_with_limit = 1024 }); -// _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); -// } - -// pub fn register(self: *Registry, n: *parser.Node) !*Node { -// const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, n); -// if (node_lookup_gop.found_existing) { -// return node_lookup_gop.value_ptr.*; -// } - -// // on error, we're probably going to abort the entire browser context -// // but, just in case, let's try to keep things tidy. -// errdefer _ = self.lookup_by_node.remove(n); - -// const node = try self.node_pool.create(); -// errdefer self.node_pool.destroy(node); - -// const id = self.node_id; -// self.node_id = id + 1; - -// node.* = .{ -// ._node = n, -// .id = id, -// .set_child_nodes_event = false, -// }; - -// node_lookup_gop.value_ptr.* = node; -// try self.lookup_by_id.putNoClobber(self.allocator, id, node); -// return node; -// } -// }; - -// const NodeContext = struct { -// pub fn hash(_: NodeContext, n: *parser.Node) u64 { -// return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(n))); -// } - -// pub fn eql(_: NodeContext, a: *parser.Node, b: *parser.Node) bool { -// return @intFromPtr(a) == @intFromPtr(b); -// } -// }; - -// // Searches are a 3 step process: -// // 1 - Dom.performSearch -// // 2 - Dom.getSearchResults -// // 3 - Dom.discardSearchResults -// // -// // For a given browser context, we can have multiple active searches. I.e. -// // performSearch could be called multiple times without getSearchResults or -// // discardSearchResults being called. We keep these active searches in the -// // browser context's node_search_list, which is a SearchList. Since we don't -// // expect many active searches (mostly just 1), a list is fine to scan through. -// pub const Search = struct { -// name: []const u8, -// node_ids: []const Id, - -// pub const List = struct { -// registry: *Registry, -// search_id: u16 = 0, -// arena: std.heap.ArenaAllocator, -// searches: std.ArrayListUnmanaged(Search) = .{}, - -// pub fn init(allocator: Allocator, registry: *Registry) List { -// return .{ -// .registry = registry, -// .arena = std.heap.ArenaAllocator.init(allocator), -// }; -// } - -// pub fn deinit(self: *List) void { -// self.arena.deinit(); -// } - -// pub fn reset(self: *List) void { -// self.search_id = 0; -// self.searches = .{}; -// _ = self.arena.reset(.{ .retain_with_limit = 4096 }); -// } - -// pub fn create(self: *List, nodes: []const *parser.Node) !Search { -// const id = self.search_id; -// defer self.search_id = id +% 1; - -// const arena = self.arena.allocator(); - -// const name = switch (id) { -// 0 => "0", -// 1 => "1", -// 2 => "2", -// 3 => "3", -// 4 => "4", -// 5 => "5", -// 6 => "6", -// 7 => "7", -// 8 => "8", -// 9 => "9", -// else => try std.fmt.allocPrint(arena, "{d}", .{id}), -// }; - -// var registry = self.registry; -// const node_ids = try arena.alloc(Id, nodes.len); -// for (nodes, node_ids) |node, *node_id| { -// node_id.* = (try registry.register(node)).id; -// } - -// const search = Search{ -// .name = name, -// .node_ids = node_ids, -// }; -// try self.searches.append(arena, search); -// return search; -// } - -// pub fn remove(self: *List, name: []const u8) void { -// for (self.searches.items, 0..) |search, i| { -// if (std.mem.eql(u8, name, search.name)) { -// _ = self.searches.swapRemove(i); -// return; -// } -// } -// } - -// pub fn get(self: *const List, name: []const u8) ?Search { -// for (self.searches.items) |search| { -// if (std.mem.eql(u8, name, search.name)) { -// return search; -// } -// } -// return null; -// } -// }; -// }; - -// // Need a custom writer, because we can't just serialize the node as-is. -// // Sometimes we want to serializ the node without chidren, sometimes with just -// // its direct children, and sometimes the entire tree. -// // (For now, we only support direct children) - -// pub const Writer = struct { -// depth: i32, -// exclude_root: bool, -// root: *const Node, -// registry: *Registry, - -// pub const Opts = struct { -// depth: i32 = 0, -// exclude_root: bool = false, -// }; - -// pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { -// if (self.exclude_root) { -// _ = self.writeChildren(self.root, 1, w) catch |err| { -// log.err(.cdp, "node writeChildren", .{ .err = err }); -// return error.WriteFailed; -// }; -// } else { -// self.toJSON(self.root, 0, w) catch |err| { -// // The only error our jsonStringify method can return is -// // @TypeOf(w).Error. In other words, our code can't return its own -// // error, we can only return a writer error. Kinda sucks. -// log.err(.cdp, "node toJSON stringify", .{ .err = err }); -// return error.WriteFailed; -// }; -// } -// } - -// fn toJSON(self: *const Writer, node: *const Node, depth: usize, w: anytype) !void { -// try w.beginObject(); -// try self.writeCommon(node, false, w); - -// try w.objectField("children"); -// const child_count = try self.writeChildren(node, depth, w); -// try w.objectField("childNodeCount"); -// try w.write(child_count); - -// try w.endObject(); -// } - -// fn writeChildren(self: *const Writer, node: *const Node, depth: usize, w: anytype) anyerror!usize { -// var registry = self.registry; -// const child_nodes = try parser.nodeGetChildNodes(node._node); -// const child_count = parser.nodeListLength(child_nodes); -// const full_child = self.depth < 0 or self.depth < depth; - -// var i: usize = 0; -// try w.beginArray(); -// for (0..child_count) |_| { -// const child = (parser.nodeListItem(child_nodes, @intCast(i))) orelse break; -// const child_node = try registry.register(child); -// if (full_child) { -// try self.toJSON(child_node, depth + 1, w); -// } else { -// try w.beginObject(); -// try self.writeCommon(child_node, true, w); -// try w.endObject(); -// } - -// i += 1; -// } -// try w.endArray(); - -// return i; -// } - -// fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void { -// try w.objectField("nodeId"); -// try w.write(node.id); - -// try w.objectField("backendNodeId"); -// try w.write(node.id); - -// const n = node._node; - -// if (parser.nodeParentNode(n)) |p| { -// const parent_node = try self.registry.register(p); -// try w.objectField("parentId"); -// try w.write(parent_node.id); -// } - -// const _map = try parser.nodeGetAttributes(n); -// if (_map) |map| { -// const attr_count = try parser.namedNodeMapGetLength(map); -// try w.objectField("attributes"); -// try w.beginArray(); -// for (0..attr_count) |i| { -// const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue; -// try w.write(try parser.attributeGetName(attr)); -// try w.write(try parser.attributeGetValue(attr) orelse continue); -// } -// try w.endArray(); -// } - -// try w.objectField("nodeType"); -// try w.write(@intFromEnum(parser.nodeType(n))); - -// try w.objectField("nodeName"); -// try w.write(try parser.nodeName(n)); - -// try w.objectField("localName"); -// try w.write(try parser.nodeLocalName(n)); - -// try w.objectField("nodeValue"); -// try w.write((parser.nodeValue(n)) orelse ""); - -// if (include_child_count) { -// try w.objectField("childNodeCount"); -// const child_nodes = try parser.nodeGetChildNodes(n); -// try w.write(parser.nodeListLength(child_nodes)); -// } - -// try w.objectField("documentURL"); -// try w.write(null); - -// try w.objectField("baseURL"); -// try w.write(null); - -// try w.objectField("xmlVersion"); -// try w.write(""); - -// try w.objectField("compatibilityMode"); -// try w.write("NoQuirksMode"); - -// try w.objectField("isScrollable"); -// try w.write(false); -// } -// }; - -// const testing = @import("testing.zig"); -// test "cdp Node: Registry register" { -// parser.init(); -// defer parser.deinit(); - -// var registry = Registry.init(testing.allocator); -// defer registry.deinit(); - -// try testing.expectEqual(0, registry.lookup_by_id.count()); -// try testing.expectEqual(0, registry.lookup_by_node.count()); - -// var doc = try testing.Document.init("link1

other

"); -// defer doc.deinit(); - -// { -// const n = (try doc.querySelector("#a1")).?; -// const node = try registry.register(n); -// const n1b = registry.lookup_by_id.get(1).?; -// const n1c = registry.lookup_by_node.get(node._node).?; -// try testing.expectEqual(node, n1b); -// try testing.expectEqual(node, n1c); - -// try testing.expectEqual(1, node.id); -// try testing.expectEqual(n, node._node); -// } - -// { -// const n = (try doc.querySelector("p")).?; -// const node = try registry.register(n); -// const n1b = registry.lookup_by_id.get(2).?; -// const n1c = registry.lookup_by_node.get(node._node).?; -// try testing.expectEqual(node, n1b); -// try testing.expectEqual(node, n1c); - -// try testing.expectEqual(2, node.id); -// try testing.expectEqual(n, node._node); -// } -// } - -// test "cdp Node: search list" { -// parser.init(); -// defer parser.deinit(); - -// var registry = Registry.init(testing.allocator); -// defer registry.deinit(); - -// var search_list = Search.List.init(testing.allocator, ®istry); -// defer search_list.deinit(); - -// { -// // empty search list, noops -// search_list.remove("0"); -// try testing.expectEqual(null, search_list.get("0")); -// } - -// { -// // empty nodes -// const s1 = try search_list.create(&.{}); -// try testing.expectEqual("0", s1.name); -// try testing.expectEqual(0, s1.node_ids.len); - -// const s2 = search_list.get("0").?; -// try testing.expectEqual("0", s2.name); -// try testing.expectEqual(0, s2.node_ids.len); - -// search_list.remove("0"); -// try testing.expectEqual(null, search_list.get("0")); -// } - -// { -// var doc = try testing.Document.init(""); -// defer doc.deinit(); - -// const s1 = try search_list.create(try doc.querySelectorAll("a")); -// try testing.expectEqual("1", s1.name); -// try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); - -// try testing.expectEqual(2, registry.lookup_by_id.count()); -// try testing.expectEqual(2, registry.lookup_by_node.count()); - -// const s2 = try search_list.create(try doc.querySelectorAll("#a1")); -// try testing.expectEqual("2", s2.name); -// try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); - -// const s3 = try search_list.create(try doc.querySelectorAll("#a2")); -// try testing.expectEqual("3", s3.name); -// try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); - -// try testing.expectEqual(2, registry.lookup_by_id.count()); -// try testing.expectEqual(2, registry.lookup_by_node.count()); -// } -// } - -// test "cdp Node: Writer" { -// parser.init(); -// defer parser.deinit(); - -// var registry = Registry.init(testing.allocator); -// defer registry.deinit(); - -// var doc = try testing.Document.init("
"); -// defer doc.deinit(); - -// { -// const node = try registry.register(doc.asNode()); -// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ -// .root = node, -// .depth = 0, -// .exclude_root = false, -// .registry = ®istry, -// }, .{}); -// defer testing.allocator.free(json); - -// try testing.expectJson(.{ -// .nodeId = 1, -// .backendNodeId = 1, -// .nodeType = 9, -// .nodeName = "#document", -// .localName = "", -// .nodeValue = "", -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .isScrollable = false, -// .compatibilityMode = "NoQuirksMode", -// .childNodeCount = 1, -// .children = &.{.{ -// .nodeId = 2, -// .backendNodeId = 2, -// .nodeType = 1, -// .nodeName = "HTML", -// .localName = "html", -// .nodeValue = "", -// .childNodeCount = 2, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// }}, -// }, json); -// } - -// { -// const node = registry.lookup_by_id.get(2).?; -// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ -// .root = node, -// .depth = 1, -// .exclude_root = false, -// .registry = ®istry, -// }, .{}); -// defer testing.allocator.free(json); - -// try testing.expectJson(.{ -// .nodeId = 2, -// .backendNodeId = 2, -// .nodeType = 1, -// .nodeName = "HTML", -// .localName = "html", -// .nodeValue = "", -// .childNodeCount = 2, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// .children = &.{ .{ -// .nodeId = 3, -// .backendNodeId = 3, -// .nodeType = 1, -// .nodeName = "HEAD", -// .localName = "head", -// .nodeValue = "", -// .childNodeCount = 0, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// .parentId = 2, -// }, .{ -// .nodeId = 4, -// .backendNodeId = 4, -// .nodeType = 1, -// .nodeName = "BODY", -// .localName = "body", -// .nodeValue = "", -// .childNodeCount = 2, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// .parentId = 2, -// } }, -// }, json); -// } - -// { -// const node = registry.lookup_by_id.get(2).?; -// const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ -// .root = node, -// .depth = -1, -// .exclude_root = true, -// .registry = ®istry, -// }, .{}); -// defer testing.allocator.free(json); - -// try testing.expectJson(&.{ .{ -// .nodeId = 3, -// .backendNodeId = 3, -// .nodeType = 1, -// .nodeName = "HEAD", -// .localName = "head", -// .nodeValue = "", -// .childNodeCount = 0, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// .parentId = 2, -// }, .{ -// .nodeId = 4, -// .backendNodeId = 4, -// .nodeType = 1, -// .nodeName = "BODY", -// .localName = "body", -// .nodeValue = "", -// .childNodeCount = 2, -// .documentURL = null, -// .baseURL = null, -// .xmlVersion = "", -// .compatibilityMode = "NoQuirksMode", -// .isScrollable = false, -// .children = &.{ .{ -// .nodeId = 5, -// .localName = "a", -// .childNodeCount = 0, -// .parentId = 4, -// }, .{ -// .nodeId = 6, -// .localName = "div", -// .childNodeCount = 1, -// .parentId = 4, -// .children = &.{.{ -// .nodeId = 7, -// .localName = "a", -// .childNodeCount = 0, -// .parentId = 6, -// }}, -// } }, -// } }, json); -// } -// } +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const log = @import("../log.zig"); +const Page = @import("../browser/Page.zig"); +const DOMNode = @import("../browser/webapi/Node.zig"); + +pub const Id = u32; + +const Node = @This(); + +id: Id, +dom: *DOMNode, +set_child_nodes_event: bool, + +// Whenever we send a node to the client, we register it here for future lookup. +// We maintain a node -> id and id -> node lookup. +pub const Registry = struct { + node_id: u32, + allocator: Allocator, + arena: std.heap.ArenaAllocator, + node_pool: std.heap.MemoryPool(Node), + lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), + lookup_by_node: std.HashMapUnmanaged(*DOMNode, *Node, NodeContext, std.hash_map.default_max_load_percentage), + + pub fn init(allocator: Allocator) Registry { + return .{ + .node_id = 1, + .lookup_by_id = .{}, + .lookup_by_node = .{}, + .allocator = allocator, + .arena = std.heap.ArenaAllocator.init(allocator), + .node_pool = std.heap.MemoryPool(Node).init(allocator), + }; + } + + pub fn deinit(self: *Registry) void { + const allocator = self.allocator; + self.lookup_by_id.deinit(allocator); + self.lookup_by_node.deinit(allocator); + self.node_pool.deinit(); + self.arena.deinit(); + } + + pub fn reset(self: *Registry) void { + self.lookup_by_id.clearRetainingCapacity(); + self.lookup_by_node.clearRetainingCapacity(); + _ = self.arena.reset(.{ .retain_with_limit = 1024 }); + _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); + } + + pub fn register(self: *Registry, dom_node: *DOMNode) !*Node { + const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, dom_node); + if (node_lookup_gop.found_existing) { + return node_lookup_gop.value_ptr.*; + } + + // on error, we're probably going to abort the entire browser context + // but, just in case, let's try to keep things tidy. + errdefer _ = self.lookup_by_node.remove(dom_node); + + const node = try self.node_pool.create(); + errdefer self.node_pool.destroy(node); + + const id = self.node_id; + self.node_id = id + 1; + + node.* = .{ + .id = id, + .dom = dom_node, + .set_child_nodes_event = false, + }; + + node_lookup_gop.value_ptr.* = node; + try self.lookup_by_id.putNoClobber(self.allocator, id, node); + return node; + } +}; + +const NodeContext = struct { + pub fn hash(_: NodeContext, dom_node: *DOMNode) u64 { + return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(dom_node))); + } + + pub fn eql(_: NodeContext, a: *DOMNode, b: *DOMNode) bool { + return @intFromPtr(a) == @intFromPtr(b); + } +}; + +// Searches are a 3 step process: +// 1 - Dom.performSearch +// 2 - Dom.getSearchResults +// 3 - Dom.discardSearchResults +// +// For a given browser context, we can have multiple active searches. I.e. +// performSearch could be called multiple times without getSearchResults or +// discardSearchResults being called. We keep these active searches in the +// browser context's node_search_list, which is a SearchList. Since we don't +// expect many active searches (mostly just 1), a list is fine to scan through. +pub const Search = struct { + name: []const u8, + node_ids: []const Id, + + pub const List = struct { + search_id: u16 = 0, + registry: *Registry, + arena: std.heap.ArenaAllocator, + searches: std.ArrayListUnmanaged(Search) = .{}, + + pub fn init(allocator: Allocator, registry: *Registry) List { + return .{ + .registry = registry, + .arena = std.heap.ArenaAllocator.init(allocator), + }; + } + + pub fn deinit(self: *List) void { + self.arena.deinit(); + } + + pub fn reset(self: *List) void { + self.search_id = 0; + self.searches = .{}; + _ = self.arena.reset(.{ .retain_with_limit = 4096 }); + } + + pub fn create(self: *List, nodes: []const *DOMNode) !Search { + const id = self.search_id; + defer self.search_id = id +% 1; + + const arena = self.arena.allocator(); + + const name = switch (id) { + 0 => "0", + 1 => "1", + 2 => "2", + 3 => "3", + 4 => "4", + 5 => "5", + 6 => "6", + 7 => "7", + 8 => "8", + 9 => "9", + else => try std.fmt.allocPrint(arena, "{d}", .{id}), + }; + + var registry = self.registry; + const node_ids = try arena.alloc(Id, nodes.len); + for (nodes, node_ids) |node, *node_id| { + node_id.* = (try registry.register(node)).id; + } + + const search = Search{ + .name = name, + .node_ids = node_ids, + }; + try self.searches.append(arena, search); + return search; + } + + pub fn remove(self: *List, name: []const u8) void { + for (self.searches.items, 0..) |search, i| { + if (std.mem.eql(u8, name, search.name)) { + _ = self.searches.swapRemove(i); + return; + } + } + } + + pub fn get(self: *const List, name: []const u8) ?Search { + for (self.searches.items) |search| { + if (std.mem.eql(u8, name, search.name)) { + return search; + } + } + return null; + } + }; +}; + +// Need a custom writer, because we can't just serialize the node as-is. +// Sometimes we want to serializ the node without chidren, sometimes with just +// its direct children, and sometimes the entire tree. +// (For now, we only support direct children) + +pub const Writer = struct { + depth: i32, + exclude_root: bool, + root: *const Node, + registry: *Registry, + + pub const Opts = struct { + depth: i32 = 0, + exclude_root: bool = false, + }; + + pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { + if (self.exclude_root) { + _ = self.writeChildren(self.root, 1, w) catch |err| { + log.err(.cdp, "node writeChildren", .{ .err = err }); + return error.WriteFailed; + }; + } else { + self.toJSON(self.root, 0, w) catch |err| { + // The only error our jsonStringify method can return is + // @TypeOf(w).Error. In other words, our code can't return its own + // error, we can only return a writer error. Kinda sucks. + log.err(.cdp, "node toJSON stringify", .{ .err = err }); + return error.WriteFailed; + }; + } + } + + fn toJSON(self: *const Writer, node: *const Node, depth: usize, w: anytype) !void { + try w.beginObject(); + try self.writeCommon(node, false, w); + + try w.objectField("children"); + const child_count = try self.writeChildren(node, depth, w); + try w.objectField("childNodeCount"); + try w.write(child_count); + + try w.endObject(); + } + + fn writeChildren(self: *const Writer, node: *const Node, depth: usize, w: anytype) anyerror!usize { + var count: usize = 0; + var it = node.dom.childrenIterator(); + + var registry = self.registry; + const full_child = self.depth < 0 or self.depth < depth; + + try w.beginArray(); + while (it.next()) |dom_child| { + const child_node = try registry.register(dom_child); + if (full_child) { + try self.toJSON(child_node, depth + 1, w); + } else { + try w.beginObject(); + try self.writeCommon(child_node, true, w); + try w.endObject(); + } + count += 1; + } + try w.endArray(); + + return count; + } + + fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void { + try w.objectField("nodeId"); + try w.write(node.id); + + try w.objectField("backendNodeId"); + try w.write(node.id); + + const dom_node = node.dom; + + if (dom_node._parent) |dom_parent| { + const parent_node = try self.registry.register(dom_parent); + try w.objectField("parentId"); + try w.write(parent_node.id); + } + + if (dom_node.is(DOMNode.Element)) |element| { + if (element.hasAttributes()) { + try w.objectField("attributes"); + try w.beginArray(); + var it = element.attributeIterator(); + while (it.next()) |attr| { + try w.write(attr._name.str()); + try w.write(attr._value.str()); + } + try w.endArray(); + } + + try w.objectField("localName"); + try w.write(element.getLocalName()); + } else { + try w.objectField("localName"); + try w.write(""); + } + + try w.objectField("nodeType"); + try w.write(dom_node.getNodeType()); + + try w.objectField("nodeName"); + var name_buf: [Page.BUF_SIZE]u8 = undefined; + try w.write(dom_node.getNodeName(&name_buf)); + + try w.objectField("nodeValue"); + try w.write(dom_node.getNodeValue() orelse ""); + + if (include_child_count) { + try w.objectField("childNodeCount"); + try w.write(dom_node.getChildrenCount()); + } + + try w.objectField("documentURL"); + try w.write(null); + + try w.objectField("baseURL"); + try w.write(null); + + try w.objectField("xmlVersion"); + try w.write(""); + + try w.objectField("compatibilityMode"); + try w.write("NoQuirksMode"); + + try w.objectField("isScrollable"); + try w.write(false); + } +}; + +const testing = @import("testing.zig"); +test "cdp Node: Registry register" { + var registry = Registry.init(testing.allocator); + defer registry.deinit(); + + try testing.expectEqual(0, registry.lookup_by_id.count()); + try testing.expectEqual(0, registry.lookup_by_node.count()); + + var page = try testing.pageTest("cdp/registry1.html"); + defer page._session.removePage(); + var doc = page.window._document; + + { + const dom_node = (try doc.querySelector("#a1", page)).?.asNode(); + const node = try registry.register(dom_node); + const n1b = registry.lookup_by_id.get(1).?; + const n1c = registry.lookup_by_node.get(node.dom).?; + try testing.expectEqual(node, n1b); + try testing.expectEqual(node, n1c); + + try testing.expectEqual(1, node.id); + try testing.expectEqual(dom_node, node.dom); + } + + { + const dom_node = (try doc.querySelector("p", page)).?.asNode(); + const node = try registry.register(dom_node); + const n1b = registry.lookup_by_id.get(2).?; + const n1c = registry.lookup_by_node.get(node.dom).?; + try testing.expectEqual(node, n1b); + try testing.expectEqual(node, n1c); + + try testing.expectEqual(2, node.id); + try testing.expectEqual(dom_node, node.dom); + } +} + +test "cdp Node: search list" { + var registry = Registry.init(testing.allocator); + defer registry.deinit(); + + var search_list = Search.List.init(testing.allocator, ®istry); + defer search_list.deinit(); + + { + // empty search list, noops + search_list.remove("0"); + try testing.expectEqual(null, search_list.get("0")); + } + + { + // empty nodes + const s1 = try search_list.create(&.{}); + try testing.expectEqual("0", s1.name); + try testing.expectEqual(0, s1.node_ids.len); + + const s2 = search_list.get("0").?; + try testing.expectEqual("0", s2.name); + try testing.expectEqual(0, s2.node_ids.len); + + search_list.remove("0"); + try testing.expectEqual(null, search_list.get("0")); + } + + { + var page = try testing.pageTest("cdp/registry2.html"); + defer page._session.removePage(); + var doc = page.window._document; + + const s1 = try search_list.create((try doc.querySelectorAll("a", page))._nodes); + try testing.expectEqual("1", s1.name); + try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); + + try testing.expectEqual(2, registry.lookup_by_id.count()); + try testing.expectEqual(2, registry.lookup_by_node.count()); + + const s2 = try search_list.create((try doc.querySelectorAll("#a1", page))._nodes); + try testing.expectEqual("2", s2.name); + try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); + + const s3 = try search_list.create((try doc.querySelectorAll("#a2", page))._nodes); + try testing.expectEqual("3", s3.name); + try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); + + try testing.expectEqual(2, registry.lookup_by_id.count()); + try testing.expectEqual(2, registry.lookup_by_node.count()); + } +} + +test "cdp Node: Writer" { + var registry = Registry.init(testing.allocator); + defer registry.deinit(); + + var page = try testing.pageTest("cdp/registry3.html"); + defer page._session.removePage(); + var doc = page.window._document; + + { + const node = try registry.register(doc.asNode()); + const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ + .root = node, + .depth = 0, + .exclude_root = false, + .registry = ®istry, + }, .{}); + defer testing.allocator.free(json); + + try testing.expectJson(.{ + .nodeId = 1, + .backendNodeId = 1, + .nodeType = 9, + .nodeName = "#document", + .localName = "", + .nodeValue = "", + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .isScrollable = false, + .compatibilityMode = "NoQuirksMode", + .childNodeCount = 1, + .children = &.{.{ + .nodeId = 2, + .backendNodeId = 2, + .nodeType = 1, + .nodeName = "HTML", + .localName = "html", + .nodeValue = "", + .childNodeCount = 2, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + }}, + }, json); + } + + { + const node = registry.lookup_by_id.get(2).?; + const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ + .root = node, + .depth = 1, + .exclude_root = false, + .registry = ®istry, + }, .{}); + defer testing.allocator.free(json); + + try testing.expectJson(.{ + .nodeId = 2, + .backendNodeId = 2, + .nodeType = 1, + .nodeName = "HTML", + .localName = "html", + .nodeValue = "", + .childNodeCount = 2, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .children = &.{ .{ + .nodeId = 3, + .backendNodeId = 3, + .nodeType = 1, + .nodeName = "HEAD", + .localName = "head", + .nodeValue = "", + .childNodeCount = 0, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .parentId = 2, + }, .{ + .nodeId = 4, + .backendNodeId = 4, + .nodeType = 1, + .nodeName = "BODY", + .localName = "body", + .nodeValue = "", + .childNodeCount = 3, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .parentId = 2, + } }, + }, json); + } + + { + const node = registry.lookup_by_id.get(2).?; + const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ + .root = node, + .depth = -1, + .exclude_root = true, + .registry = ®istry, + }, .{}); + defer testing.allocator.free(json); + + try testing.expectJson(&.{ .{ + .nodeId = 3, + .backendNodeId = 3, + .nodeType = 1, + .nodeName = "HEAD", + .localName = "head", + .nodeValue = "", + .childNodeCount = 0, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .parentId = 2, + }, .{ + .nodeId = 4, + .backendNodeId = 4, + .nodeType = 1, + .nodeName = "BODY", + .localName = "body", + .nodeValue = "", + .childNodeCount = 3, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .children = &.{ .{ + .nodeId = 5, + .localName = "a", + .childNodeCount = 0, + .attributes = &.{"id", "a1"}, + .parentId = 4, + }, .{ + .nodeId = 6, + .localName = "div", + .childNodeCount = 1, + .parentId = 4, + .children = &.{.{ + .nodeId = 7, + .localName = "a", + .childNodeCount = 0, + .parentId = 6, + .attributes = &.{"id", "a2"}, + }}, + }, .{ + .nodeId = 8, + .backendNodeId = 8, + .nodeName = "#text", + .localName = "", + .childNodeCount = 0, + .parentId = 4, + .nodeValue = "\n", + } }, + } }, json); + } +} diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index a9b3c9198..a2ce7159e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -287,8 +287,7 @@ pub fn CDPT(comptime TypeProvider: type) type { } pub fn BrowserContext(comptime CDP_T: type) type { - // @ZIGMOD - // const Node = @import("Node.zig"); + const Node = @import("Node.zig"); return struct { id: []const u8, @@ -328,9 +327,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { security_origin: []const u8, page_life_cycle_events: bool, secure_context_type: []const u8, - // @ZIGDOM - // node_registry: Node.Registry, - // node_search_list: Node.Search.List, + node_registry: Node.Registry, + node_search_list: Node.Search.List, inspector: js.Inspector, isolated_worlds: std.ArrayListUnmanaged(IsolatedWorld), @@ -363,9 +361,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { const inspector = try cdp.browser.env.newInspector(arena, self); - // @ZIGDOM - // var registry = Node.Registry.init(allocator); - // errdefer registry.deinit(); + var registry = Node.Registry.init(allocator); + errdefer registry.deinit(); self.* = .{ .id = id, @@ -378,9 +375,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { .secure_context_type = "Secure", // TODO = enum .loader_id = LOADER_ID, .page_life_cycle_events = false, // TODO; Target based value - // @ZIGDOM - // .node_registry = registry, - // .node_search_list = undefined, + .node_registry = registry, + .node_search_list = undefined, .isolated_worlds = .empty, .inspector = inspector, .notification_arena = cdp.notification_arena.allocator(), @@ -388,8 +384,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { .captured_responses = .empty, .log_interceptor = LogInterceptor(Self).init(allocator, self), }; - // ZIGDOM - // self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); + self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); errdefer self.deinit(); try cdp.browser.notification.register(.page_remove, self, onPageRemove); @@ -424,9 +419,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { world.deinit(); } self.isolated_worlds.clearRetainingCapacity(); - // @ZIGDOM - // self.node_registry.deinit(); - // self.node_search_list.deinit(); + self.node_registry.deinit(); + self.node_search_list.deinit(); self.cdp.browser.notification.unregisterAll(self); if (self.http_proxy_changed) { @@ -440,10 +434,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { } pub fn reset(self: *Self) void { - // @ZIGDOM - _ = self; - // self.node_registry.reset(); - // self.node_search_list.reset(); + self.node_registry.reset(); + self.node_search_list.reset(); } pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { @@ -462,15 +454,14 @@ pub fn BrowserContext(comptime CDP_T: type) type { return world; } - // @ZIGDOM - // pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer { - // return .{ - // .root = root, - // .depth = opts.depth, - // .exclude_root = opts.exclude_root, - // .registry = &self.node_registry, - // }; - // } + pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer { + return .{ + .root = root, + .depth = opts.depth, + .exclude_root = opts.exclude_root, + .registry = &self.node_registry, + }; + } pub fn getURL(self: *const Self) ?[:0]const u8 { const page = self.session.currentPage() orelse return null; diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 3912b842f..cefc88232 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -32,8 +32,7 @@ pub const expect = std.testing.expect; pub const expectEqual = base.expectEqual; pub const expectError = base.expectError; pub const expectEqualSlices = base.expectEqualSlices; - -pub const Document = @import("../testing.zig").Document; +pub const pageTest = base.pageTest; const Client = struct { allocator: Allocator, diff --git a/src/testing.zig b/src/testing.zig index 77cc1f00a..b250dc207 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -40,6 +40,7 @@ const App = @import("App.zig"); const js = @import("browser/js/js.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); +const Page = @import("browser/Page.zig"); // Merged std.testing.expectEqual and std.testing.expectString // can be useful when testing fields of an anytype an you don't know @@ -415,6 +416,27 @@ fn runWebApiTest(test_file: [:0]const u8) !void { }; } +// Used by a few CDP tests - wouldn't be sad to see this go. +pub fn pageTest(comptime test_file: []const u8) !*Page { + const page = try test_session.createPage(); + errdefer test_session.removePage(); + + const url = try std.fmt.allocPrintSentinel( + arena_allocator, + "http://127.0.0.1:9582/{s}{s}", + .{ WEB_API_TEST_ROOT, test_file }, + 0, + ); + + try page.navigate(url, .{}); + test_session.fetchWait(2000); + + page._session.browser.runMicrotasks(); + page._session.browser.runMessageLoop(); + + return page; +} + test { std.testing.refAllDecls(@This()); } From 0e1b966dcef4e2c57e07871230ff28d9bdf6f8b5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 9 Dec 2025 13:04:01 +0800 Subject: [PATCH 168/257] re-enable CDP dom domain --- src/browser/js/Inspector.zig | 8 +- src/browser/tests/cdp/dom1.html | 1 + src/browser/tests/cdp/dom2.html | 1 + src/browser/webapi/Element.zig | 2 +- src/cdp/domains/dom.zig | 1269 +++++++++++++++---------------- src/cdp/testing.zig | 25 +- src/testing.zig | 4 - 7 files changed, 653 insertions(+), 657 deletions(-) create mode 100644 src/browser/tests/cdp/dom1.html create mode 100644 src/browser/tests/cdp/dom2.html diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index f04409e87..04a8c5c8d 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -109,7 +109,7 @@ pub fn getRemoteObject( group: []const u8, value: anytype, ) !RemoteObject { - const js_value = try context.zigValueToJs(value); + const js_value = try context.zigValueToJs(value, .{}); // We do not want to expose this as a parameter for now const generate_preview = false; @@ -127,8 +127,10 @@ pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []con const unwrapped = try self.session.unwrapObject(allocator, object_id); // The values context and groupId are not used here const toa = getTaggedAnyOpaque(unwrapped.value) orelse return null; - if (toa.subtype == null or toa.subtype != .node) return error.ObjectIdIsNotANode; - return toa.ptr; + if (toa.subtype == null or toa.subtype != .node) { + return error.ObjectIdIsNotANode; + } + return toa.value; } const NoopInspector = struct { diff --git a/src/browser/tests/cdp/dom1.html b/src/browser/tests/cdp/dom1.html new file mode 100644 index 000000000..db532cc51 --- /dev/null +++ b/src/browser/tests/cdp/dom1.html @@ -0,0 +1 @@ +

1

2

diff --git a/src/browser/tests/cdp/dom2.html b/src/browser/tests/cdp/dom2.html new file mode 100644 index 000000000..c9b3acb3a --- /dev/null +++ b/src/browser/tests/cdp/dom2.html @@ -0,0 +1 @@ +

2

diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index cd6b003ea..8022cea8e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -27,7 +27,6 @@ const reflect = @import("../reflect.zig"); const Node = @import("Node.zig"); const CSS = @import("CSS.zig"); -const DOMRect = @import("DOMRect.zig"); const ShadowRoot = @import("ShadowRoot.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); @@ -35,6 +34,7 @@ const Animation = @import("animation/Animation.zig"); const DOMStringMap = @import("element/DOMStringMap.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); +pub const DOMRect = @import("DOMRect.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); pub const Attribute = @import("element/Attribute.zig"); diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index e99fd6b65..791f94585 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -18,657 +18,642 @@ const std = @import("std"); const log = @import("../../log.zig"); +const Node = @import("../Node.zig"); +const DOMNode = @import("../../browser/webapi/Node.zig"); +const Selector = @import("../../browser/webapi/selector/Selector.zig"); + const Allocator = std.mem.Allocator; -// const css = @import("../../browser/dom/css.zig"); -// const parser = @import("../../browser/netsurf.zig"); -// const dom_node = @import("../../browser/dom/node.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - // ZIGDOM - // getDocument, - // performSearch, - // getSearchResults, - // discardSearchResults, - // querySelector, - // querySelectorAll, - // resolveNode, - // describeNode, - // scrollIntoViewIfNeeded, - // getContentQuads, - // getBoxModel, - // requestChildNodes, - // getFrameOwner, + getDocument, + performSearch, + getSearchResults, + discardSearchResults, + querySelector, + querySelectorAll, + resolveNode, + describeNode, + scrollIntoViewIfNeeded, + getContentQuads, + getBoxModel, + requestChildNodes, + getFrameOwner, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), - // @ZIGDOM - // .getDocument => return getDocument(cmd), - // .performSearch => return performSearch(cmd), - // .getSearchResults => return getSearchResults(cmd), - // .discardSearchResults => return discardSearchResults(cmd), - // .querySelector => return querySelector(cmd), - // .querySelectorAll => return querySelectorAll(cmd), - // .resolveNode => return resolveNode(cmd), - // .describeNode => return describeNode(cmd), - // .scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd), - // .getContentQuads => return getContentQuads(cmd), - // .getBoxModel => return getBoxModel(cmd), - // .requestChildNodes => return requestChildNodes(cmd), - // .getFrameOwner => return getFrameOwner(cmd), + .getDocument => return getDocument(cmd), + .performSearch => return performSearch(cmd), + .getSearchResults => return getSearchResults(cmd), + .discardSearchResults => return discardSearchResults(cmd), + .querySelector => return querySelector(cmd), + .querySelectorAll => return querySelectorAll(cmd), + .resolveNode => return resolveNode(cmd), + .describeNode => return describeNode(cmd), + .scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd), + .getContentQuads => return getContentQuads(cmd), + .getBoxModel => return getBoxModel(cmd), + .requestChildNodes => return requestChildNodes(cmd), + .getFrameOwner => return getFrameOwner(cmd), + } +} + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument +fn getDocument(cmd: anytype) !void { + const Params = struct { + // CDP documentation implies that 0 isn't valid, but it _does_ work in Chrome + depth: i32 = 3, + pierce: bool = false, + }; + const params = try cmd.params(Params) orelse Params{}; + + if (params.pierce) { + log.warn(.cdp, "not implemented", .{ .feature = "DOM.getDocument: Not implemented pierce parameter" }); + } + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = try bc.node_registry.register(page.window._document.asNode()); + return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); +} + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch +fn performSearch(cmd: anytype) !void { + const params = (try cmd.params(struct { + query: []const u8, + includeUserAgentShadowDOM: ?bool = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + const list = try Selector.querySelectorAll(page.window._document.asNode(), params.query, page); + const search = try bc.node_search_list.create(list._nodes); + + // dispatch setChildNodesEvents to inform the client of the subpart of node + // tree covering the results. + try dispatchSetChildNodes(cmd, list._nodes); + + return cmd.sendResult(.{ + .searchId = search.name, + .resultCount = @as(u32, @intCast(search.node_ids.len)), + }, .{}); +} + +// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree +// hierarchy of each nodes. +// We dispatch event in the reverse order: from the top level to the direct parents. +// We should dispatch a node only if it has never been sent. +fn dispatchSetChildNodes(cmd: anytype, dom_nodes: []const *DOMNode) !void { + const arena = cmd.arena; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const session_id = bc.session_id orelse return error.SessionIdNotLoaded; + + var parents: std.ArrayList(*Node) = .empty; + for (dom_nodes) |dom_node| { + var current = dom_node; + while (true) { + const parent_node = current._parent orelse break; + + const node = try bc.node_registry.register(parent_node); + if (node.set_child_nodes_event) { + break; + } + try parents.append(arena, node); + current = parent_node; + } + } + + const plen = parents.items.len; + if (plen == 0) { + return; + } + + var i: usize = plen; + // We're going to iterate in reverse order from how we added them. + // This ensures that we're emitting the tree of nodes top-down. + while (i > 0) { + i -= 1; + const node = parents.items[i]; + // Although our above loop won't add an already-sent node to `parents` + // this can still be true because two nodes can share the same parent node + // so we might have just sent the node a previous iteration of this loop + if (node.set_child_nodes_event) continue; + + node.set_child_nodes_event = true; + + // If the node has no parent, it's the root node. + // We don't dispatch event for it because we assume the root node is + // dispatched via the DOM.getDocument command. + const dom_parent = node.dom._parent orelse continue; + + // Retrieve the parent from the registry. + const parent_node = try bc.node_registry.register(dom_parent); + + try cmd.sendEvent("DOM.setChildNodes", .{ + .parentId = parent_node.id, + .nodes = .{bc.nodeWriter(node, .{})}, + }, .{ + .session_id = session_id, + }); + } +} + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults +fn discardSearchResults(cmd: anytype) !void { + const params = (try cmd.params(struct { + searchId: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + bc.node_search_list.remove(params.searchId); + return cmd.sendResult(null, .{}); +} + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults +fn getSearchResults(cmd: anytype) !void { + const params = (try cmd.params(struct { + searchId: []const u8, + fromIndex: u32, + toIndex: u32, + })) orelse return error.InvalidParams; + + if (params.fromIndex >= params.toIndex) { + return error.BadIndices; + } + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + const search = bc.node_search_list.get(params.searchId) orelse { + return error.SearchResultNotFound; + }; + + const node_ids = search.node_ids; + + if (params.fromIndex >= node_ids.len) return error.BadFromIndex; + if (params.toIndex > node_ids.len) return error.BadToIndex; + + return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{}); +} + +fn querySelector(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: Node.Id, + selector: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { + return cmd.sendError(-32000, "Could not find node with given id", .{}); + }; + + const element = try Selector.querySelector(node.dom, params.selector, page) orelse return error.NodeNotFoundForGivenId; + const dom_node = element.asNode(); + const registered_node = try bc.node_registry.register(dom_node); + + // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. + var array = [1]*DOMNode{dom_node}; + try dispatchSetChildNodes(cmd, array[0..]); + + return cmd.sendResult(.{ + .nodeId = registered_node.id, + }, .{}); +} + +fn querySelectorAll(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: Node.Id, + selector: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { + return cmd.sendError(-32000, "Could not find node with given id", .{}); + }; + + const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, page); + const nodes = selected_nodes._nodes; + + const node_ids = try cmd.arena.alloc(Node.Id, nodes.len); + for (nodes, node_ids) |selected_node, *node_id| { + node_id.* = (try bc.node_registry.register(selected_node)).id; + } + + // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. + try dispatchSetChildNodes(cmd, nodes); + + return cmd.sendResult(.{ + .nodeIds = node_ids, + }, .{}); +} + +fn resolveNode(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?u32 = null, + objectGroup: ?[]const u8 = null, + executionContextId: ?u32 = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + var js_context = page.js; + if (params.executionContextId) |context_id| { + if (js_context.v8_context.debugContextId() != context_id) { + for (bc.isolated_worlds.items) |*isolated_world| { + js_context = &(isolated_world.executor.context orelse return error.ContextNotFound); + if (js_context.v8_context.debugContextId() == context_id) { + break; + } + } else return error.ContextNotFound; + } + } + + const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; + + // node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement + // So we use the Node.Union when retrieve the value from the environment + const remote_object = try bc.inspector.getRemoteObject( + js_context, + params.objectGroup orelse "", + node.dom, + ); + defer remote_object.deinit(); + + const arena = cmd.arena; + return cmd.sendResult(.{ .object = .{ + .type = try remote_object.getType(arena), + .subtype = try remote_object.getSubtype(arena), + .className = try remote_object.getClassName(arena), + .description = try remote_object.getDescription(arena), + .objectId = try remote_object.getObjectId(arena), + } }, .{}); +} + +fn describeNode(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + objectId: ?[]const u8 = null, + depth: i32 = 1, + pierce: bool = false, + })) orelse return error.InvalidParams; + + if (params.pierce) { + log.warn(.cdp, "not implemented", .{ .feature = "DOM.describeNode: Not implemented pierce parameter" }); + } + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + + return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); +} + +// An array of quad vertices, x immediately followed by y for each point, points clock-wise. +// Note Y points downward +// We are assuming the start/endpoint is not repeated. +const Quad = [8]f64; + +const BoxModel = struct { + content: Quad, + padding: Quad, + border: Quad, + margin: Quad, + width: i32, + height: i32, + // shapeOutside: ?ShapeOutsideInfo, +}; + +fn rectToQuad(rect: *const DOMNode.Element.DOMRect) Quad { + return Quad{ + rect._x, + rect._y, + rect._x + rect._width, + rect._y, + rect._x + rect._width, + rect._y + rect._height, + rect._x, + rect._y + rect._height, + }; +} + +fn scrollIntoViewIfNeeded(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?u32 = null, + objectId: ?[]const u8 = null, + rect: ?DOMNode.Element.DOMRect = null, + })) orelse return error.InvalidParams; + // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null + + // We retrieve the node to at least check if it exists and is valid. + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + + switch (node.dom._type) { + .element => {}, + .document => {}, + .cdata => {}, + else => return error.NodeDoesNotHaveGeometry, + } + + return cmd.sendResult(null, .{}); +} + +fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node { + const input_node_id = node_id orelse backend_node_id; + if (input_node_id) |input_node_id_| { + return browser_context.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound; + } + if (object_id) |object_id_| { + // Retrieve the object from which ever context it is in. + const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_); + return try browser_context.node_registry.register(@ptrCast(@alignCast(parser_node))); + } + return error.MissingParams; +} + +// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads +// Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface +fn getContentQuads(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + objectId: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + + // TODO likely if the following CSS properties are set the quads should be empty + // visibility: hidden + // display: none + + const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; + // TODO implement for document or text + // Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example. + // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? + // Elements like SVGElement may have multiple quads. + + const rect = try element.getBoundingClientRect(page); + const quad = rectToQuad(rect); + + return cmd.sendResult(.{ .quads = &.{quad} }, .{}); +} + +fn getBoxModel(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?u32 = null, + objectId: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + + // TODO implement for document or text + const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; + + const rect = try element.getBoundingClientRect(page); + const quad = rectToQuad(rect); + + return cmd.sendResult(.{ .model = BoxModel{ + .content = quad, + .padding = quad, + .border = quad, + .margin = quad, + .width = @intFromFloat(rect._width), + .height = @intFromFloat(rect._height), + } }, .{}); +} + +fn requestChildNodes(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: Node.Id, + depth: i32 = 1, + pierce: bool = false, + })) orelse return error.InvalidParams; + + if (params.depth == 0) return error.InvalidParams; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const session_id = bc.session_id orelse return error.SessionIdNotLoaded; + const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { + return error.InvalidNode; + }; + + try cmd.sendEvent("DOM.setChildNodes", .{ + .parentId = node.id, + .nodes = bc.nodeWriter(node, .{ .depth = params.depth, .exclude_root = true }), + }, .{ + .session_id = session_id, + }); + + return cmd.sendResult(null, .{}); +} + +fn getFrameOwner(cmd: anytype) !void { + const params = (try cmd.params(struct { + frameId: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const target_id = bc.target_id orelse return error.TargetNotLoaded; + if (std.mem.eql(u8, target_id, params.frameId) == false) { + return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); } + + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = try bc.node_registry.register(page.window._document.asNode()); + return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); } -// ZIGDOM -// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument -// fn getDocument(cmd: anytype) !void { -// const Params = struct { -// // CDP documentation implies that 0 isn't valid, but it _does_ work in Chrome -// depth: i32 = 3, -// pierce: bool = false, -// }; -// const params = try cmd.params(Params) orelse Params{}; - -// if (params.pierce) { -// log.warn(.cdp, "not implemented", .{ .feature = "DOM.getDocument: Not implemented pierce parameter" }); -// } - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; -// const doc = parser.documentHTMLToDocument(page.window.document); - -// const node = try bc.node_registry.register(parser.documentToNode(doc)); -// return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); -// } - -// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch -// fn performSearch(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// query: []const u8, -// includeUserAgentShadowDOM: ?bool = null, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; -// const doc = parser.documentHTMLToDocument(page.window.document); - -// const allocator = cmd.cdp.allocator; -// var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query); -// defer list.deinit(allocator); - -// const search = try bc.node_search_list.create(list.nodes.items); - -// // dispatch setChildNodesEvents to inform the client of the subpart of node -// // tree covering the results. -// try dispatchSetChildNodes(cmd, list.nodes.items); - -// return cmd.sendResult(.{ -// .searchId = search.name, -// .resultCount = @as(u32, @intCast(search.node_ids.len)), -// }, .{}); -// } - -// // dispatchSetChildNodes send the setChildNodes event for the whole DOM tree -// // hierarchy of each nodes. -// // We dispatch event in the reverse order: from the top level to the direct parents. -// // We should dispatch a node only if it has never been sent. -// fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void { -// const arena = cmd.arena; -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const session_id = bc.session_id orelse return error.SessionIdNotLoaded; - -// var parents: std.ArrayListUnmanaged(*Node) = .{}; -// for (nodes) |_n| { -// var n = _n; -// while (true) { -// const p = parser.nodeParentNode(n) orelse break; - -// // Register the node. -// const node = try bc.node_registry.register(p); -// if (node.set_child_nodes_event) break; -// try parents.append(arena, node); -// n = p; -// } -// } - -// const plen = parents.items.len; -// if (plen == 0) return; - -// var i: usize = plen; -// // We're going to iterate in reverse order from how we added them. -// // This ensures that we're emitting the tree of nodes top-down. -// while (i > 0) { -// i -= 1; -// const node = parents.items[i]; -// // Although our above loop won't add an already-sent node to `parents` -// // this can still be true because two nodes can share the same parent node -// // so we might have just sent the node a previous iteration of this loop -// if (node.set_child_nodes_event) continue; - -// node.set_child_nodes_event = true; - -// // If the node has no parent, it's the root node. -// // We don't dispatch event for it because we assume the root node is -// // dispatched via the DOM.getDocument command. -// const p = parser.nodeParentNode(node._node) orelse { -// continue; -// }; - -// // Retrieve the parent from the registry. -// const parent_node = try bc.node_registry.register(p); - -// try cmd.sendEvent("DOM.setChildNodes", .{ -// .parentId = parent_node.id, -// .nodes = .{bc.nodeWriter(node, .{})}, -// }, .{ -// .session_id = session_id, -// }); -// } -// } - -// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults -// fn discardSearchResults(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// searchId: []const u8, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - -// bc.node_search_list.remove(params.searchId); -// return cmd.sendResult(null, .{}); -// } - -// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults -// fn getSearchResults(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// searchId: []const u8, -// fromIndex: u32, -// toIndex: u32, -// })) orelse return error.InvalidParams; - -// if (params.fromIndex >= params.toIndex) { -// return error.BadIndices; -// } - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - -// const search = bc.node_search_list.get(params.searchId) orelse { -// return error.SearchResultNotFound; -// }; - -// const node_ids = search.node_ids; - -// if (params.fromIndex >= node_ids.len) return error.BadFromIndex; -// if (params.toIndex > node_ids.len) return error.BadToIndex; - -// return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{}); -// } - -// fn querySelector(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: Node.Id, -// selector: []const u8, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - -// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { -// return cmd.sendError(-32000, "Could not find node with given id", .{}); -// }; - -// const selected_node = try css.querySelector( -// cmd.arena, -// node._node, -// params.selector, -// ) orelse return error.NodeNotFoundForGivenId; - -// const registered_node = try bc.node_registry.register(selected_node); - -// // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. -// var array = [1]*parser.Node{selected_node}; -// try dispatchSetChildNodes(cmd, array[0..]); - -// return cmd.sendResult(.{ -// .nodeId = registered_node.id, -// }, .{}); -// } - -// fn querySelectorAll(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: Node.Id, -// selector: []const u8, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - -// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { -// return cmd.sendError(-32000, "Could not find node with given id", .{}); -// }; - -// const arena = cmd.arena; -// const selected_nodes = try css.querySelectorAll(arena, node._node, params.selector); -// const nodes = selected_nodes.nodes.items; - -// const node_ids = try arena.alloc(Node.Id, nodes.len); -// for (nodes, node_ids) |selected_node, *node_id| { -// node_id.* = (try bc.node_registry.register(selected_node)).id; -// } - -// // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. -// try dispatchSetChildNodes(cmd, nodes); - -// return cmd.sendResult(.{ -// .nodeIds = node_ids, -// }, .{}); -// } - -// fn resolveNode(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: ?Node.Id = null, -// backendNodeId: ?u32 = null, -// objectGroup: ?[]const u8 = null, -// executionContextId: ?u32 = null, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; - -// var js_context = page.js; -// if (params.executionContextId) |context_id| { -// if (js_context.v8_context.debugContextId() != context_id) { -// for (bc.isolated_worlds.items) |*isolated_world| { -// js_context = &(isolated_world.executor.context orelse return error.ContextNotFound); -// if (js_context.v8_context.debugContextId() == context_id) { -// break; -// } -// } else return error.ContextNotFound; -// } -// } - -// const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; -// const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; - -// // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement -// // So we use the Node.Union when retrieve the value from the environment -// const remote_object = try bc.inspector.getRemoteObject( -// js_context, -// params.objectGroup orelse "", -// try dom_node.Node.toInterface(node._node), -// ); -// defer remote_object.deinit(); - -// const arena = cmd.arena; -// return cmd.sendResult(.{ .object = .{ -// .type = try remote_object.getType(arena), -// .subtype = try remote_object.getSubtype(arena), -// .className = try remote_object.getClassName(arena), -// .description = try remote_object.getDescription(arena), -// .objectId = try remote_object.getObjectId(arena), -// } }, .{}); -// } - -// fn describeNode(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: ?Node.Id = null, -// backendNodeId: ?Node.Id = null, -// objectId: ?[]const u8 = null, -// depth: i32 = 1, -// pierce: bool = false, -// })) orelse return error.InvalidParams; - -// if (params.pierce) { -// log.warn(.cdp, "not implemented", .{ .feature = "DOM.describeNode: Not implemented pierce parameter" }); -// } -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - -// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - -// return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); -// } - -// // An array of quad vertices, x immediately followed by y for each point, points clock-wise. -// // Note Y points downward -// // We are assuming the start/endpoint is not repeated. -// const Quad = [8]f64; - -// const BoxModel = struct { -// content: Quad, -// padding: Quad, -// border: Quad, -// margin: Quad, -// width: i32, -// height: i32, -// // shapeOutside: ?ShapeOutsideInfo, -// }; - -// fn rectToQuad(rect: Element.DOMRect) Quad { -// return Quad{ -// rect.x, -// rect.y, -// rect.x + rect.width, -// rect.y, -// rect.x + rect.width, -// rect.y + rect.height, -// rect.x, -// rect.y + rect.height, -// }; -// } - -// fn scrollIntoViewIfNeeded(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: ?Node.Id = null, -// backendNodeId: ?u32 = null, -// objectId: ?[]const u8 = null, -// rect: ?Element.DOMRect = null, -// })) orelse return error.InvalidParams; -// // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null - -// // We retrieve the node to at least check if it exists and is valid. -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - -// const node_type = parser.nodeType(node._node); -// switch (node_type) { -// .element => {}, -// .document => {}, -// .text => {}, -// else => return error.NodeDoesNotHaveGeometry, -// } - -// return cmd.sendResult(null, .{}); -// } - -// fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node { -// const input_node_id = node_id orelse backend_node_id; -// if (input_node_id) |input_node_id_| { -// return browser_context.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound; -// } -// if (object_id) |object_id_| { -// // Retrieve the object from which ever context it is in. -// const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_); -// return try browser_context.node_registry.register(@ptrCast(@alignCast(parser_node))); -// } -// return error.MissingParams; -// } - -// // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads -// // Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface -// fn getContentQuads(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: ?Node.Id = null, -// backendNodeId: ?Node.Id = null, -// objectId: ?[]const u8 = null, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; - -// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - -// // TODO likely if the following CSS properties are set the quads should be empty -// // visibility: hidden -// // display: none - -// if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; -// // TODO implement for document or text -// // Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example. -// // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? -// // Elements like SVGElement may have multiple quads. - -// const element = parser.nodeToElement(node._node); -// const rect = try Element._getBoundingClientRect(element, page); -// const quad = rectToQuad(rect); - -// return cmd.sendResult(.{ .quads = &.{quad} }, .{}); -// } - -// fn getBoxModel(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: ?Node.Id = null, -// backendNodeId: ?u32 = null, -// objectId: ?[]const u8 = null, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; - -// const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); - -// // TODO implement for document or text -// if (parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement; -// const element = parser.nodeToElement(node._node); - -// const rect = try Element._getBoundingClientRect(element, page); -// const quad = rectToQuad(rect); - -// return cmd.sendResult(.{ .model = BoxModel{ -// .content = quad, -// .padding = quad, -// .border = quad, -// .margin = quad, -// .width = @intFromFloat(rect.width), -// .height = @intFromFloat(rect.height), -// } }, .{}); -// } - -// fn requestChildNodes(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// nodeId: Node.Id, -// depth: i32 = 1, -// pierce: bool = false, -// })) orelse return error.InvalidParams; - -// if (params.depth == 0) return error.InvalidParams; -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const session_id = bc.session_id orelse return error.SessionIdNotLoaded; -// const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { -// return error.InvalidNode; -// }; - -// try cmd.sendEvent("DOM.setChildNodes", .{ -// .parentId = node.id, -// .nodes = bc.nodeWriter(node, .{ .depth = params.depth, .exclude_root = true }), -// }, .{ -// .session_id = session_id, -// }); - -// return cmd.sendResult(null, .{}); -// } - -// fn getFrameOwner(cmd: anytype) !void { -// const params = (try cmd.params(struct { -// frameId: []const u8, -// })) orelse return error.InvalidParams; - -// const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; -// const target_id = bc.target_id orelse return error.TargetNotLoaded; -// if (std.mem.eql(u8, target_id, params.frameId) == false) { -// return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); -// } - -// const page = bc.session.currentPage() orelse return error.PageNotLoaded; -// const doc = parser.documentHTMLToDocument(page.window.document); - -// const node = try bc.node_registry.register(parser.documentToNode(doc)); -// return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); -// } - -// const testing = @import("../testing.zig"); - -// test "cdp.dom: getSearchResults unknown search id" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ -// .id = 8, -// .method = "DOM.getSearchResults", -// .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, -// })); -// } - -// test "cdp.dom: search flow" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - -// try ctx.processMessage(.{ -// .id = 12, -// .method = "DOM.performSearch", -// .params = .{ .query = "p" }, -// }); -// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 }); - -// { -// // getSearchResults -// try ctx.processMessage(.{ -// .id = 13, -// .method = "DOM.getSearchResults", -// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 }, -// }); -// try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2 } }, .{ .id = 13 }); - -// // different fromIndex -// try ctx.processMessage(.{ -// .id = 14, -// .method = "DOM.getSearchResults", -// .params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 }, -// }); -// try ctx.expectSentResult(.{ .nodeIds = &.{2} }, .{ .id = 14 }); - -// // different toIndex -// try ctx.processMessage(.{ -// .id = 15, -// .method = "DOM.getSearchResults", -// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, -// }); -// try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 15 }); -// } - -// try ctx.processMessage(.{ -// .id = 16, -// .method = "DOM.discardSearchResults", -// .params = .{ .searchId = "0" }, -// }); -// try ctx.expectSentResult(null, .{ .id = 16 }); - -// // make sure the delete actually did something -// try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{ -// .id = 17, -// .method = "DOM.getSearchResults", -// .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, -// })); -// } - -// test "cdp.dom: querySelector unknown search id" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - -// try ctx.processMessage(.{ -// .id = 9, -// .method = "DOM.querySelector", -// .params = .{ .nodeId = 99, .selector = "" }, -// }); -// try ctx.expectSentError(-32000, "Could not find node with given id", .{}); - -// try ctx.processMessage(.{ -// .id = 9, -// .method = "DOM.querySelectorAll", -// .params = .{ .nodeId = 99, .selector = "" }, -// }); -// try ctx.expectSentError(-32000, "Could not find node with given id", .{}); -// } - -// test "cdp.dom: querySelector Node not found" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

1

2

" }); - -// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry -// .id = 3, -// .method = "DOM.performSearch", -// .params = .{ .query = "p" }, -// }); -// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 }); - -// try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{ -// .id = 4, -// .method = "DOM.querySelector", -// .params = .{ .nodeId = 1, .selector = "a" }, -// })); - -// try ctx.processMessage(.{ -// .id = 5, -// .method = "DOM.querySelectorAll", -// .params = .{ .nodeId = 1, .selector = "a" }, -// }); -// try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 }); -// } - -// test "cdp.dom: querySelector Nodes found" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); - -// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry -// .id = 3, -// .method = "DOM.performSearch", -// .params = .{ .query = "div" }, -// }); -// try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 }); - -// try ctx.processMessage(.{ -// .id = 4, -// .method = "DOM.querySelector", -// .params = .{ .nodeId = 1, .selector = "p" }, -// }); -// try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); -// try ctx.expectSentResult(.{ .nodeId = 6 }, .{ .id = 4 }); - -// try ctx.processMessage(.{ -// .id = 5, -// .method = "DOM.querySelectorAll", -// .params = .{ .nodeId = 1, .selector = "p" }, -// }); -// try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); -// try ctx.expectSentResult(.{ .nodeIds = &.{6} }, .{ .id = 5 }); -// } - -// test "cdp.dom: getBoxModel" { -// var ctx = testing.context(); -// defer ctx.deinit(); - -// _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "

2

" }); - -// try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry -// .id = 3, -// .method = "DOM.getDocument", -// }); - -// try ctx.processMessage(.{ -// .id = 4, -// .method = "DOM.querySelector", -// .params = .{ .nodeId = 1, .selector = "p" }, -// }); -// try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 }); - -// try ctx.processMessage(.{ -// .id = 5, -// .method = "DOM.getBoxModel", -// .params = .{ .nodeId = 6 }, -// }); -// try ctx.expectSentResult(.{ .model = BoxModel{ -// .content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, -// .padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, -// .border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, -// .margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, -// .width = 1, -// .height = 1, -// } }, .{ .id = 5 }); -// } +const testing = @import("../testing.zig"); +test "cdp.dom: getSearchResults unknown search id" { + var ctx = testing.context(); + defer ctx.deinit(); + + try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ + .id = 8, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, + })); +} + +test "cdp.dom: search flow" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); + + try ctx.processMessage(.{ + .id = 12, + .method = "DOM.performSearch", + .params = .{ .query = "p" }, + }); + try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 }); + + { + // getSearchResults + try ctx.processMessage(.{ + .id = 13, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 }, + }); + try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2 } }, .{ .id = 13 }); + + // different fromIndex + try ctx.processMessage(.{ + .id = 14, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 }, + }); + try ctx.expectSentResult(.{ .nodeIds = &.{2} }, .{ .id = 14 }); + + // different toIndex + try ctx.processMessage(.{ + .id = 15, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, + }); + try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 15 }); + } + + try ctx.processMessage(.{ + .id = 16, + .method = "DOM.discardSearchResults", + .params = .{ .searchId = "0" }, + }); + try ctx.expectSentResult(null, .{ .id = 16 }); + + // make sure the delete actually did something + try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{ + .id = 17, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, + })); +} + +test "cdp.dom: querySelector unknown search id" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); + + try ctx.processMessage(.{ + .id = 9, + .method = "DOM.querySelector", + .params = .{ .nodeId = 99, .selector = "" }, + }); + try ctx.expectSentError(-32000, "Could not find node with given id", .{}); + + try ctx.processMessage(.{ + .id = 9, + .method = "DOM.querySelectorAll", + .params = .{ .nodeId = 99, .selector = "" }, + }); + try ctx.expectSentError(-32000, "Could not find node with given id", .{}); +} + +test "cdp.dom: querySelector Node not found" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); + + try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry + .id = 3, + .method = "DOM.performSearch", + .params = .{ .query = "p" }, + }); + try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 }); + + try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{ + .id = 4, + .method = "DOM.querySelector", + .params = .{ .nodeId = 1, .selector = "a" }, + })); + + try ctx.processMessage(.{ + .id = 5, + .method = "DOM.querySelectorAll", + .params = .{ .nodeId = 1, .selector = "a" }, + }); + try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 }); +} + +test "cdp.dom: querySelector Nodes found" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); + + try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry + .id = 3, + .method = "DOM.performSearch", + .params = .{ .query = "div" }, + }); + try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 }); + + try ctx.processMessage(.{ + .id = 4, + .method = "DOM.querySelector", + .params = .{ .nodeId = 1, .selector = "p" }, + }); + try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); + try ctx.expectSentResult(.{ .nodeId = 7 }, .{ .id = 4 }); + + try ctx.processMessage(.{ + .id = 5, + .method = "DOM.querySelectorAll", + .params = .{ .nodeId = 1, .selector = "p" }, + }); + try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); + try ctx.expectSentResult(.{ .nodeIds = &.{7} }, .{ .id = 5 }); +} + +test "cdp.dom: getBoxModel" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); + + try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry + .id = 3, + .method = "DOM.getDocument", + }); + + try ctx.processMessage(.{ + .id = 4, + .method = "DOM.querySelector", + .params = .{ .nodeId = 1, .selector = "p" }, + }); + try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 }); + + try ctx.processMessage(.{ + .id = 5, + .method = "DOM.getBoxModel", + .params = .{ .nodeId = 6 }, + }); + try ctx.expectSentResult(.{ .model = BoxModel{ + .content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, + .padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, + .border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, + .margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, + .width = 1, + .height = 1, + } }, .{ .id = 5 }); +} diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index cefc88232..8d459a9f4 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -93,7 +93,7 @@ const TestContext = struct { id: ?[]const u8 = null, target_id: ?[]const u8 = null, session_id: ?[]const u8 = null, - html: ?[]const u8 = null, + url: ?[:0]const u8 = null, }; pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) { var c = self.cdp(); @@ -116,12 +116,23 @@ const TestContext = struct { bc.session_id = sid; } - // @ZIGDOM - // if (opts.html) |html| { - // if (bc.session_id == null) bc.session_id = "SID-X"; - // const page = try bc.session.createPage(); - // page.window._document = (try Document.init(html)).doc; - // } + if (opts.url) |url| { + if (bc.session_id == null) { + bc.session_id = "SID-X"; + } + if (bc.target_id == null) { + bc.target_id = "TID-X"; + } + const page = try bc.session.createPage(); + const full_url = try std.fmt.allocPrintSentinel( + self.arena.allocator(), + "http://127.0.0.1:9582/src/browser/tests/{s}", + .{ url }, + 0, + ); + try page.navigate(full_url, .{}); + bc.session.fetchWait(2000); + } return bc; } diff --git a/src/testing.zig b/src/testing.zig index b250dc207..452ce812d 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -430,10 +430,6 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { try page.navigate(url, .{}); test_session.fetchWait(2000); - - page._session.browser.runMicrotasks(); - page._session.browser.runMessageLoop(); - return page; } From 53ccefc15c4de9a254e095b70fdac8c48badab72 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 9 Dec 2025 08:50:58 +0100 Subject: [PATCH 169/257] cdp: implement Security.setIgnoreCertificateErrors ensure no inflight conns is running before set TLS verify --- src/cdp/domains/security.zig | 39 +++++++++++++++++++++++++++++++++ src/http/Client.zig | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/cdp/domains/security.zig b/src/cdp/domains/security.zig index dad0cebd4..830cd5910 100644 --- a/src/cdp/domains/security.zig +++ b/src/cdp/domains/security.zig @@ -21,9 +21,48 @@ const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, + setIgnoreCertificateErrors, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), + .setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd), } } + +fn setIgnoreCertificateErrors(cmd: anytype) !void { + const params = (try cmd.params(struct { + ignore: bool, + })) orelse return error.InvalidParams; + + if (params.ignore) { + try cmd.cdp.browser.http_client.disableTlsVerify(); + } else { + try cmd.cdp.browser.http_client.enableTlsVerify(); + } + + return cmd.sendResult(null, .{}); +} + +const testing = @import("../testing.zig"); + +test "cdp.Security: setIgnoreCertificateErrors" { + var ctx = testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .id = "BID-9" }); + + try ctx.processMessage(.{ + .id = 8, + .method = "Security.setIgnoreCertificateErrors", + .params = .{ .ignore = true }, + }); + try ctx.expectSentResult(null, .{ .id = 8 }); + + try ctx.processMessage(.{ + .id = 9, + .method = "Security.setIgnoreCertificateErrors", + .params = .{ .ignore = false }, + }); + try ctx.expectSentResult(null, .{ .id = 9 }); +} diff --git a/src/http/Client.zig b/src/http/Client.zig index 69e6d9e13..0e2e08159 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -92,6 +92,11 @@ notification: ?*Notification = null, // restoring, this originally-configured value is what it goes to. http_proxy: ?[:0]const u8 = null, +// track if the client use a proxy for connections. +// We can't use http_proxy because we want also to track proxy configured via +// CDP. +use_proxy: bool, + // The complete user-agent header line user_agent: [:0]const u8, @@ -125,6 +130,7 @@ pub fn init(allocator: Allocator, ca_blob: ?c.curl_blob, opts: Http.Opts) !*Clie .handles = handles, .allocator = allocator, .http_proxy = opts.http_proxy, + .use_proxy = opts.http_proxy != null, .user_agent = opts.user_agent, .transfer_pool = transfer_pool, }; @@ -308,6 +314,7 @@ pub fn changeProxy(self: *Client, proxy: [:0]const u8) !void { for (self.handles.handles) |*h| { try errorCheck(c.curl_easy_setopt(h.conn.easy, c.CURLOPT_PROXY, proxy.ptr)); } + self.use_proxy = true; } // Same restriction as changeProxy. Should be ok since this is only called on @@ -319,6 +326,41 @@ pub fn restoreOriginalProxy(self: *Client) !void { for (self.handles.handles) |*h| { try errorCheck(c.curl_easy_setopt(h.conn.easy, c.CURLOPT_PROXY, proxy)); } + self.use_proxy = proxy != null; +} + +// Enable TLS verification on all connections. +pub fn enableTlsVerify(self: *const Client) !void { + try self.ensureNoActiveConnection(); + + for (self.handles.handles) |*h| { + const easy = h.conn.easy; + + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYHOST, @as(c_long, 2))); + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYPEER, @as(c_long, 1))); + + if (self.use_proxy) { + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, @as(c_long, 2))); + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, @as(c_long, 1))); + } + } +} + +// Disable TLS verification on all connections. +pub fn disableTlsVerify(self: *const Client) !void { + try self.ensureNoActiveConnection(); + + for (self.handles.handles) |*h| { + const easy = h.conn.easy; + + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYHOST, @as(c_long, 0))); + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYPEER, @as(c_long, 0))); + + if (self.use_proxy) { + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, @as(c_long, 0))); + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, @as(c_long, 0))); + } + } } fn makeRequest(self: *Client, handle: *Handle, transfer: *Transfer) !void { From 47b4b68e60143fc1ca29bc023d51815172fc914b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 9 Dec 2025 16:38:56 +0800 Subject: [PATCH 170/257] add parsed DocType to document (and handle dumping it) --- src/browser/Page.zig | 1 - src/browser/dump.zig | 31 +++++++++++++++++++++--- src/browser/parser/Parser.zig | 22 +++++++++++------ src/browser/tests/document/document.html | 1 + src/browser/webapi/XMLSerializer.zig | 6 ++++- src/lightpanda.zig | 2 +- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 9bfd17cfb..af235a5bd 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -868,7 +868,6 @@ fn notifyNetworkAlmostIdle(self: *Page) void { // called from the parser pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void { - // TODO: should some of this be pushed into appendNode... ? const node = switch (child) { .node => |n| n, .text => |txt| blk: { diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 628073974..adcef5861 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -24,6 +24,7 @@ const Slot = @import("webapi/element/html/Slot.zig"); pub const RootOpts = struct { with_base: bool = false, strip: Opts.Strip = .{}, + shadow: Opts.Shadow = .rendered, }; pub const Opts = struct { @@ -48,10 +49,10 @@ pub const Opts = struct { }; }; -pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { - const doc = page.document; +pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { if (opts.with_base) { if (doc.is(Node.Document.HTMLDocument)) |html_doc| { + try writer.writeAll(""); const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode(); const base = try doc.createElement("base", null, page); try base.setAttributeSafe("base", page.url, page); @@ -59,7 +60,7 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { } } - return deep(doc.asNode(), .{ .strip = opts.strip }, writer, page); + return deep(doc.asNode(), .{ .strip = opts.strip, .shadow = opts.shadow }, writer, page); } pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { @@ -130,7 +131,29 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri } }, .document => try children(node, opts, writer, page), - .document_type => {}, + .document_type => |dt| { + try writer.writeAll("\n"); + }, .document_fragment => try children(node, opts, writer, page), .attribute => unreachable, } diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 65e25ecd0..24991caf7 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -219,17 +219,25 @@ fn _createCommentCallback(self: *Parser, str: []const u8) !*anyopaque { } fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void { - _ = public_id; - _ = system_id; - const self: *Parser = @ptrCast(@alignCast(ctx)); - self._appendDoctypeToDocument(name.slice()) catch |err| { + self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| { self.err = .{ .err = err, .source = .append_doctype_to_document }; }; } -fn _appendDoctypeToDocument(self: *Parser, name: []const u8) !void { - _ = self; - _ = name; +fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const u8, system_id: []const u8) !void { + const page = self.page; + + // Create the DocumentType node + const DocumentType = @import("../webapi/DocumentType.zig"); + const doctype = try page._factory.node(DocumentType{ + ._proto = undefined, + ._name = try page.dupeString(name), + ._public_id = try page.dupeString(public_id), + ._system_id = try page.dupeString(system_id), + }); + + // Append it to the document + try page.appendNew(self.container.node, .{ .node = doctype.asNode() }); } fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void { diff --git a/src/browser/tests/document/document.html b/src/browser/tests/document/document.html index 8658b9b64..1138c132a 100644 --- a/src/browser/tests/document/document.html +++ b/src/browser/tests/document/document.html @@ -8,6 +8,7 @@ + + diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index e7191a138..205d1732e 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../js/js.zig"); const URL = @import("URL.zig"); @@ -64,6 +65,25 @@ pub fn getHash(self: *const Location) []const u8 { return self._url.getHash(); } +pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void { + const normalized_hash = blk: { + if (hash.len == 0) { + const old_url = page.url; + + break :blk if (std.mem.indexOfScalar(u8, old_url, '#')) |index| + old_url[0..index] + else + old_url; + } else if (hash[0] == '#') + break :blk hash + else + break :blk try std.fmt.allocPrint(page.arena, "#{s}", .{hash}); + }; + + const duped_hash = try page.arena.dupeZ(u8, normalized_hash); + return page.navigate(duped_hash, .{ .reason = .script }); +} + pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { return self._url.toString(page); } @@ -80,7 +100,7 @@ pub const JsApi = struct { pub const toString = bridge.function(Location.toString, .{}); pub const href = bridge.accessor(Location.toString, null, .{}); pub const search = bridge.accessor(Location.getSearch, null, .{}); - pub const hash = bridge.accessor(Location.getHash, null, .{}); + pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{}); pub const pathname = bridge.accessor(Location.getPathname, null, .{}); pub const hostname = bridge.accessor(Location.getHostname, null, .{}); pub const host = bridge.accessor(Location.getHost, null, .{}); From ee7852665e1fd50648696c09551d9674233c1d56 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:47 -0800 Subject: [PATCH 184/257] fix GPL headers --- .../NavigationCurrentEntryChangeEvent.zig | 18 ++++++++++++++++++ .../navigation/NavigationEventTarget.zig | 18 ++++++++++++++++++ .../navigation/NavigationHistoryEntry.zig | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 4d0166cfa..ead742e31 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const Event = @import("../Event.zig"); const Page = @import("../../Page.zig"); diff --git a/src/browser/webapi/navigation/NavigationEventTarget.zig b/src/browser/webapi/navigation/NavigationEventTarget.zig index d262d3b49..1e9b0bd48 100644 --- a/src/browser/webapi/navigation/NavigationEventTarget.zig +++ b/src/browser/webapi/navigation/NavigationEventTarget.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const EventTarget = @import("../EventTarget.zig"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 3ef16bdce..86dc295c7 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -1,3 +1,21 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const URL = @import("../URL.zig"); const EventTarget = @import("../EventTarget.zig"); From 01d71323fcaf375e090d894499bb15b8cb73a7ea Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:48 -0800 Subject: [PATCH 185/257] complete History impl backed by Navigation --- src/browser/Page.zig | 8 +- src/browser/Session.zig | 3 + src/browser/js/bridge.zig | 1 + src/browser/webapi/Event.zig | 1 + src/browser/webapi/History.zig | 92 ++++++++++++---------- src/browser/webapi/Window.zig | 21 ++++- src/browser/webapi/event/PopStateEvent.zig | 72 +++++++++++++++++ 7 files changed, 150 insertions(+), 48 deletions(-) create mode 100644 src/browser/webapi/event/PopStateEvent.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ce3620fa0..75b644135 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -34,7 +34,6 @@ const Mime = @import("Mime.zig"); const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); const Scheduler = @import("Scheduler.zig"); -const History = @import("webapi/History.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); @@ -211,7 +210,6 @@ fn reset(self: *Page, comptime initializing: bool) !void { self.window = try self._factory.eventTarget(Window{ ._document = self.document, ._storage_bucket = storage_bucket, - ._history = History.init(self), ._performance = Performance.init(), ._proto = undefined, ._location = &default_location, @@ -1903,6 +1901,12 @@ const IdleNotification = union(enum) { } }; +pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { + const URLRaw = @import("URL.zig"); + const current_origin = (try URLRaw.getOrigin(self.arena, self.url)) orelse return false; + return std.mem.startsWith(u8, url, current_origin); +} + pub const NavigateReason = enum { anchor, address_bar, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d8b4f0d1b..5f25ae857 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -23,6 +23,7 @@ const log = @import("../log.zig"); const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); const Navigation = @import("webapi/navigation/Navigation.zig"); +const History = @import("webapi/History.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); @@ -55,6 +56,7 @@ executor: js.ExecutionWorld, cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, +history: History, navigation: Navigation, page: ?*Page = null, @@ -80,6 +82,7 @@ pub fn init(self: *Session, browser: *Browser) !void { .arena = session_allocator, .cookie_jar = storage.Cookie.Jar.init(allocator), .navigation = Navigation.init(session_allocator), + .history = .{}, .transfer_arena = browser.transfer_arena.allocator(), }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 5fccd42c7..3358cc535 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -569,6 +569,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), @import("../webapi/event/PageTransitionEvent.zig"), + @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 11da60fdb..0a49655b3 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -58,6 +58,7 @@ pub const Type = union(enum) { composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), + pop_state_event: *@import("event/PopStateEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index d80fe3ba7..214f7230e 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -20,67 +20,75 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const PopStateEvent = @import("event/PopStateEvent.zig"); const History = @This(); -_page: *Page, -_length: u32 = 1, -_state: ?js.Object = null, - -pub fn init(page: *Page) History { - return .{ - ._page = page, - }; +pub fn getLength(_: *const History, page: *Page) u32 { + return @intCast(page._session.navigation._entries.items.len); } -pub fn deinit(self: *History) void { - if (self._state) |state| { - js.q.JS_FreeValue(self._page.js.ctx, state.value); - } +pub fn getState(_: *const History, page: *Page) !?js.Value { + if (page._session.navigation.getCurrentEntry()._state.value) |state| { + const value = try js.Value.fromJson(page.js, state); + return value; + } else return null; } -pub fn getLength(self: *const History) u32 { - return self._length; -} +pub fn pushState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page._session.arena; + const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); -pub fn getState(self: *const History) ?js.Object { - return self._state; + const json = state.toJson(arena) catch return error.DateClone; + _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true); } -pub fn pushState(self: *History, state: js.Object, _title: []const u8, url: ?[]const u8, page: *Page) !void { - _ = _title; // title is ignored in modern browsers - _ = url; // For minimal implementation, we don't actually navigate - _ = page; +pub fn replaceState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page._session.arena; + const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); - self._state = try state.persist(); - self._length += 1; + const json = state.toJson(arena) catch return error.DateClone; + _ = try page._session.navigation.replaceEntry(url, .{ .source = .history, .value = json }, page, true); } -pub fn replaceState(self: *History, state: js.Object, _title: []const u8, url: ?[]const u8, page: *Page) !void { - _ = _title; - _ = url; - _ = page; - self._state = try state.persist(); - // Note: replaceState doesn't change length +fn goInner(delta: i32, page: *Page) !void { + // 0 behaves the same as no argument, both reloadig the page. + + const current = page._session.navigation._index; + const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta))); + if (index_s < 0 or index_s > page._session.navigation._entries.items.len - 1) { + return; + } + + const index = @as(usize, @intCast(index_s)); + const entry = page._session.navigation._entries.items[index]; + + if (entry._url) |url| { + if (try page.isSameOrigin(url)) { + const event = try PopStateEvent.init("popstate", .{ .state = entry._state.value }, page); + + try page._event_manager.dispatchWithFunction( + page.window.asEventTarget(), + event.asEvent(), + page.window._on_popstate, + .{ .context = "Pop State" }, + ); + } + } + + _ = try page._session.navigation.navigateInner(entry._url, .{ .traverse = index }, page); } -pub fn back(self: *History, page: *Page) void { - _ = self; - _ = page; - // Minimal implementation: no-op +pub fn back(_: *History, page: *Page) !void { + try goInner(-1, page); } -pub fn forward(self: *History, page: *Page) void { - _ = self; - _ = page; - // Minimal implementation: no-op +pub fn forward(_: *History, page: *Page) !void { + try goInner(1, page); } -pub fn go(self: *History, delta: i32, page: *Page) void { - _ = self; - _ = delta; - _ = page; - // Minimal implementation: no-op +pub fn go(_: *History, delta: ?i32, page: *Page) !void { + try goInner(delta orelse 0, page); } pub const JsApi = struct { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d6b83bb40..72db12651 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -52,10 +52,10 @@ _console: Console = .init, _navigator: Navigator = .init, _screen: Screen = .init, _performance: Performance, -_history: History, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, _on_pageshow: ?js.Function = null, +_on_popstate: ?js.Function = null, _on_error: ?js.Function = null, // TODO: invoke on error? _on_unhandled_rejection: ?js.Function = null, // TODO: invoke on error _location: *Location, @@ -115,8 +115,8 @@ pub fn getLocation(self: *const Window) *Location { return self._location; } -pub fn getHistory(self: *Window) *History { - return &self._history; +pub fn getHistory(_: *Window, page: *Page) *History { + return &page._session.history; } pub fn getNavigation(_: *Window, page: *Page) *Navigation { @@ -151,6 +151,18 @@ pub fn setOnPageShow(self: *Window, cb_: ?js.Function) !void { } } +pub fn getOnPopState(self: *const Window) ?js.Function { + return self._on_popstate; +} + +pub fn setOnPopState(self: *Window, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_popstate = cb; + } else { + self._on_popstate = null; + } +} + pub fn getOnError(self: *const Window) ?js.Function { return self._on_error; } @@ -504,13 +516,14 @@ pub const JsApi = struct { pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" }); pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" }); pub const location = bridge.accessor(Window.getLocation, null, .{ .cache = "location" }); - pub const history = bridge.accessor(Window.getHistory, null, .{ .cache = "history" }); + pub const history = bridge.accessor(Window.getHistory, null, .{}); pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" }); pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); + pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const fetch = bridge.function(Window.fetch, .{}); diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig new file mode 100644 index 000000000..f6d7ce0f6 --- /dev/null +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -0,0 +1,72 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const log = @import("../../../log.zig"); +// const Window = @import("../html/window.zig").Window; +const Event = @import("../Event.zig"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent +const PopStateEvent = @This(); + +const EventOptions = struct { + state: ?[]const u8 = null, +}; + +_proto: *Event, +_state: ?[]const u8, + +pub fn init(typ: []const u8, _options: ?EventOptions, page: *Page) !*PopStateEvent { + const options = _options orelse EventOptions{}; + + return page._factory.event(typ, PopStateEvent{ + ._proto = undefined, + ._state = options.state, + }); +} + +pub fn asEvent(self: *PopStateEvent) *Event { + return self._proto; +} + +pub fn getState(self: *PopStateEvent, page: *Page) !?js.Value { + if (self._state == null) return null; + + const value = try js.Value.fromJson(page.js, self._state.?); + return value; +} + +pub fn getUAVisualTransition(_: *PopStateEvent) bool { + // Not currently supported so we always return false; + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(PopStateEvent); + + pub const Meta = struct { + pub const name = "PopStateEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(PopStateEvent.init, .{}); + pub const state = bridge.accessor(PopStateEvent.getState, null, .{}); + pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.getUAVisualTransition, null, .{}); +}; From ac85341cab9301d4260fd8246862976e1dcf0292 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:48 -0800 Subject: [PATCH 186/257] add NavigationKind to navigate --- src/browser/Page.zig | 7 +++-- src/browser/Session.zig | 6 +++- src/browser/webapi/Location.zig | 2 +- src/browser/webapi/navigation/Navigation.zig | 29 ++++++++++++-------- src/cdp/domains/page.zig | 2 +- src/cdp/domains/target.zig | 8 ++++-- src/cdp/testing.zig | 4 +-- src/lightpanda.zig | 2 +- src/main_legacy_test.zig | 2 +- src/main_wpt.zig | 2 +- src/testing.zig | 4 +-- 11 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 75b644135..0fd2c7508 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -58,6 +58,7 @@ const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const storage = @import("webapi/storage/storage.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); +const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; @@ -270,7 +271,7 @@ fn registerBackgroundTasks(self: *Page) !void { }.runMessageLoop, 250, .{ .name = "page.messageLoop" }); } -pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { +pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self._session; const resolved_url = try URL.resolve( @@ -292,7 +293,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; - try session.navigation.updateEntries("", .{ .push = null }, self, true); + try session.navigation.updateEntries(resolved_url, kind, self, true); return; } } @@ -353,6 +354,8 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .timestamp = timestamp(.monotonic), }); + session.navigation._current_navigation_kind = kind; + http_client.request(.{ .ctx = self, .url = self.url, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 5f25ae857..3c599c393 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -191,7 +191,11 @@ fn processQueuedNavigation(self: *Session) !bool { return err; }; - page.navigate(qn.url, qn.opts) catch |err| { + page.navigate( + qn.url, + qn.opts, + self.navigation._current_navigation_kind orelse .{ .push = null }, + ) catch |err| { log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url }); return err; }; diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 205d1732e..c2c2b1a8f 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -81,7 +81,7 @@ pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void { }; const duped_hash = try page.arena.dupeZ(u8, normalized_hash); - return page.navigate(duped_hash, .{ .reason = .script }); + return page.navigate(duped_hash, .{ .reason = .script }, .{ .replace = null }); } pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index e19e8b03f..2f2b284fc 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -92,7 +92,6 @@ pub fn back(self: *Navigation, page: *Page) !NavigationReturn { const new_index = self._index - 1; const next_entry = self._entries.items[new_index]; - self._index = new_index; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } @@ -108,7 +107,6 @@ pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { const new_index = self._index + 1; const next_entry = self._entries.items[new_index]; - self._index = new_index; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } @@ -132,7 +130,10 @@ pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, // This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct. pub fn processNavigation(self: *Navigation, page: *Page) !void { const url = page.url; + const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; + defer self._current_navigation_kind = null; + try self.updateEntries(url, kind, page, false); } @@ -247,9 +248,11 @@ pub fn navigateInner( const committed = try page.js.createPromiseResolver(.page); const finished = try page.js.createPromiseResolver(.page); - const new_url = try URL.resolve(arena, url, page.url, .{}); + const new_url = try URL.resolve(arena, page.url, url, .{}); const is_same_document = URL.eqlDocument(new_url, page.url); + const previous = self.getCurrentEntry(); + switch (kind) { .push => |state| { if (is_same_document) { @@ -261,8 +264,7 @@ pub fn navigateInner( _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .replace => |state| { @@ -275,8 +277,7 @@ pub fn navigateInner( _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .traverse => |index| { @@ -289,16 +290,22 @@ pub fn navigateInner( // todo: Fire navigate event finished.resolve("navigation traverse", {}); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .reload => { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); }, } + // If we haven't navigated off, let us fire off an a currententrychange. + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = previous, .navigationType = @tagName(kind) }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); + return .{ .committed = committed.promise(), .finished = finished.promise(), diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index bc961fe16..c27d3de78 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -183,7 +183,7 @@ fn navigate(cmd: anytype) !void { try page.navigate(params.url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, - }); + }, .{ .push = null }); } pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.PageNavigate) !void { diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index b59285afb..d6f672e29 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -179,9 +179,11 @@ fn createTarget(cmd: anytype) !void { try doAttachtoTarget(cmd, target_id); } - try page.navigate(params.url, .{ - .reason = .address_bar, - }); + try page.navigate( + params.url, + .{ .reason = .address_bar }, + .{ .push = null }, + ); try cmd.sendResult(.{ .targetId = target_id, diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 8d459a9f4..b575d4f66 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -127,10 +127,10 @@ const TestContext = struct { const full_url = try std.fmt.allocPrintSentinel( self.arena.allocator(), "http://127.0.0.1:9582/src/browser/tests/{s}", - .{ url }, + .{url}, 0, ); - try page.navigate(full_url, .{}); + try page.navigate(full_url, .{}, .{ .push = null }); bc.session.fetchWait(2000); } return bc; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index c7e714b1e..55ec5df7f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -60,7 +60,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { // } // } - _ = try page.navigate(url, .{}); + _ = try page.navigate(url, .{}, .{ .push = null }); _ = session.fetchWait(opts.wait_ms); const writer = opts.writer orelse return; diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 0c409c357..5b1cf8628 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -85,7 +85,7 @@ pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { try_catch.init(js_context); defer try_catch.deinit(); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); session.fetchWait(2000); page._session.browser.runMicrotasks(); diff --git a/src/main_wpt.zig b/src/main_wpt.zig index ff512e408..5e334a224 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -114,7 +114,7 @@ fn run( defer session.removePage(); const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); _ = page.wait(2000); diff --git a/src/testing.zig b/src/testing.zig index 452ce812d..f3c1aeac3 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -403,7 +403,7 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(js_context); defer try_catch.deinit(); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); page._session.browser.runMicrotasks(); @@ -428,7 +428,7 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { 0, ); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); return page; } From 395f93240d0d878ff12c472cf4783f750880b2ae Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:10:18 -0800 Subject: [PATCH 187/257] minor Navigation style changes --- src/browser/webapi/Window.zig | 2 +- .../webapi/event/NavigationCurrentEntryChangeEvent.zig | 2 +- src/browser/webapi/event/PageTransitionEvent.zig | 4 +--- src/browser/webapi/event/PopStateEvent.zig | 6 +++--- src/browser/webapi/navigation/Navigation.zig | 4 ++-- src/browser/webapi/navigation/NavigationEventTarget.zig | 2 +- src/browser/webapi/navigation/NavigationHistoryEntry.zig | 2 +- src/browser/webapi/navigation/root.zig | 2 +- 8 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 72db12651..792ca8ddf 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -24,7 +24,7 @@ const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); -const Navigation = @import("../webapi/navigation/Navigation.zig"); +const Navigation = @import("navigation/Navigation.zig"); const Crypto = @import("Crypto.zig"); const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index ead742e31..f5d2a9358 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index f4cebf549..44b2c9e71 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -47,8 +47,6 @@ pub fn getPersisted(self: *PageTransitionEvent) bool { return self._persisted; } -const PageTransitionKind = enum { show, hide }; - pub const JsApi = struct { pub const bridge = js.Bridge(PageTransitionEvent); diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index f6d7ce0f6..3ecffb997 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -52,7 +52,7 @@ pub fn getState(self: *PopStateEvent, page: *Page) !?js.Value { return value; } -pub fn getUAVisualTransition(_: *PopStateEvent) bool { +pub fn hasUAVisualTransition(_: *PopStateEvent) bool { // Not currently supported so we always return false; return false; } @@ -68,5 +68,5 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(PopStateEvent.init, .{}); pub const state = bridge.accessor(PopStateEvent.getState, null, .{}); - pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.getUAVisualTransition, null, .{}); + pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.hasUAVisualTransition, null, .{}); }; diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 2f2b284fc..28016bd38 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -42,7 +42,7 @@ _current_navigation_kind: ?NavigationKind = null, _index: usize = 0, // Need to be stable pointers, because Events can reference entries. -_entries: std.ArrayListUnmanaged(*NavigationHistoryEntry) = .empty, +_entries: std.ArrayList(*NavigationHistoryEntry) = .empty, _next_entry_id: usize = 0, pub fn init(arena: std.mem.Allocator) Navigation { diff --git a/src/browser/webapi/navigation/NavigationEventTarget.zig b/src/browser/webapi/navigation/NavigationEventTarget.zig index 1e9b0bd48..0872d7fb5 100644 --- a/src/browser/webapi/navigation/NavigationEventTarget.zig +++ b/src/browser/webapi/navigation/NavigationEventTarget.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 86dc295c7..0f3289b27 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/navigation/root.zig b/src/browser/webapi/navigation/root.zig index e2a5c1733..d611cf9fc 100644 --- a/src/browser/webapi/navigation/root.zig +++ b/src/browser/webapi/navigation/root.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire From 6534dc4c4ff4a7ec2600f5bdaf8f97c0834b3473 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:11:15 -0800 Subject: [PATCH 188/257] use Navigation ptr instead of fat copy --- src/browser/webapi/navigation/NavigationHistoryEntry.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 0f3289b27..2411a7418 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -52,7 +52,7 @@ pub fn id(self: *const NavigationHistoryEntry) []const u8 { } pub fn index(self: *const NavigationHistoryEntry, page: *Page) i32 { - const navigation = page._session.navigation; + const navigation = &page._session.navigation; for (navigation._entries.items, 0..) |entry, i| { if (std.mem.eql(u8, entry._id, self._id)) { From 3662d1681ec2da68841df159bf1ca7474d76121a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:13:17 -0800 Subject: [PATCH 189/257] no need to run microtasks before onload --- src/browser/Page.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0fd2c7508..904f03b70 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -429,9 +429,6 @@ pub fn documentIsComplete(self: *Page) void { fn _documentIsComplete(self: *Page) !void { self.document._ready_state = .complete; - self._session.browser.runMicrotasks(); - self._session.browser.runMessageLoop(); - // dispatch window.load event const event = try Event.init("load", .{}, self); // this event is weird, it's dispatched directly on the window, but From ddb83cf9c52fc60168c63a61710a8668a644fefe Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:17:54 -0800 Subject: [PATCH 190/257] add assert and note on getCurrentEntry --- src/browser/webapi/navigation/Navigation.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 28016bd38..49de232e8 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -72,6 +72,10 @@ pub fn getCanGoForward(self: *const Navigation) bool { } pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { + // This should never fail. An entry should always be created before + // we run the scripts on the page we are loading. + std.debug.assert(self._entries.items.len > 0); + return self._entries.items[self._index]; } From 7c9d7259e6cf6089eac1dfd4b181e7f86d09c64a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:47:09 -0800 Subject: [PATCH 191/257] add NavigationActivation --- src/browser/Page.zig | 5 +- src/browser/js/bridge.zig | 1 + src/browser/webapi/navigation/Navigation.zig | 34 +++++++++-- .../navigation/NavigationActivation.zig | 56 +++++++++++++++++++ src/browser/webapi/navigation/root.zig | 23 ++------ 5 files changed, 90 insertions(+), 29 deletions(-) create mode 100644 src/browser/webapi/navigation/NavigationActivation.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 904f03b70..2b81cf970 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -530,7 +530,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { self.clearTransferArena(); //We need to handle different navigation types differently. - try self._session.navigation.processNavigation(self); + try self._session.navigation.commitNavigation(self); defer if (comptime IS_DEBUG) { log.debug(.page, "page.load.complete", .{ .url = self.url }); @@ -567,9 +567,6 @@ fn pageDoneCallback(ctx: *anyopaque) !void { }, else => unreachable, } - // We need to handle different navigation types differently. - // @ZIGDOM - // try self._session.navigation.processNavigation(self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3358cc535..b79622d06 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -605,4 +605,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/navigation/Navigation.zig"), @import("../webapi/navigation/NavigationEventTarget.zig"), @import("../webapi/navigation/NavigationHistoryEntry.zig"), + @import("../webapi/navigation/NavigationActivation.zig"), }); diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 49de232e8..424c8d784 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -29,10 +29,11 @@ const EventTarget = @import("../EventTarget.zig"); const Navigation = @This(); const NavigationKind = @import("root.zig").NavigationKind; -const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); +const NavigationActivation = @import("NavigationActivation.zig"); const NavigationTransition = @import("root.zig").NavigationTransition; const NavigationState = @import("root.zig").NavigationState; +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEntryChangeEvent.zig"); const NavigationEventTarget = @import("NavigationEventTarget.zig"); @@ -44,6 +45,7 @@ _index: usize = 0, // Need to be stable pointers, because Events can reference entries. _entries: std.ArrayList(*NavigationHistoryEntry) = .empty, _next_entry_id: usize = 0, +_activation: ?NavigationActivation = null, pub fn init(arena: std.mem.Allocator) Navigation { return Navigation{ ._arena = arena }; @@ -63,6 +65,10 @@ pub fn onNewPage(self: *Navigation, page: *Page) !void { ); } +pub fn getActivation(self: *const Navigation) ?NavigationActivation { + return self._activation; +} + pub fn getCanGoBack(self: *const Navigation) bool { return self._index > 0; } @@ -71,12 +77,18 @@ pub fn getCanGoForward(self: *const Navigation) bool { return self._entries.items.len > self._index + 1; } +pub fn getCurrentEntryOrNull(self: *Navigation) ?*NavigationHistoryEntry { + if (self._entries.items.len > self._index) { + return self._entries.items[self._index]; + } else return null; +} + pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { // This should never fail. An entry should always be created before // we run the scripts on the page we are loading. std.debug.assert(self._entries.items.len > 0); - return self._entries.items[self._index]; + return self.getCurrentEntryOrNull().?; } pub fn getTransition(_: *const Navigation) ?NavigationTransition { @@ -117,8 +129,8 @@ pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, page: *Page, dispatch: bool) !void { switch (kind) { - .replace => { - _ = try self.replaceEntry(url, .{ .source = .navigation, .value = null }, page, dispatch); + .replace => |state| { + _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, dispatch); }, .push => |state| { _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, dispatch); @@ -131,14 +143,23 @@ pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, } // This is for after true navigation processing, where we need to ensure that our entries are up to date. -// This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct. -pub fn processNavigation(self: *Navigation, page: *Page) !void { +// +// This is only really safe to run in the `pageDoneCallback` +// where we can guarantee that the URL and NavigationKind are correct. +pub fn commitNavigation(self: *Navigation, page: *Page) !void { const url = page.url; const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; defer self._current_navigation_kind = null; + const from_entry = self.getCurrentEntryOrNull(); try self.updateEntries(url, kind, page, false); + + self._activation = NavigationActivation{ + ._from = from_entry, + ._entry = self.getCurrentEntry(), + ._type = kind.toNavigationType(), + }; } /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. @@ -401,6 +422,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const activation = bridge.accessor(Navigation.getActivation, null, .{}); pub const canGoBack = bridge.accessor(Navigation.getCanGoBack, null, .{}); pub const canGoForward = bridge.accessor(Navigation.getCanGoForward, null, .{}); pub const currentEntry = bridge.accessor(Navigation.getCurrentEntry, null, .{}); diff --git a/src/browser/webapi/navigation/NavigationActivation.zig b/src/browser/webapi/navigation/NavigationActivation.zig new file mode 100644 index 000000000..3c161f9bf --- /dev/null +++ b/src/browser/webapi/navigation/NavigationActivation.zig @@ -0,0 +1,56 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); + +const NavigationType = @import("root.zig").NavigationType; +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation +const NavigationActivation = @This(); + +_entry: *NavigationHistoryEntry, +_from: ?*NavigationHistoryEntry = null, +_type: NavigationType, + +pub fn getEntry(self: *const NavigationActivation) *NavigationHistoryEntry { + return self._entry; +} + +pub fn getFrom(self: *const NavigationActivation) ?*NavigationHistoryEntry { + return self._from; +} + +pub fn getNavigationType(self: *const NavigationActivation) []const u8 { + return @tagName(self._type); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigationActivation); + + pub const Meta = struct { + pub const name = "NavigationActivation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const entry = bridge.accessor(NavigationActivation.getEntry, null, .{}); + pub const from = bridge.accessor(NavigationActivation.getFrom, null, .{}); + pub const navigationType = bridge.accessor(NavigationActivation.getNavigationType, null, .{}); +}; diff --git a/src/browser/webapi/navigation/root.zig b/src/browser/webapi/navigation/root.zig index d611cf9fc..ef8b20e2c 100644 --- a/src/browser/webapi/navigation/root.zig +++ b/src/browser/webapi/navigation/root.zig @@ -38,6 +38,10 @@ pub const NavigationKind = union(NavigationType) { replace: ?[]const u8, traverse: usize, reload, + + pub fn toNavigationType(self: NavigationKind) NavigationType { + return std.meta.activeTag(self); + } }; pub const NavigationState = struct { @@ -45,25 +49,6 @@ pub const NavigationState = struct { value: ?[]const u8, }; -// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation -pub const NavigationActivation = struct { - entry: NavigationHistoryEntry, - from: ?NavigationHistoryEntry = null, - type: NavigationType, - - pub fn get_entry(self: *const NavigationActivation) NavigationHistoryEntry { - return self.entry; - } - - pub fn get_from(self: *const NavigationActivation) ?NavigationHistoryEntry { - return self.from; - } - - pub fn get_navigationType(self: *const NavigationActivation) NavigationType { - return self.type; - } -}; - // https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition pub const NavigationTransition = struct { finished: js.Promise, From 02a0727870cfece2c75c294bc16d2fa28cd63922 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:51:06 -0800 Subject: [PATCH 192/257] eqlDocument slicing at hash --- src/browser/URL.zig | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 11739b070..4a399a3b1 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -272,14 +272,10 @@ pub fn getHost(raw: [:0]const u8) []const u8 { // Returns true if these two URLs point to the same document. pub fn eqlDocument(first: [:0]const u8, second: [:0]const u8) bool { - if (!std.mem.eql(u8, getProtocol(first), getProtocol(second))) return false; - if (!std.mem.eql(u8, getHost(first), getHost(second))) return false; - if (!std.mem.eql(u8, getPort(first), getPort(second))) return false; - if (!std.mem.eql(u8, getPathname(first), getPathname(second))) return false; - if (!std.mem.eql(u8, getSearch(first), getSearch(second))) return false; - // hashes are allowed to be different. - - return true; + // First '#' signifies the start of the fragment. + const first_hash_index = std.mem.indexOfScalar(u8, first, '#') orelse first.len; + const second_hash_index = std.mem.indexOfScalar(u8, second, '#') orelse second.len; + return std.mem.eql(u8, first[0..first_hash_index], second[0..second_hash_index]); } const KnownProtocol = enum { From 27e58181fba2e0132b010048715fd0c018105328 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Dec 2025 15:28:24 +0800 Subject: [PATCH 193/257] Properly resolve inspector ObjectId back to a DOM Node Tweak element boundingRect and "renderer" based on what puppeteer needs. --- src/browser/Renderer.zig | 109 -------------------- src/browser/js/Inspector.zig | 18 +++- src/browser/webapi/Element.zig | 99 ++++++++++++------ src/browser/webapi/IntersectionObserver.zig | 45 ++------ src/cdp/domains/dom.zig | 19 ++-- 5 files changed, 101 insertions(+), 189 deletions(-) delete mode 100644 src/browser/Renderer.zig diff --git a/src/browser/Renderer.zig b/src/browser/Renderer.zig deleted file mode 100644 index 9a11dd32f..000000000 --- a/src/browser/Renderer.zig +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -const std = @import("std"); - -const parser = @import("netsurf.zig"); - -const Allocator = std.mem.Allocator; - -const Renderer = @This(); - -allocator: Allocator, - -// key is a @ptrFromInt of the element -// value is the index position -positions: std.AutoHashMapUnmanaged(u64, u32), - -// given an index, get the element -elements: std.ArrayListUnmanaged(u64), - -const Element = @import("dom/element.zig").Element; - -// we expect allocator to be an arena -pub fn init(allocator: Allocator) Renderer { - return .{ - .elements = .{}, - .positions = .{}, - .allocator = allocator, - }; -} - -// The DOMRect is always relative to the viewport, not the document the element belongs to. -// Element that are not part of the main document, either detached or in a shadow DOM should not call this function. -pub fn getRect(self: *Renderer, e: *parser.Element) !Element.DOMRect { - var elements = &self.elements; - const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e)); - var x: u32 = gop.value_ptr.*; - if (gop.found_existing == false) { - x = @intCast(elements.items.len); - try elements.append(self.allocator, @intFromPtr(e)); - gop.value_ptr.* = x; - } - - const _x: f64 = @floatFromInt(x); - const y: f64 = 0.0; - const w: f64 = 1.0; - const h: f64 = 1.0; - - return .{ - .x = _x, - .y = y, - .width = w, - .height = h, - .left = _x, - .top = y, - .right = _x + w, - .bottom = y + h, - }; -} - -pub fn boundingRect(self: *const Renderer) Element.DOMRect { - const x: f64 = 0.0; - const y: f64 = 0.0; - const w: f64 = @floatFromInt(self.width()); - const h: f64 = @floatFromInt(self.width()); - - return .{ - .x = x, - .y = y, - .width = w, - .height = h, - .left = x, - .top = y, - .right = x + w, - .bottom = y + h, - }; -} - -pub fn width(self: *const Renderer) u32 { - return @max(@as(u32, @intCast(self.elements.items.len)), 1); // At least 1 pixel even if empty -} - -pub fn height(_: *const Renderer) u32 { - return 1; -} - -pub fn getElementAtPosition(self: *const Renderer, x: i32, y: i32) ?*parser.Element { - if (y != 0 or x < 0) { - return null; - } - - const elements = self.elements.items; - return if (x < elements.len) @ptrFromInt(elements[@intCast(x)]) else null; -} diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index 04a8c5c8d..d53820d84 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -123,14 +123,24 @@ pub fn getRemoteObject( } // Gets a value by object ID regardless of which context it is in. -pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !?*anyopaque { +// Our TaggedAnyOpaque stores the "resolved" ptr value (the most specific _type, +// e.g. we store the ptr to the Div not the EventTarget). But, this is asking for +// the pointer to the Node, so we need to use the same resolution mechanism which +// is used when we're calling a function to turn the Div into a Node, which is +// what Context.typeTaggedAnyOpaque does. +pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !*anyopaque { const unwrapped = try self.session.unwrapObject(allocator, object_id); // The values context and groupId are not used here - const toa = getTaggedAnyOpaque(unwrapped.value) orelse return null; - if (toa.subtype == null or toa.subtype != .node) { + const js_val = unwrapped.value; + if (js_val.isObject() == false) { + std.debug.print("XX-0\n", .{}); return error.ObjectIdIsNotANode; } - return toa.value; + const Node = @import("../webapi/Node.zig"); + return Context.typeTaggedAnyOpaque(*Node, js_val.castTo(v8.Object)) catch { + std.debug.print("XX-1\n", .{}); + return error.ObjectIdIsNotANode; + }; } const NoopInspector = struct { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 8022cea8e..f38d21a00 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -755,9 +755,53 @@ pub fn checkVisibility(self: *Element, page: *Page) !bool { return true; } +fn getElementDimensions(self: *Element, page: *Page) !struct { width: f64, height: f64 } { + const style = try self.getStyle(page); + const decl = style.asCSSStyleDeclaration(); + var width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 5.0; + var height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 5.0; + + if (width == 5.0 or height == 5.0) { + const tag = self.getTag(); + + // Root containers get large default size to contain descendant positions. + // With calculateDocumentPosition using 10x multipliers per level, deep trees + // can position elements at y=millions, so we need a large container height. + // 100M pixels is plausible for very long documents. + if (tag == .html or tag == .body) { + if (width == 5.0) width = 1920.0; + if (height == 5.0) height = 100_000_000.0; + } else if (tag == .img or tag == .iframe) { + if (self.getAttributeSafe("width")) |w| { + width = std.fmt.parseFloat(f64, w) catch width; + } + if (self.getAttributeSafe("height")) |h| { + height = std.fmt.parseFloat(f64, h) catch height; + } + } + } + + return .{ .width = width, .height = height }; +} + +pub fn getClientWidth(self: *Element, page: *Page) !f64 { + if (!try self.checkVisibility(page)) { + return 0.0; + } + const dims = try self.getElementDimensions(page); + return dims.width; +} + +pub fn getClientHeight(self: *Element, page: *Page) !f64 { + if (!try self.checkVisibility(page)) { + return 0.0; + } + const dims = try self.getElementDimensions(page); + return dims.height; +} + pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { - const is_visible = try self.checkVisibility(page); - if (!is_visible) { + if (!try self.checkVisibility(page)) { return page._factory.create(DOMRect{ ._x = 0.0, ._y = 0.0, @@ -771,38 +815,19 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { } const y = calculateDocumentPosition(self.asNode()); - - var width: f64 = 1.0; - var height: f64 = 1.0; - - const style = try self.getStyle(page); - const decl = style.asCSSStyleDeclaration(); - width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 1.0; - height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 1.0; - - if (width == 1.0 or height == 1.0) { - const tag = self.getTag(); - if (tag == .img or tag == .iframe) { - if (self.getAttributeSafe("width")) |w| { - width = std.fmt.parseFloat(f64, w) catch width; - } - if (self.getAttributeSafe("height")) |h| { - height = std.fmt.parseFloat(f64, h) catch height; - } - } - } + const dims = try self.getElementDimensions(page); const x: f64 = 0.0; const top = y; const left = x; - const right = x + width; - const bottom = y + height; + const right = x + dims.width; + const bottom = y + dims.height; return page._factory.create(DOMRect{ ._x = x, ._y = y, - ._width = width, - ._height = height, + ._width = dims.width, + ._height = dims.height, ._top = top, ._right = right, ._bottom = bottom, @@ -810,11 +835,19 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { }); } +pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect { + if (!try self.checkVisibility(page)) { + return &.{}; + } + const ptr = try self.getBoundingClientRect(page); + return ptr[0..1]; +} + // Calculates a pseudo-position in the document using an efficient heuristic. // // Instead of walking the entire DOM tree (which would be O(total_nodes)), this // function walks UP the tree counting previous siblings at each level. Each level -// uses exponential weighting (1000x per depth level) to preserve document order. +// uses exponential weighting (10x per depth level) to preserve document order. // // This gives O(depth * avg_siblings) complexity while maintaining relative positioning // that's useful for scraping and understanding element flow in the document. @@ -825,15 +858,16 @@ pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect { // → position 0 (0 siblings at level 2) // → position 1 (1 sibling at level 2) // -//
→ position 1000 (1 sibling at level 1, weighted by 1000) -//

→ position 1000 (0 siblings at level 2, parent has 1000) +//
→ position 10 (1 sibling at level 1, weighted by 10) +//

→ position 10 (0 siblings at level 2, parent has 10) //
// // // Trade-offs: // - Much faster than full tree-walking for deep/large DOMs // - Positions reflect document order and parent-child relationships -// - Not pixel-accurate, but sufficient for 1x1 layout heuristics +// - Keeps positions within reasonable bounds (10-level deep tree → ~10M pixels) +// - Not pixel-accurate, but sufficient for layout heuristics fn calculateDocumentPosition(node: *Node) f64 { var position: f64 = 0.0; var multiplier: f64 = 1.0; @@ -849,7 +883,7 @@ fn calculateDocumentPosition(node: *Node) f64 { } position += count * multiplier; - multiplier *= 1000.0; + multiplier *= 10.0; current = parent; } @@ -1145,6 +1179,9 @@ pub const JsApi = struct { pub const getAnimations = bridge.function(Element.getAnimations, .{}); pub const animate = bridge.function(Element.animate, .{}); pub const checkVisibility = bridge.function(Element.checkVisibility, .{}); + pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{}); + pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{}); + pub const getClientRects = bridge.function(Element.getClientRects, .{}); pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{}); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{}); diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index c6940899e..4666e5266 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -142,7 +142,7 @@ fn calculateIntersection( ) !IntersectionData { const target_rect = try target.getBoundingClientRect(page); - // Use root element's rect or viewport (simplified: assume infinite viewport) + // Use root element's rect or viewport (simplified: assume 1920x1080) const root_rect = if (self._root) |root| try root.getBoundingClientRect(page) else @@ -158,46 +158,19 @@ fn calculateIntersection( ._left = 0.0, }); - // Calculate intersection rectangle - const left = @max(target_rect._left, root_rect._left); - const top = @max(target_rect._top, root_rect._top); - const right = @min(target_rect._right, root_rect._right); - const bottom = @min(target_rect._bottom, root_rect._bottom); + // For a headless browser without real layout, we treat all elements as fully visible. + // This avoids fingerprinting issues (massive viewports) and matches the behavior + // scripts expect when querying element visibility. + const is_intersecting = true; + const intersection_ratio: f64 = 1.0; - const is_intersecting = left < right and top < bottom; - - var intersection_rect: ?*DOMRect = null; - var intersection_ratio: f64 = 0.0; - - if (is_intersecting) { - const width = right - left; - const height = bottom - top; - const intersection_area = width * height; - const target_area = target_rect._width * target_rect._height; - - if (target_area > 0) { - intersection_ratio = intersection_area / target_area; - } - - intersection_rect = try page._factory.create(DOMRect{ - ._x = left, - ._y = top, - ._width = width, - ._height = height, - ._top = top, - ._right = right, - ._bottom = bottom, - ._left = left, - }); - } else { - // No intersection - reuse shared zero rect to avoid allocation - intersection_rect = &zero_rect; - } + // Intersection rect is the same as the target rect (fully visible) + const intersection_rect = target_rect; return .{ .is_intersecting = is_intersecting, .intersection_ratio = intersection_ratio, - .intersection_rect = intersection_rect.?, + .intersection_rect = intersection_rect, .bounding_client_rect = target_rect, .root_bounds = root_rect, }; diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 791f94585..7fa429fb6 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -429,12 +429,13 @@ fn getBoxModel(cmd: anytype) !void { const rect = try element.getBoundingClientRect(page); const quad = rectToQuad(rect); + const zero = [_]f64{0.0} ** 8; return cmd.sendResult(.{ .model = BoxModel{ .content = quad, - .padding = quad, - .border = quad, - .margin = quad, + .padding = zero, + .border = zero, + .margin = zero, .width = @intFromFloat(rect._width), .height = @intFromFloat(rect._height), } }, .{}); @@ -649,11 +650,11 @@ test "cdp.dom: getBoxModel" { .params = .{ .nodeId = 6 }, }); try ctx.expectSentResult(.{ .model = BoxModel{ - .content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 }, - .width = 1, - .height = 1, + .content = Quad{ 0.0, 0.0, 5.0, 0.0, 5.0, 5.0, 0.0, 5.0 }, + .padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, + .border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, + .margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, + .width = 5, + .height = 5, } }, .{ .id = 5 }); } From 9c8299f13f0b0da1a2084d40b085745a489765a0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Dec 2025 16:39:27 +0800 Subject: [PATCH 194/257] Change to linear scaling for renderer. With the previous exponential approach, a deep site (the deepest element in amazon's product page is 36 levels deep) would be unrealistic. --- src/browser/webapi/Element.zig | 54 +++++++++++++++++----------------- src/cdp/domains/dom.zig | 2 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f38d21a00..4c9287d54 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -765,8 +765,8 @@ fn getElementDimensions(self: *Element, page: *Page) !struct { width: f64, heigh const tag = self.getTag(); // Root containers get large default size to contain descendant positions. - // With calculateDocumentPosition using 10x multipliers per level, deep trees - // can position elements at y=millions, so we need a large container height. + // With calculateDocumentPosition using linear depth scaling (100px per level), + // even very deep trees (100 levels) stay within 10,000px. // 100M pixels is plausible for very long documents. if (tag == .html or tag == .body) { if (width == 5.0) width = 1920.0; @@ -843,51 +843,51 @@ pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect { return ptr[0..1]; } -// Calculates a pseudo-position in the document using an efficient heuristic. +// Calculates a pseudo-position in the document using linear depth scaling. // -// Instead of walking the entire DOM tree (which would be O(total_nodes)), this -// function walks UP the tree counting previous siblings at each level. Each level -// uses exponential weighting (10x per depth level) to preserve document order. -// -// This gives O(depth * avg_siblings) complexity while maintaining relative positioning -// that's useful for scraping and understanding element flow in the document. +// This approach uses a fixed pixel offset per depth level (100px) plus sibling +// position within that level. This keeps positions reasonable even for very deep +// DOM trees (e.g., Amazon product pages can be 36+ levels deep). // // Example: -// → position 0 -//
→ position 0 (0 siblings at level 1) -// → position 0 (0 siblings at level 2) -// → position 1 (1 sibling at level 2) +// → position 0 (depth 0) +//
→ position 100 (depth 1, 0 siblings) +// → position 200 (depth 2, 0 siblings) +// → position 201 (depth 2, 1 sibling) //
-//
→ position 10 (1 sibling at level 1, weighted by 10) -//

→ position 10 (0 siblings at level 2, parent has 10) +//
→ position 101 (depth 1, 1 sibling) +//

→ position 200 (depth 2, 0 siblings) //
// // // Trade-offs: -// - Much faster than full tree-walking for deep/large DOMs -// - Positions reflect document order and parent-child relationships -// - Keeps positions within reasonable bounds (10-level deep tree → ~10M pixels) -// - Not pixel-accurate, but sufficient for layout heuristics +// - O(depth) complexity, very fast +// - Linear scaling: 36 levels ≈ 3,600px, 100 levels ≈ 10,000px +// - Rough document order preserved (depth dominates, siblings differentiate) +// - Fits comfortably in realistic document heights fn calculateDocumentPosition(node: *Node) f64 { - var position: f64 = 0.0; - var multiplier: f64 = 1.0; + var depth: f64 = 0.0; + var sibling_offset: f64 = 0.0; var current = node; - while (current.parentNode()) |parent| { - var count: f64 = 0.0; + // Count siblings at the immediate level + if (current.parentNode()) |parent| { var sibling = parent.firstChild(); while (sibling) |s| { if (s == current) break; - count += 1.0; + sibling_offset += 1.0; sibling = s.nextSibling(); } + } - position += count * multiplier; - multiplier *= 10.0; + // Count depth from root + while (current.parentNode()) |parent| { + depth += 1.0; current = parent; } - return position; + // Each depth level = 100px, siblings add within that level + return (depth * 100.0) + sibling_offset; } const GetElementsByTagNameResult = union(enum) { diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 7fa429fb6..4629bfc40 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -650,7 +650,7 @@ test "cdp.dom: getBoxModel" { .params = .{ .nodeId = 6 }, }); try ctx.expectSentResult(.{ .model = BoxModel{ - .content = Quad{ 0.0, 0.0, 5.0, 0.0, 5.0, 5.0, 0.0, 5.0 }, + .content = Quad{ 0.0, 200.0, 5.0, 200.0, 5.0, 205.0, 0.0, 205.0 }, .padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, .border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, .margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, From 159165490d4dbc6c4e1015a95809f585e9309810 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Dec 2025 17:56:49 +0800 Subject: [PATCH 195/257] Allow event listener to remove itself or other pending listeners --- src/browser/EventManager.zig | 41 ++++++++- src/browser/js/Inspector.zig | 2 - src/browser/tests/events.html | 162 ++++++++++++++++++++++++++++++++-- 3 files changed, 195 insertions(+), 10 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 408d67446..0b4e3a6de 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -39,6 +39,7 @@ page: *Page, arena: Allocator, listener_pool: std.heap.MemoryPool(Listener), lookup: std.AutoHashMapUnmanaged(usize, std.DoublyLinkedList), +dispatch_depth: u32 = 0, pub fn init(page: *Page) EventManager { return .{ @@ -46,6 +47,7 @@ pub fn init(page: *Page) EventManager { .lookup = .{}, .arena = page.arena, .listener_pool = std.heap.MemoryPool(Listener).init(page.arena), + .dispatch_depth = 0, }; } @@ -247,12 +249,27 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe const page = self.page; const typ = event._type_string; + // Track that we're dispatching to prevent immediate removal + self.dispatch_depth += 1; + defer { + self.dispatch_depth -= 1; + // Clean up any marked listeners in this target's list after this phase + // We do this regardless of depth to handle cross-target removals correctly + self.cleanupMarkedListeners(list); + } + var node = list.first; while (node) |n| { // do this now, in case we need to remove n (once: true or aborted signal) node = n.next; const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); + + // Skip listeners that were marked for removal + if (listener.marked_for_removal) { + continue; + } + if (!listener.typ.eql(typ)) { continue; } @@ -310,8 +327,27 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: } fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { - list.remove(&listener.node); - self.listener_pool.destroy(listener); + if (self.dispatch_depth > 0) { + // We're in the middle of dispatching, just mark for removal + // This prevents invalidating the linked list during iteration + listener.marked_for_removal = true; + } else { + // Safe to remove immediately + list.remove(&listener.node); + self.listener_pool.destroy(listener); + } +} + +fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void { + var node = list.first; + while (node) |n| { + node = n.next; + const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); + if (listener.marked_for_removal) { + list.remove(&listener.node); + self.listener_pool.destroy(listener); + } + } } fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, function: js.Function, capture: bool) ?*Listener { @@ -341,6 +377,7 @@ const Listener = struct { function: Function, signal: ?*@import("webapi/AbortSignal.zig") = null, node: std.DoublyLinkedList.Node, + marked_for_removal: bool = false, }; const Function = union(enum) { diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index d53820d84..1da5be808 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -133,12 +133,10 @@ pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []con // The values context and groupId are not used here const js_val = unwrapped.value; if (js_val.isObject() == false) { - std.debug.print("XX-0\n", .{}); return error.ObjectIdIsNotANode; } const Node = @import("../webapi/Node.zig"); return Context.typeTaggedAnyOpaque(*Node, js_val.castTo(v8.Object)) catch { - std.debug.print("XX-1\n", .{}); return error.ObjectIdIsNotANode; }; } diff --git a/src/browser/tests/events.html b/src/browser/tests/events.html index a0459f403..a3682ae1b 100644 --- a/src/browser/tests/events.html +++ b/src/browser/tests/events.html @@ -319,14 +319,20 @@ + +
+ + +
+ + +
+ + +
+ From 61aca85632c2c7fd35f13cc564901daf9d085e36 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Dec 2025 18:43:24 +0800 Subject: [PATCH 196/257] Pass Headers legacy tests --- src/browser/tests/legacy/fetch/headers.html | 19 ++- src/browser/tests/net/headers.html | 175 ++++++++++++++++++++ src/browser/webapi/KeyValueList.zig | 23 ++- src/browser/webapi/net/Headers.zig | 24 ++- src/browser/webapi/net/URLSearchParams.zig | 2 +- 5 files changed, 221 insertions(+), 22 deletions(-) diff --git a/src/browser/tests/legacy/fetch/headers.html b/src/browser/tests/legacy/fetch/headers.html index 57d6ce2ee..0626ad3d1 100644 --- a/src/browser/tests/legacy/fetch/headers.html +++ b/src/browser/tests/legacy/fetch/headers.html @@ -1,3 +1,4 @@ + + + + + + + diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 4ec85f203..f2282fcac 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -33,6 +33,7 @@ pub fn registerTypes() []const type { }; } +const Normalizer = *const fn([]const u8, *Page) []const u8; pub const KeyValueList = @This(); _entries: std.ArrayListUnmanaged(Entry) = .empty, @@ -50,7 +51,7 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { return list; } -pub fn fromJsObject(arena: Allocator, js_obj: js.Object) !KeyValueList { +pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { var it = js_obj.nameIterator(); var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, it.count); @@ -58,9 +59,10 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object) !KeyValueList { while (try it.next()) |name| { const js_value = try js_obj.get(name); const value = try js_value.toString(arena); + const normalized = if (comptime normalizer) |n| n(name, page) else name; - try list._entries.append(arena, .{ - .name = try String.init(arena, name, .{}), + list._entries.appendAssumeCapacity(.{ + .name = try String.init(arena, normalized, .{}), .value = try String.init(arena, value, .{}), }); } @@ -68,6 +70,21 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object) !KeyValueList { return list; } +pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { + var list = KeyValueList.init(); + try list.ensureTotalCapacity(arena, kvs.len); + + for (kvs) |pair| { + const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0]; + + list._entries.appendAssumeCapacity(.{ + .name = try String.init(arena, normalized, .{}), + .value = try String.init(arena, pair[1], .{}), + }); + } + return list; +} + pub const Entry = struct { name: String, value: String, diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 633771791..8c80c5917 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -1,6 +1,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); +const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const KeyValueList = @import("../KeyValueList.zig"); @@ -13,13 +14,15 @@ _list: KeyValueList, pub const InitOpts = union(enum) { obj: *Headers, + strings: []const [2][]const u8, js_obj: js.Object, }; pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { const list = if (opts_) |opts| switch (opts) { .obj => |obj| try KeyValueList.copy(page.arena, obj._list), - .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj), + .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, page), + .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, page), } else KeyValueList.init(); return page._factory.create(Headers{ @@ -27,12 +30,6 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { }); } -// pub fn fromJsObject(js_obj: js.Object, page: *Page) !*Headers { -// return page._factory.create(Headers{ -// ._list = try KeyValueList.fromJsObject(page.arena, js_obj), -// }); -// } - pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const normalized_name = normalizeHeaderName(name, page); try self._list.append(page.arena, normalized_name, value); @@ -43,9 +40,17 @@ pub fn delete(self: *Headers, name: []const u8, page: *Page) void { self._list.delete(normalized_name, null); } -pub fn get(self: *const Headers, name: []const u8, page: *Page) ?[]const u8 { +pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { const normalized_name = normalizeHeaderName(name, page); - return self._list.get(normalized_name); + const all_values = try self._list.getAll(normalized_name, page); + + if (all_values.len == 0) { + return null; + } + if (all_values.len == 1) { + return all_values[0]; + } + return try std.mem.join(page.call_arena, ", ", all_values); } pub fn has(self: *const Headers, name: []const u8, page: *Page) bool { @@ -97,6 +102,7 @@ fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { return std.ascii.lowerString(&page.buf, name); } + pub const JsApi = struct { pub const bridge = js.Bridge(Headers); diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 73e5e1101..f3069531d 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -45,7 +45,7 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), .value => |js_val| { if (js_val.isObject()) { - break :blk try KeyValueList.fromJsObject(arena, js_val.toObject()); + break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); } if (js_val.isString()) { break :blk try paramsFromString(arena, try js_val.toString(arena), &page.buf); From a355d9e5178b6729f10879f6e321dc65914cf84d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 07:13:59 +0800 Subject: [PATCH 197/257] Handle infinitely recursive mutation observer FireFox hangs in these cases, but we'd rather handle it gracefully. --- src/browser/Page.zig | 14 ++++ .../tests/legacy/dom/mutation_observer.html | 76 ------------------- 2 files changed, 14 insertions(+), 76 deletions(-) delete mode 100644 src/browser/tests/legacy/dom/mutation_observer.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 48daeeb54..aab9b6f71 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -100,6 +100,7 @@ _script_manager: ScriptManager, // List of active MutationObservers _mutation_observers: std.ArrayList(*MutationObserver) = .{}, _mutation_delivery_scheduled: bool = false, +_mutation_delivery_depth: u32 = 0, // List of active IntersectionObservers _intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, @@ -244,6 +245,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._mutation_observers = .{}; self._mutation_delivery_scheduled = false; + self._mutation_delivery_depth = 0; self._intersection_observers = .{}; self._intersection_delivery_scheduled = false; self._customized_builtin_definitions = .{}; @@ -849,6 +851,18 @@ pub fn deliverMutations(self: *Page) void { } self._mutation_delivery_scheduled = false; + self._mutation_delivery_depth += 1; + defer if (!self._mutation_delivery_scheduled) { + // reset the depth once nothing is left to be scheduled + self._mutation_delivery_depth = 0; + }; + + if (self._mutation_delivery_depth > 100) { + log.err(.page, "page.MutationLimit", .{}); + self._mutation_delivery_depth = 0; + return; + } + // Iterate backwards to handle observers that disconnect during their callback var i = self._mutation_observers.items.len; while (i > 0) { diff --git a/src/browser/tests/legacy/dom/mutation_observer.html b/src/browser/tests/legacy/dom/mutation_observer.html deleted file mode 100644 index f67cb9247..000000000 --- a/src/browser/tests/legacy/dom/mutation_observer.html +++ /dev/null @@ -1,76 +0,0 @@ - -
-

And

-

And

-

And

- - - - - From 86ae0048256d43246961131d8b4fbfb923d082b5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 07:41:08 +0800 Subject: [PATCH 198/257] new Comment(?[]const u8) --- src/browser/tests/cdata/comment.html | 7 +++++++ src/browser/webapi/cdata/Comment.zig | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 src/browser/tests/cdata/comment.html diff --git a/src/browser/tests/cdata/comment.html b/src/browser/tests/cdata/comment.html new file mode 100644 index 000000000..453da3848 --- /dev/null +++ b/src/browser/tests/cdata/comment.html @@ -0,0 +1,7 @@ + + + + diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig index f91faf895..dca43b25d 100644 --- a/src/browser/webapi/cdata/Comment.zig +++ b/src/browser/webapi/cdata/Comment.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const CData = @import("../CData.zig"); @@ -24,6 +25,11 @@ const Comment = @This(); _proto: *CData, +pub fn init(content: ?[]const u8, page: *Page) !*Comment { + const node = try page.createComment(content orelse ""); + return node.as(Comment); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Comment); @@ -32,4 +38,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const constructor = bridge.constructor(Comment.init, .{}); }; From b25e46de2e4f72895fe21659eca2ab162d7de47e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 11:48:09 +0800 Subject: [PATCH 199/257] zig fmt --- src/browser/webapi/KeyValueList.zig | 2 +- src/browser/webapi/Node.zig | 2 +- src/browser/webapi/net/Headers.zig | 1 - src/cdp/Node.zig | 6 +++--- src/cdp/domains/dom.zig | 2 +- src/cdp/testing.zig | 2 +- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index f2282fcac..d94eacb5f 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -33,7 +33,7 @@ pub fn registerTypes() []const type { }; } -const Normalizer = *const fn([]const u8, *Page) []const u8; +const Normalizer = *const fn ([]const u8, *Page) []const u8; pub const KeyValueList = @This(); _entries: std.ArrayListUnmanaged(Entry) = .empty, diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 619518f74..f203c7ae5 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -777,7 +777,7 @@ pub const JsApi = struct { pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10); pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20); - pub const nodeName = bridge.accessor(struct{ + pub const nodeName = bridge.accessor(struct { fn wrap(self: *const Node, page: *Page) []const u8 { return self.getNodeName(&page.buf); } diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 8c80c5917..5c9626553 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -102,7 +102,6 @@ fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { return std.ascii.lowerString(&page.buf, name); } - pub const JsApi = struct { pub const bridge = js.Bridge(Headers); diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index ca7c43ca3..b4d6c42a0 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -307,7 +307,7 @@ pub const Writer = struct { try w.write(dom_node.getNodeName(&name_buf)); try w.objectField("nodeValue"); - try w.write(dom_node.getNodeValue() orelse ""); + try w.write(dom_node.getNodeValue() orelse ""); if (include_child_count) { try w.objectField("childNodeCount"); @@ -564,7 +564,7 @@ test "cdp Node: Writer" { .nodeId = 5, .localName = "a", .childNodeCount = 0, - .attributes = &.{"id", "a1"}, + .attributes = &.{ "id", "a1" }, .parentId = 4, }, .{ .nodeId = 6, @@ -576,7 +576,7 @@ test "cdp Node: Writer" { .localName = "a", .childNodeCount = 0, .parentId = 6, - .attributes = &.{"id", "a2"}, + .attributes = &.{ "id", "a2" }, }}, }, .{ .nodeId = 8, diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 4629bfc40..ea0c81ef5 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -94,7 +94,7 @@ fn performSearch(cmd: anytype) !void { // dispatch setChildNodesEvents to inform the client of the subpart of node // tree covering the results. - try dispatchSetChildNodes(cmd, list._nodes); + try dispatchSetChildNodes(cmd, list._nodes); return cmd.sendResult(.{ .searchId = search.name, diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 8d459a9f4..7d59a7c43 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -127,7 +127,7 @@ const TestContext = struct { const full_url = try std.fmt.allocPrintSentinel( self.arena.allocator(), "http://127.0.0.1:9582/src/browser/tests/{s}", - .{ url }, + .{url}, 0, ); try page.navigate(full_url, .{}); From 34f0857b4fc45fc6b7b59ccfb3a355c4c6873b97 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 12:51:56 +0800 Subject: [PATCH 200/257] Element legacy test passing --- src/browser/tests/document/document.html | 77 ++++++++++++++++ src/browser/tests/element/attributes.html | 81 +++++++++++++++++ src/browser/tests/element/element.html | 90 ++++++++++++++++++- src/browser/tests/element/query_selector.html | 2 + .../tests/element/query_selector_all.html | 3 + src/browser/tests/legacy/dom/element.html | 41 +-------- src/browser/webapi/DOMException.zig | 2 +- src/browser/webapi/Document.zig | 31 +++++++ src/browser/webapi/Element.zig | 43 ++++++++- src/browser/webapi/collections/NodeList.zig | 1 + src/browser/webapi/element/Attribute.zig | 26 ++++++ src/browser/webapi/selector/Selector.zig | 3 +- 12 files changed, 356 insertions(+), 44 deletions(-) diff --git a/src/browser/tests/document/document.html b/src/browser/tests/document/document.html index 1138c132a..4ec114112 100644 --- a/src/browser/tests/document/document.html +++ b/src/browser/tests/document/document.html @@ -181,3 +181,80 @@ document.cookie = 'IgnoreMy=Ghost; HttpOnly'; testing.expectEqual('name=Oeschger; favorite_food=tripe', document.cookie); + + + + + + diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html index e33af9ed1..56f6ef834 100644 --- a/src/browser/tests/element/attributes.html +++ b/src/browser/tests/element/attributes.html @@ -165,3 +165,84 @@ testing.expectEqual(false, div.hasAttributes()); } + + diff --git a/src/browser/tests/element/element.html b/src/browser/tests/element/element.html index f779c9cdb..2a7adbaf1 100644 --- a/src/browser/tests/element/element.html +++ b/src/browser/tests/element/element.html @@ -9,7 +9,7 @@ Span 1

Paragraph 2

-
+
+ + + + + + + + diff --git a/src/browser/tests/element/query_selector.html b/src/browser/tests/element/query_selector.html index cb753465a..9750bd1a5 100644 --- a/src/browser/tests/element/query_selector.html +++ b/src/browser/tests/element/query_selector.html @@ -10,6 +10,8 @@ - - @@ -338,4 +301,4 @@ const p = document.createElement('p'); p.textContent = 'XAnge\xa0Privacy'; testing.expectEqual('

XAnge Privacy

', p.outerHTML); - + --> diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 72d795595..7ae241d2d 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -57,7 +57,7 @@ pub fn getName(self: *const DOMException) []const u8 { pub fn getMessage(self: *const DOMException) []const u8 { return switch (self._code) { .none => "", - .invalid_character_error => "Invalid Character", + .invalid_character_error => "Error: Invalid Character", .index_size_error => "IndexSizeError: Index or size is negative or greater than the allowed amount", .syntax_error => "Syntax Error", .not_supported => "Not Supported", diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 1973b8f0e..a665849fe 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -125,6 +125,16 @@ pub fn createElementNS(_: *const Document, namespace: ?[]const u8, name: []const return node.as(Element); } +pub fn createAttribute(_: *const Document, name: []const u8, page: *Page) !?*Element.Attribute { + try Element.Attribute.validateAttributeName(name); + return page._factory.node(Element.Attribute{ + ._proto = undefined, + ._name = try page.dupeString(name), + ._value = "", + ._element = null, + }); +} + pub fn getElementById(self: *const Document, id_: ?[]const u8) ?*Element { const id = id_ orelse return null; return self._elements_by_id.get(id); @@ -317,6 +327,24 @@ pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !* return node.cloneNode(deep_, page); } +pub fn append(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { + const parent = self.asNode(); + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.appendChild(child, page); + } +} + +pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { + const parent = self.asNode(); + var i = nodes.len; + while (i > 0) { + i -= 1; + const child = try nodes[i].toNode(page); + _ = try parent.insertBefore(child, parent.firstChild(), page); + } +} + const ReadyState = enum { loading, interactive, @@ -360,6 +388,7 @@ pub const JsApi = struct { pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); pub const createComment = bridge.function(Document.createComment, .{}); pub const createTextNode = bridge.function(Document.createTextNode, .{}); + pub const createAttribute = bridge.function(Document.createAttribute, .{ .dom_exception = true }); pub const createCDATASection = bridge.function(Document.createCDATASection, .{ .dom_exception = true }); pub const createRange = bridge.function(Document.createRange, .{}); pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true }); @@ -373,6 +402,8 @@ pub const JsApi = struct { pub const getElementsByName = bridge.function(Document.getElementsByName, .{}); pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); + pub const append = bridge.function(Document.append, .{}); + pub const prepend = bridge.function(Document.prepend, .{}); pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 4c9287d54..00b46cdf7 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -343,6 +343,14 @@ pub fn setId(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe("id", value, page); } +pub fn getDir(self: *const Element) []const u8 { + return self.getAttributeSafe("dir") orelse ""; +} + +pub fn setDir(self: *Element, value: []const u8, page: *Page) !void { + return self.setAttributeSafe("dir", value, page); +} + pub fn getClassName(self: *const Element) []const u8 { return self.getAttributeSafe("class") orelse ""; } @@ -388,6 +396,7 @@ pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attrib } pub fn setAttribute(self: *Element, name: []const u8, value: []const u8, page: *Page) !void { + try Attribute.validateAttributeName(name); const attributes = try self.getOrCreateAttributeList(page); _ = try attributes.put(name, value, self, page); } @@ -503,6 +512,7 @@ pub fn removeAttribute(self: *Element, name: []const u8, page: *Page) !void { } pub fn toggleAttribute(self: *Element, name: []const u8, force: ?bool, page: *Page) !bool { + try Attribute.validateAttributeName(name); const has = try self.hasAttribute(name, page); const should_add = force orelse !has; @@ -647,6 +657,27 @@ pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !voi } } +pub fn before(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.insertBefore(child, node, page); + } +} + +pub fn after(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { + const node = self.asNode(); + const parent = node.parentNode() orelse return; + const next = node.nextSibling(); + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + _ = try parent.insertBefore(child, next, page); + } +} + pub fn firstElementChild(self: *Element) ?*Element { var maybe_child = self.asNode().firstChild(); while (maybe_child) |child| { @@ -946,6 +977,10 @@ pub fn cloneElement(self: *Element, deep: bool, page: *Page) !*Node { return node; } +pub fn scrollIntoViewIfNeeded(_: *const Element, center_if_needed: ?bool) void { + _ = center_if_needed; +} + pub fn format(self: *Element, writer: *std.Io.Writer) !void { try writer.writeByte('<'); try writer.writeAll(self.getTagNameDump()); @@ -1136,6 +1171,7 @@ pub const JsApi = struct { pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{}); + pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, null, .{}); pub const dataset = bridge.accessor(Element.getDataset, null, .{}); @@ -1145,10 +1181,10 @@ pub const JsApi = struct { pub const hasAttributes = bridge.function(Element.hasAttributes, .{}); pub const getAttribute = bridge.function(Element.getAttribute, .{}); pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{}); - pub const setAttribute = bridge.function(Element.setAttribute, .{}); + pub const setAttribute = bridge.function(Element.setAttribute, .{ .dom_exception = true }); pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{}); pub const removeAttribute = bridge.function(Element.removeAttribute, .{}); - pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{}); + pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true }); pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{}); pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true }); pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); @@ -1167,6 +1203,8 @@ pub const JsApi = struct { pub const remove = bridge.function(Element.remove, .{}); pub const append = bridge.function(Element.append, .{}); pub const prepend = bridge.function(Element.prepend, .{}); + pub const before = bridge.function(Element.before, .{}); + pub const after = bridge.function(Element.after, .{}); pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{}); pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{}); pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{}); @@ -1188,6 +1226,7 @@ pub const JsApi = struct { pub const children = bridge.accessor(Element.getChildren, null, .{}); pub const focus = bridge.function(Element.focus, .{}); pub const blur = bridge.function(Element.blur, .{}); + pub const scrollIntoViewIfNeeded = bridge.function(Element.scrollIntoViewIfNeeded, .{}); }; pub const Build = struct { diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index dae615098..8ee8b104c 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -111,6 +111,7 @@ pub const JsApi = struct { pub const length = bridge.accessor(NodeList.length, null, .{}); pub const @"[]" = bridge.indexed(NodeList.getAtIndex, .{ .null_as_undefined = true }); + pub const item = bridge.function(NodeList.getAtIndex, .{}); pub const keys = bridge.function(NodeList.keys, .{}); pub const values = bridge.function(NodeList.values, .{}); pub const entries = bridge.function(NodeList.entries, .{}); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index b5d45a612..d7fa36e6e 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -343,6 +343,32 @@ fn shouldAddToIdMap(normalized_name: []const u8, element: *Element) bool { return node.isConnected(); } +pub fn validateAttributeName(name: []const u8) !void { + if (name.len == 0) { + return error.InvalidCharacterError; + } + + const first = name[0]; + if ((first >= '0' and first <= '9') or first == '-' or first == '.') { + return error.InvalidCharacterError; + } + + for (name) |c| { + if (c == 0 or c == '/' or c == '=' or c == '>' or std.ascii.isWhitespace(c)) { + return error.InvalidCharacterError; + } + + const is_valid = (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '_' or c == '-' or c == '.' or c == ':'; + + if (!is_valid) { + return error.InvalidCharacterError; + } + } +} + pub fn normalizeNameForLookup(name: []const u8, page: *Page) ![]const u8 { if (!needsLowerCasing(name)) { return name; diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 44d7c4387..6f0869eb3 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -38,7 +38,8 @@ pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Elemen if (first == .id) { const el = page.getElementByIdFromNode(root, first.id) orelse continue; // Check if the element is within the root subtree - if (root.contains(el.asNode())) { + const node = el.asNode(); + if (node != root and root.contains(node)) { return el; } continue; From bead805680eaa60a4a9109b0873b5b4be18a308a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 9 Dec 2025 17:58:10 +0300 Subject: [PATCH 201/257] backport: Prefer BoringSSL as TLS backend --- .github/workflows/e2e-test.yml | 2 +- .gitmodules | 3 --- build.zig | 21 ++++++++++++++++++--- build.zig.zon | 4 ++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 992c8b2a9..1b8b910b7 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -124,7 +124,7 @@ jobs: needs: zig-build-release env: - MAX_MEMORY: 27000 + MAX_MEMORY: 28000 MAX_AVG_DURATION: 23 LIGHTPANDA_DISABLE_TELEMETRY: true diff --git a/.gitmodules b/.gitmodules index 3358b9a3e..5462f8f0e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,9 +4,6 @@ [submodule "vendor/nghttp2"] path = vendor/nghttp2 url = https://github.com/nghttp2/nghttp2.git -[submodule "vendor/mbedtls"] - path = vendor/mbedtls - url = https://github.com/Mbed-TLS/mbedtls.git [submodule "vendor/zlib"] path = vendor/zlib url = https://github.com/madler/zlib.git diff --git a/build.zig b/build.zig index a273d81f9..2519ec097 100644 --- a/build.zig +++ b/build.zig @@ -433,13 +433,27 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo mod.addCMacro("STDC_HEADERS", "1"); mod.addCMacro("TIME_WITH_SYS_TIME", "1"); mod.addCMacro("USE_NGHTTP2", "1"); - mod.addCMacro("USE_MBEDTLS", "1"); + mod.addCMacro("USE_OPENSSL", "1"); + mod.addCMacro("OPENSSL_IS_BORINGSSL", "1"); mod.addCMacro("USE_THREADS_POSIX", "1"); mod.addCMacro("USE_UNIX_SOCKETS", "1"); } try buildZlib(b, mod); try buildBrotli(b, mod); + const boringssl_dep = b.dependency("boringssl-zig", .{ + .target = target, + .optimize = mod.optimize.?, + .force_pic = true, + }); + + const ssl = boringssl_dep.artifact("ssl"); + ssl.bundle_ubsan_rt = false; + const crypto = boringssl_dep.artifact("crypto"); + crypto.bundle_ubsan_rt = false; + + mod.linkLibrary(ssl); + mod.linkLibrary(crypto); try buildMbedtls(b, mod); try buildNghttp2(b, mod); try buildCurl(b, mod); @@ -845,8 +859,9 @@ fn buildCurl(b: *Build, m: *Build.Module) !void { root ++ "lib/vauth/spnego_sspi.c", root ++ "lib/vauth/vauth.c", root ++ "lib/vtls/cipher_suite.c", - root ++ "lib/vtls/mbedtls.c", - root ++ "lib/vtls/mbedtls_threadlock.c", + root ++ "lib/vtls/openssl.c", + root ++ "lib/vtls/hostcheck.c", + root ++ "lib/vtls/keylog.c", root ++ "lib/vtls/vtls.c", root ++ "lib/vtls/vtls_scache.c", root ++ "lib/vtls/x509asn1.c", diff --git a/build.zig.zon b/build.zig.zon index 6d3b20617..cb0136209 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,5 +9,9 @@ .hash = "v8-0.0.0-xddH6wTgAwALFCYoZbUIqtsRyP6mr69N7aKT_cySHKN2", }, //.v8 = .{ .path = "../zig-v8-fork" } + .@"boringssl-zig" = .{ + .url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096", + .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK", + }, }, } From 68763d9a30969e8da5965ff2d3579a5cda60b6ef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 15:23:39 +0800 Subject: [PATCH 202/257] speed up tests --- src/browser/Page.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index aab9b6f71..5a427dc30 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -659,10 +659,13 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { const ms = ms_to_next_task orelse blk: { if (wait_ms - ms_remaining < 100) { + if (comptime builtin.is_test) { + return .done; + } // Look, we want to exit ASAP, but we don't want // to exit so fast that we've run none of the // background jobs. - break :blk if (comptime builtin.is_test) 1 else 50; + break :blk 50; } // No http transfers, no cdp extra socket, no // scheduled tasks, we're done. From 7b6776345af46658ab9af0172bc41533f78287d3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 9 Dec 2025 17:25:04 +0300 Subject: [PATCH 203/257] backport: Remove `_TYPED_ARRAY_ID_KLUDGE` hack Bonus: Add `ArrayBuffer`. --- src/browser/js/Context.zig | 100 ++++++++++++++++----------------- src/browser/js/js.zig | 112 ++++++++++++++++++++++--------------- 2 files changed, 117 insertions(+), 95 deletions(-) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 24f52decc..6bb3c0620 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -821,64 +821,62 @@ pub fn jsValueToZig(self: *Context, comptime T: type, js_value: v8.Value) !T { // Extracted so that it can be used in both jsValueToZig and in // probeJsValueToZig. Avoids having to duplicate this logic when probing. fn jsValueToStruct(self: *Context, comptime T: type, js_value: v8.Value) !?T { - if (T == js.Function) { - if (!js_value.isFunction()) { - return null; - } - return try self.createFunction(js_value); - } - - if (@hasDecl(T, "_TYPED_ARRAY_ID_KLUDGE")) { - const VT = @typeInfo(std.meta.fieldInfo(T, .values).type).pointer.child; - const arr = (try self.jsValueToTypedArray(VT, js_value)) orelse return null; - return .{ .values = arr }; - } - - if (T == js.String) { - return .{ .string = try self.valueToString(js_value, .{ .allocator = self.arena }) }; - } - - if (comptime T == js.Value) { + return switch (T) { + js.Function => { + if (!js_value.isFunction()) { + return null; + } + return try self.createFunction(js_value); + }, + // zig fmt: off + js.TypedArray(u8), js.TypedArray(u16), js.TypedArray(u32), js.TypedArray(u64), + js.TypedArray(i8), js.TypedArray(i16), js.TypedArray(i32), js.TypedArray(i64), + js.TypedArray(f32), js.TypedArray(f64), + // zig fmt: on + => { + const ValueType = @typeInfo(std.meta.fieldInfo(T, .values).type).pointer.child; + const arr = (try self.jsValueToTypedArray(ValueType, js_value)) orelse return null; + return .{ .values = arr }; + }, + js.String => .{ .string = try self.valueToString(js_value, .{ .allocator = self.arena }) }, // Caller wants an opaque js.Object. Probably a parameter - // that it needs to pass back into a callback - return js.Value{ - .context = self, + // that it needs to pass back into a callback. + js.Value => js.Value{ .js_val = js_value, - }; - } - - const js_obj = js_value.castTo(v8.Object); - - if (comptime T == js.Object) { + .context = self, + }, // Caller wants an opaque js.Object. Probably a parameter - // that it needs to pass back into a callback - return js.Object{ - .js_obj = js_obj, + // that it needs to pass back into a callback. + js.Object => js.Object{ + .js_obj = js_value.castTo(v8.Object), .context = self, - }; - } + }, + else => { + if (!js_value.isObject()) { + return null; + } - if (!js_value.isObject()) { - return null; - } + const js_obj = js_value.castTo(v8.Object); + const v8_context = self.v8_context; + const isolate = self.isolate; - const v8_context = self.v8_context; - const isolate = self.isolate; + var value: T = undefined; + inline for (@typeInfo(T).@"struct".fields) |field| { + const name = field.name; + const key = v8.String.initUtf8(isolate, name); + if (js_obj.has(v8_context, key.toValue())) { + @field(value, name) = try self.jsValueToZig(field.type, try js_obj.getValue(v8_context, key)); + } else if (@typeInfo(field.type) == .optional) { + @field(value, name) = null; + } else { + const dflt = field.defaultValue() orelse return null; + @field(value, name) = dflt; + } + } - var value: T = undefined; - inline for (@typeInfo(T).@"struct".fields) |field| { - const name = field.name; - const key = v8.String.initUtf8(isolate, name); - if (js_obj.has(v8_context, key.toValue())) { - @field(value, name) = try self.jsValueToZig(field.type, try js_obj.getValue(v8_context, key)); - } else if (@typeInfo(field.type) == .optional) { - @field(value, name) = null; - } else { - const dflt = field.defaultValue() orelse return null; - @field(value, name) = dflt; - } - } - return value; + return value; + }, + }; } fn jsValueToTypedArray(_: *Context, comptime T: type, js_value: v8.Value) !?[]T { diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 203b3b248..9530990be 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -53,8 +53,6 @@ pub fn Bridge(comptime T: type) type { // Env.JsObject. Want a TypedArray? Env.TypedArray. pub fn TypedArray(comptime T: type) type { return struct { - pub const _TYPED_ARRAY_ID_KLUDGE = true; - values: []const T, pub fn dupe(self: TypedArray(T), allocator: Allocator) !TypedArray(T) { @@ -63,6 +61,14 @@ pub fn TypedArray(comptime T: type) type { }; } +pub const ArrayBuffer = struct { + values: []const u8, + + pub fn dupe(self: ArrayBuffer, allocator: Allocator) !ArrayBuffer { + return .{ .values = try allocator.dupe(u8, self.values) }; + } +}; + pub const PromiseResolver = struct { context: *Context, resolver: v8.PromiseResolver, @@ -317,55 +323,73 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo return v8.initNull(isolate).toValue(); }, .@"struct" => { - const T = @TypeOf(value); - if (@hasDecl(T, "_TYPED_ARRAY_ID_KLUDGE")) { - const values = value.values; - const value_type = @typeInfo(@TypeOf(values)).pointer.child; - const len = values.len; - const bits = switch (@typeInfo(value_type)) { - .int => |n| n.bits, - .float => |f| f.bits, - else => @compileError("Invalid TypeArray type: " ++ @typeName(value_type)), - }; - - var array_buffer: v8.ArrayBuffer = undefined; - if (len == 0) { - array_buffer = v8.ArrayBuffer.init(isolate, 0); - } else { - const buffer_len = len * bits / 8; - const backing_store = v8.BackingStore.init(isolate, buffer_len); + switch (@TypeOf(value)) { + ArrayBuffer => { + const values = value.values; + const len = values.len; + var array_buffer: v8.ArrayBuffer = undefined; + const backing_store = v8.BackingStore.init(isolate, len); const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData())); - @memcpy(data[0..buffer_len], @as([]const u8, @ptrCast(values))[0..buffer_len]); + @memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]); array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr()); - } - switch (@typeInfo(value_type)) { - .int => |n| switch (n.signedness) { - .unsigned => switch (n.bits) { - 8 => return v8.Uint8Array.init(array_buffer, 0, len).toValue(), - 16 => return v8.Uint16Array.init(array_buffer, 0, len).toValue(), - 32 => return v8.Uint32Array.init(array_buffer, 0, len).toValue(), - 64 => return v8.BigUint64Array.init(array_buffer, 0, len).toValue(), - else => {}, + return .{ .handle = array_buffer.handle }; + }, + // zig fmt: off + TypedArray(u8), TypedArray(u16), TypedArray(u32), TypedArray(u64), + TypedArray(i8), TypedArray(i16), TypedArray(i32), TypedArray(i64), + TypedArray(f32), TypedArray(f64), + // zig fmt: on + => { + const values = value.values; + const value_type = @typeInfo(@TypeOf(values)).pointer.child; + const len = values.len; + const bits = switch (@typeInfo(value_type)) { + .int => |n| n.bits, + .float => |f| f.bits, + else => @compileError("Invalid TypeArray type: " ++ @typeName(value_type)), + }; + + var array_buffer: v8.ArrayBuffer = undefined; + if (len == 0) { + array_buffer = v8.ArrayBuffer.init(isolate, 0); + } else { + const buffer_len = len * bits / 8; + const backing_store = v8.BackingStore.init(isolate, buffer_len); + const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData())); + @memcpy(data[0..buffer_len], @as([]const u8, @ptrCast(values))[0..buffer_len]); + array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr()); + } + + switch (@typeInfo(value_type)) { + .int => |n| switch (n.signedness) { + .unsigned => switch (n.bits) { + 8 => return v8.Uint8Array.init(array_buffer, 0, len).toValue(), + 16 => return v8.Uint16Array.init(array_buffer, 0, len).toValue(), + 32 => return v8.Uint32Array.init(array_buffer, 0, len).toValue(), + 64 => return v8.BigUint64Array.init(array_buffer, 0, len).toValue(), + else => {}, + }, + .signed => switch (n.bits) { + 8 => return v8.Int8Array.init(array_buffer, 0, len).toValue(), + 16 => return v8.Int16Array.init(array_buffer, 0, len).toValue(), + 32 => return v8.Int32Array.init(array_buffer, 0, len).toValue(), + 64 => return v8.BigInt64Array.init(array_buffer, 0, len).toValue(), + else => {}, + }, }, - .signed => switch (n.bits) { - 8 => return v8.Int8Array.init(array_buffer, 0, len).toValue(), - 16 => return v8.Int16Array.init(array_buffer, 0, len).toValue(), - 32 => return v8.Int32Array.init(array_buffer, 0, len).toValue(), - 64 => return v8.BigInt64Array.init(array_buffer, 0, len).toValue(), + .float => |f| switch (f.bits) { + 32 => return v8.Float32Array.init(array_buffer, 0, len).toValue(), + 64 => return v8.Float64Array.init(array_buffer, 0, len).toValue(), else => {}, }, - }, - .float => |f| switch (f.bits) { - 32 => return v8.Float32Array.init(array_buffer, 0, len).toValue(), - 64 => return v8.Float64Array.init(array_buffer, 0, len).toValue(), else => {}, - }, - else => {}, - } - // We normally don't fail in this function unless fail == true - // but this can never be valid. - @compileError("Invalid TypeArray type: " ++ @typeName(value_type)); + } + // We normally don't fail in this function unless fail == true + // but this can never be valid. + @compileError("Invalid TypeArray type: " ++ @typeName(value_type)); + }, + else => {}, } }, .@"union" => return simpleZigValueToJs(isolate, std.meta.activeTag(value), fail, null_as_undefined), From 695ed817e470509ed8cf16409225d4c4ad73f60c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 11:46:59 +0800 Subject: [PATCH 204/257] port remaining blob functionality --- src/browser/Factory.zig | 4 +- src/browser/tests/blob.html | 99 ++++++++++++++++++++++++------------- src/browser/webapi/Blob.zig | 70 ++++++++++++++------------ 3 files changed, 105 insertions(+), 68 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 91e011abb..69cf0f529 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -196,8 +196,8 @@ pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { const blob_ptr = chain.get(0); blob_ptr.* = .{ ._type = unionInit(Blob.Type, chain.get(1)), - .slice = "", - .mime = "", + ._slice = "", + ._mime = "", }; chain.setLeaf(1, child); diff --git a/src/browser/tests/blob.html b/src/browser/tests/blob.html index 693095c1f..12cd13f5a 100644 --- a/src/browser/tests/blob.html +++ b/src/browser/tests/blob.html @@ -1,8 +1,6 @@ - - Test Document Title - - + + - - @@ -60,10 +31,11 @@ const parts = ["light ", "panda ", "rocks ", "!"]; const blob = new Blob(parts); - testing.async(blob.bytes(), result => { + testing.async(async() => { const expected = new Uint8Array([108, 105, 103, 104, 116, 32, 112, 97, 110, 100, 97, 32, 114, 111, 99, 107, 115, 32, 33]); + const result = await blob.bytes(); testing.expectEqual(true, result instanceof Uint8Array); testing.expectEqual(expected, result); }); @@ -81,7 +53,7 @@ const blob = new Blob(parts, { type: "text/html", endings: "native" }); testing.expectEqual(161, blob.size); testing.expectEqual("text/html", blob.type); - testing.async(blob.bytes(), result => { + testing.async(async() => { const expected = new Uint8Array([10, 84, 104, 101, 32, 111, 112, 101, 110, 101, 100, 32, 112, 97, 99, 107, 97, 103, 101, 10, 111, 102, 32, 112, 111, 116, 97, @@ -100,8 +72,65 @@ 10, 10, 116, 111, 32, 115, 111, 108, 118, 101, 32, 116, 104, 101, 32, 10, 99, 114, 105, 109, 101, 46, 10]); + const result = await blob.bytes(); testing.expectEqual(true, result instanceof Uint8Array); testing.expectEqual(expected, result); }); } + + + + diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index a60f4b424..58106caf3 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -27,14 +27,15 @@ const Page = @import("../Page.zig"); const Blob = @This(); const _prototype_root = true; + _type: Type, /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, /// so its better to leave the deallocation of it to arena allocator. -slice: []const u8, +_slice: []const u8, /// MIME attached to blob. Can be an empty string. -mime: []const u8, +_mime: []const u8, pub const Type = union(enum) { generic, @@ -66,7 +67,7 @@ pub fn init( break :blk try page.arena.dupe(u8, t); }; - const slice = blk: { + const data = blk: { if (maybe_blob_parts) |blob_parts| { var w: Writer.Allocating = .init(page.arena); const use_native_endings = std.mem.eql(u8, options.endings, "native"); @@ -80,8 +81,8 @@ pub fn init( return page._factory.create(Blob{ ._type = .generic, - .slice = slice, - .mime = mime, + ._slice = data, + ._mime = mime, }); } @@ -147,8 +148,8 @@ fn writeBlobParts( while (end + vector_len <= part.len) : (end += vector_len) { const cr: Vec = @splat('\r'); // Load chunk as vectors. - const slice = part[end..][0..vector_len]; - const chunk: Vec = slice.*; + const data = part[end..][0..vector_len]; + const chunk: Vec = data.*; // Look for CR. const match = chunk == cr; @@ -160,16 +161,16 @@ fn writeBlobParts( var iter = bitset.iterator(.{}); var relative_start: usize = 0; while (iter.next()) |index| { - _ = try writer.writeVec(&.{ slice[relative_start..index], "\n" }); + _ = try writer.writeVec(&.{ data[relative_start..index], "\n" }); - if (index + 1 != slice.len and slice[index + 1] == '\n') { + if (index + 1 != data.len and data[index + 1] == '\n') { relative_start = index + 2; } else { relative_start = index + 1; } } - _ = try writer.writeVec(&.{slice[relative_start..]}); + _ = try writer.writeVec(&.{data[relative_start..]}); } } @@ -204,16 +205,21 @@ fn writeBlobParts( /// Returns a Promise that resolves with the contents of the blob /// as binary data contained in an ArrayBuffer. -//pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise { -// return page.js.resolvePromise(js.ArrayBuffer{ .values = self.slice }); -//} +pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise { + return page.js.resolvePromise(js.ArrayBuffer{ .values = self._slice }); +} -// TODO: Implement `stream`; requires `ReadableStream`. +const ReadableStream = @import("streams/ReadableStream.zig"); +/// Returns a ReadableStream which upon reading returns the data +/// contained within the Blob. +pub fn stream(self: *const Blob, page: *Page) !*ReadableStream { + return ReadableStream.initWithData(self._slice, page); +} /// Returns a Promise that resolves with a string containing /// the contents of the blob, interpreted as UTF-8. pub fn text(self: *const Blob, page: *Page) !js.Promise { - return page.js.resolvePromise(self.slice); + return page.js.resolvePromise(self._slice); } /// Extension to Blob; works on Firefox and Safari. @@ -221,12 +227,12 @@ pub fn text(self: *const Blob, page: *Page) !js.Promise { /// Returns a Promise that resolves with a Uint8Array containing /// the contents of the blob as an array of bytes. pub fn bytes(self: *const Blob, page: *Page) !js.Promise { - return page.js.resolvePromise(js.TypedArray(u8){ .values = self.slice }); + return page.js.resolvePromise(js.TypedArray(u8){ .values = self._slice }); } /// Returns a new Blob object which contains data /// from a subset of the blob on which it's called. -pub fn getSlice( +pub fn slice( self: *const Blob, maybe_start: ?i32, maybe_end: ?i32, @@ -239,56 +245,56 @@ pub fn getSlice( break :blk ""; } - break :blk try page.arena.dupe(u8, content_type); + break :blk try page.dupeString(content_type); } break :blk ""; }; - const slice = self.slice; + const data = self._slice; if (maybe_start) |_start| { const start = blk: { if (_start < 0) { - break :blk slice.len -| @abs(_start); + break :blk data.len -| @abs(_start); } - break :blk @min(slice.len, @as(u31, @intCast(_start))); + break :blk @min(data.len, @as(u31, @intCast(_start))); }; const end: usize = blk: { if (maybe_end) |_end| { if (_end < 0) { - break :blk @max(start, slice.len -| @abs(_end)); + break :blk @max(start, data.len -| @abs(_end)); } - break :blk @min(slice.len, @max(start, @as(u31, @intCast(_end)))); + break :blk @min(data.len, @max(start, @as(u31, @intCast(_end)))); } - break :blk slice.len; + break :blk data.len; }; return page._factory.create(Blob{ ._type = .generic, - .slice = slice[start..end], - .mime = mime, + ._slice = data[start..end], + ._mime = mime, }); } return page._factory.create(Blob{ ._type = .generic, - .slice = slice, - .mime = mime, + ._slice = data, + ._mime = mime, }); } /// Returns the size of the Blob in bytes. pub fn getSize(self: *const Blob) usize { - return self.slice.len; + return self._slice.len; } /// Returns the type of Blob; likely a MIME type, yet anything can be given. pub fn getType(self: *const Blob) []const u8 { - return self.mime; + return self._mime; } pub const JsApi = struct { @@ -303,9 +309,11 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Blob.init, .{}); pub const text = bridge.function(Blob.text, .{}); pub const bytes = bridge.function(Blob.bytes, .{}); - pub const slice = bridge.function(Blob.getSlice, .{}); + pub const slice = bridge.function(Blob.slice, .{}); pub const size = bridge.accessor(Blob.getSize, null, .{}); pub const @"type" = bridge.accessor(Blob.getType, null, .{}); + pub const stream = bridge.function(Blob.stream, .{}); + pub const arrayBuffer = bridge.function(Blob.arrayBuffer, .{}); }; const testing = @import("../../testing.zig"); From 3d8b1abda4f3870adc811089f8966488f677514b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 16:45:19 +0800 Subject: [PATCH 205/257] More legacy tests Largely around how URL attributes (a.href, img.href, link.href) handle empty values. --- Makefile | 4 +- src/browser/tests/document/focus.html | 9 + src/browser/tests/element/html/anchor.html | 211 +++++++++++++++++++-- src/browser/tests/element/html/image.html | 12 +- src/browser/tests/element/html/link.html | 12 ++ src/browser/tests/legacy/html/element.html | 4 +- src/browser/webapi/Element.zig | 8 +- src/browser/webapi/element/html/Anchor.zig | 118 +++++++++--- src/browser/webapi/element/html/Image.zig | 12 +- src/browser/webapi/element/html/Link.zig | 15 +- 10 files changed, 351 insertions(+), 54 deletions(-) create mode 100644 src/browser/tests/element/html/link.html diff --git a/Makefile b/Makefile index 7208b9ee9..3f79e1fa2 100644 --- a/Makefile +++ b/Makefile @@ -99,11 +99,11 @@ wpt-summary: ## Test - `grep` is used to filter out the huge compile command on build ifeq ($(OS), macos) test: - @script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' 2>&1 \ + @script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' 2>&1 \ | grep --line-buffered -v "^/.*zig test -freference-trace" else test: - @script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' /dev/null 2>&1 \ + @script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \ | grep --line-buffered -v "^/.*zig test -freference-trace" endif diff --git a/src/browser/tests/document/focus.html b/src/browser/tests/document/focus.html index 5b7b7c078..3e72e1d41 100644 --- a/src/browser/tests/document/focus.html +++ b/src/browser/tests/document/focus.html @@ -79,3 +79,12 @@ testing.expectEqual(1, focusCount); } + + + diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index a1402688a..2eaa7935a 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -1,29 +1,93 @@ + + +OK + + -OK + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index 5ad6454df..b9cb81538 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -31,9 +31,19 @@ testing.expectEqual('', img.alt); img.src = 'test.png'; - testing.expectEqual('test.png', img.src); + // src property returns resolved absolute URL + testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.png', img.src); + // getAttribute returns the raw attribute value testing.expectEqual('test.png', img.getAttribute('src')); + img.src = '/absolute/path.png'; + testing.expectEqual('http://127.0.0.1:9582/absolute/path.png', img.src); + testing.expectEqual('/absolute/path.png', img.getAttribute('src')); + + img.src = 'https://example.com/image.png'; + testing.expectEqual('https://example.com/image.png', img.src); + testing.expectEqual('https://example.com/image.png', img.getAttribute('src')); + img.alt = 'Test image'; testing.expectEqual('Test image', img.alt); testing.expectEqual('Test image', img.getAttribute('alt')); diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html new file mode 100644 index 000000000..25fd5430d --- /dev/null +++ b/src/browser/tests/element/html/link.html @@ -0,0 +1,12 @@ + + + + diff --git a/src/browser/tests/legacy/html/element.html b/src/browser/tests/legacy/html/element.html index 4de1f0581..d1701ae34 100644 --- a/src/browser/tests/legacy/html/element.html +++ b/src/browser/tests/legacy/html/element.html @@ -32,7 +32,7 @@ testing.expectEqual('', a.href); testing.expectEqual('', a.host); a.href = 'about'; - testing.expectEqual('http://localhost:9582/src/tests/html/about', a.href); + testing.expectEqual('http://localhost:9589/html/about', a.href); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 00b46cdf7..8d722be9d 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -613,13 +613,17 @@ pub fn focus(self: *Element, page: *Page) !void { const Event = @import("Event.zig"); if (page.document._active_element) |old| { - if (old == self) return; + if (old == self) { + return; + } const blur_event = try Event.init("blur", null, page); try page._event_manager.dispatch(old.asEventTarget(), blur_event); } - page.document._active_element = self; + if (self.asNode().isConnected()) { + page.document._active_element = self; + } const focus_event = try Event.init("focus", null, page); try page._event_manager.dispatch(self.asEventTarget(), focus_event); diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index d6b85c46f..006843db2 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -40,17 +40,11 @@ pub fn asNode(self: *Anchor) *Node { pub fn getHref(self: *Anchor, page: *Page) ![]const u8 { const element = self.asElement(); - const href = element.getAttributeSafe("href") orelse ""; + const href = element.getAttributeSafe("href") orelse return ""; if (href.len == 0) { - return page.url; + return ""; } - - const first = href[0]; - if (first == '#' or first == '?' or first == '/' or std.mem.startsWith(u8, href, "../") or std.mem.startsWith(u8, href, "./")) { - return URL.resolve(page.call_arena, page.url, href, .{}); - } - - return href; + return URL.resolve(page.call_arena, page.url, href, .{}); } pub fn setHref(self: *Anchor, value: []const u8, page: *Page) !void { @@ -66,17 +60,30 @@ pub fn setTarget(self: *Anchor, value: []const u8, page: *Page) !void { } pub fn getOrigin(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return ""; return (try URL.getOrigin(page.call_arena, href)) orelse "null"; } pub fn getHost(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); - return URL.getHost(href); + const href = try getResolvedHref(self, page) orelse return ""; + const host = URL.getHost(href); + const protocol = URL.getProtocol(href); + const port = URL.getPort(href); + + // Strip default ports + if (port.len > 0) { + if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or + (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80"))) + { + return URL.getHostname(href); + } + } + + return host; } pub fn setHost(self: *Anchor, value: []const u8, page: *Page) !void { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return; const protocol = URL.getProtocol(href); const pathname = URL.getPathname(href); const search = URL.getSearch(href); @@ -101,12 +108,12 @@ pub fn setHost(self: *Anchor, value: []const u8, page: *Page) !void { } pub fn getHostname(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return ""; return URL.getHostname(href); } pub fn setHostname(self: *Anchor, value: []const u8, page: *Page) !void { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return; const current_port = URL.getPort(href); const new_host = if (current_port.len > 0) try std.fmt.allocPrint(page.call_arena, "{s}:{s}", .{ value, current_port }) @@ -117,12 +124,24 @@ pub fn setHostname(self: *Anchor, value: []const u8, page: *Page) !void { } pub fn getPort(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); - return URL.getPort(href); + const href = try getResolvedHref(self, page) orelse return ""; + const port = URL.getPort(href); + const protocol = URL.getProtocol(href); + + // Return empty string for default ports + if (port.len > 0) { + if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or + (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80"))) + { + return ""; + } + } + + return port; } pub fn setPort(self: *Anchor, value: ?[]const u8, page: *Page) !void { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return; const hostname = URL.getHostname(href); const protocol = URL.getProtocol(href); @@ -145,12 +164,12 @@ pub fn setPort(self: *Anchor, value: ?[]const u8, page: *Page) !void { } pub fn getSearch(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return ""; return URL.getSearch(href); } pub fn setSearch(self: *Anchor, value: []const u8, page: *Page) !void { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return; const protocol = URL.getProtocol(href); const host = URL.getHost(href); const pathname = URL.getPathname(href); @@ -167,12 +186,12 @@ pub fn setSearch(self: *Anchor, value: []const u8, page: *Page) !void { } pub fn getHash(self: *Anchor, page: *Page) ![]const u8 { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return ""; return URL.getHash(href); } pub fn setHash(self: *Anchor, value: []const u8, page: *Page) !void { - const href = try getResolvedHref(self, page); + const href = try getResolvedHref(self, page) orelse return; const protocol = URL.getProtocol(href); const host = URL.getHost(href); const pathname = URL.getPathname(href); @@ -188,6 +207,50 @@ pub fn setHash(self: *Anchor, value: []const u8, page: *Page) !void { try setHref(self, new_href, page); } +pub fn getPathname(self: *Anchor, page: *Page) ![]const u8 { + const href = try getResolvedHref(self, page) orelse return ""; + return URL.getPathname(href); +} + +pub fn setPathname(self: *Anchor, value: []const u8, page: *Page) !void { + const href = try getResolvedHref(self, page) orelse return; + const protocol = URL.getProtocol(href); + const host = URL.getHost(href); + const search = URL.getSearch(href); + const hash = URL.getHash(href); + + // Add / prefix if not present and value is not empty + const pathname = if (value.len > 0 and value[0] != '/') + try std.fmt.allocPrint(page.call_arena, "/{s}", .{value}) + else + value; + + const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + try setHref(self, new_href, page); +} + +pub fn getProtocol(self: *Anchor, page: *Page) ![]const u8 { + const href = try getResolvedHref(self, page) orelse return ""; + return URL.getProtocol(href); +} + +pub fn setProtocol(self: *Anchor, value: []const u8, page: *Page) !void { + const href = try getResolvedHref(self, page) orelse return; + const host = URL.getHost(href); + const pathname = URL.getPathname(href); + const search = URL.getSearch(href); + const hash = URL.getHash(href); + + // Add : suffix if not present + const protocol = if (value.len > 0 and value[value.len - 1] != ':') + try std.fmt.allocPrint(page.call_arena, "{s}:", .{value}) + else + value; + + const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + try setHref(self, new_href, page); +} + pub fn getType(self: *Anchor) []const u8 { return self.asElement().getAttributeSafe("type") orelse ""; } @@ -212,9 +275,12 @@ pub fn setText(self: *Anchor, value: []const u8, page: *Page) !void { try self.asNode().setTextContent(value, page); } -fn getResolvedHref(self: *Anchor, page: *Page) ![:0]const u8 { - const href = self.asElement().getAttributeSafe("href"); - return URL.resolve(page.call_arena, page.url, href orelse "", .{}); +fn getResolvedHref(self: *Anchor, page: *Page) !?[:0]const u8 { + const href = self.asElement().getAttributeSafe("href") orelse return null; + if (href.len == 0) { + return null; + } + return try URL.resolve(page.call_arena, page.url, href, .{}); } // Helper function to build a new URL from components @@ -248,9 +314,11 @@ pub const JsApi = struct { pub const target = bridge.accessor(Anchor.getTarget, Anchor.setTarget, .{}); pub const name = bridge.accessor(Anchor.getName, Anchor.setName, .{}); pub const origin = bridge.accessor(Anchor.getOrigin, null, .{}); + pub const protocol = bridge.accessor(Anchor.getProtocol, Anchor.setProtocol, .{}); pub const host = bridge.accessor(Anchor.getHost, Anchor.setHost, .{}); pub const hostname = bridge.accessor(Anchor.getHostname, Anchor.setHostname, .{}); pub const port = bridge.accessor(Anchor.getPort, Anchor.setPort, .{}); + pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{}); pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{}); pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{}); pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{}); diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 9576fde75..affaaba0a 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -1,6 +1,7 @@ const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); +const URL = @import("../../../URL.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -33,8 +34,15 @@ pub fn asNode(self: *Image) *Node { return self.asElement().asNode(); } -pub fn getSrc(self: *const Image) []const u8 { - return self.asConstElement().getAttributeSafe("src") orelse ""; +pub fn getSrc(self: *const Image, page: *Page) ![]const u8 { + const element = self.asConstElement(); + const src = element.getAttributeSafe("src") orelse return ""; + if (src.len == 0) { + return ""; + } + + // Always resolve the src against the page URL + return URL.resolve(page.call_arena, page.url, src, .{}); } pub fn setSrc(self: *Image, value: []const u8, page: *Page) !void { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index b9db1e53c..e381e2274 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -35,8 +35,14 @@ pub fn asNode(self: *Link) *Node { } pub fn getHref(self: *Link, page: *Page) ![]const u8 { - const href = self.asElement().getAttributeSafe("href"); - return URL.resolve(page.call_arena, page.url, href orelse "", .{}); + const element = self.asElement(); + const href = element.getAttributeSafe("href") orelse return ""; + if (href.len == 0) { + return ""; + } + + // Always resolve the href against the page URL + return URL.resolve(page.call_arena, page.url, href, .{}); } pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { @@ -63,3 +69,8 @@ pub const JsApi = struct { pub const rel = bridge.accessor(Link.getRel, Link.setRel, .{}); pub const href = bridge.accessor(Link.getHref, Link.setHref, .{}); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Link" { + try testing.htmlRunner("element/html/link.html", .{}); +} From 38fb5b101ec5a13e08fab1bade7b3312f28d14cc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Dec 2025 19:49:51 +0800 Subject: [PATCH 206/257] add Document.elementFromPoint and elementsFromPoint --- .../tests/document/element_from_point.html | 233 ++++++++++++++++++ src/browser/tests/legacy/html/document.html | 18 +- src/browser/tests/legacy/html/image.html | 4 +- src/browser/tests/legacy/html/link.html | 2 +- src/browser/tests/window/navigator.html | 51 +--- src/browser/webapi/Document.zig | 44 ++++ src/browser/webapi/Navigator.zig | 13 +- .../collections/HTMLOptionsCollection.zig | 6 - src/browser/webapi/element/Html.zig | 32 +-- src/browser/webapi/selector/List.zig | 6 - 10 files changed, 311 insertions(+), 98 deletions(-) create mode 100644 src/browser/tests/document/element_from_point.html diff --git a/src/browser/tests/document/element_from_point.html b/src/browser/tests/document/element_from_point.html new file mode 100644 index 000000000..d3ea7da98 --- /dev/null +++ b/src/browser/tests/document/element_from_point.html @@ -0,0 +1,233 @@ + + + + +
Div 1
+
Div 2
+ +
+
Child
+
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/html/document.html b/src/browser/tests/legacy/html/document.html index 003ee8205..7abb0b034 100644 --- a/src/browser/tests/legacy/html/document.html +++ b/src/browser/tests/legacy/html/document.html @@ -52,13 +52,6 @@ let div1 = document.createElement('div'); document.body.appendChild(div1); div1.getClientRects(); // clal this to position it - testing.expectEqual('[object HTMLDivElement]', document.elementFromPoint(2.5, 2.5).toString()); - - let elems = document.elementsFromPoint(2.5, 2.5); - testing.expectEqual(3, elems.length); - testing.expectEqual('[object HTMLDivElement]', elems[0].toString()); - testing.expectEqual('[object HTMLBodyElement]', elems[1].toString()); - testing.expectEqual('[object HTMLHtmlElement]', elems[2].toString()); let a = document.createElement('a'); a.href = "https://lightpanda.io"; @@ -66,20 +59,11 @@ // Note this will be placed after the div of previous test a.getClientRects(); - let a_again = document.elementFromPoint(7.5, 0.5); - testing.expectEqual('[object HTMLAnchorElement]', a_again.toString()); - testing.expectEqual('https://lightpanda.io', a_again.href); - - let a_agains = document.elementsFromPoint(7.5, 0.5); - testing.expectEqual('https://lightpanda.io', a_agains[0].href); - - testing.expectEqual(true, !document.all); testing.expectEqual(false, !!document.all); - testing.expectEqual('[object HTMLScriptElement]', document.all(5).toString()); + testing.expectEqual('[object HTMLScriptElement]', document.all(6).toString()); testing.expectEqual('[object HTMLDivElement]', document.all('content').toString()); - testing.expectEqual(document, document.defaultView.document ); testing.expectEqual('loading', document.readyState); diff --git a/src/browser/tests/legacy/html/image.html b/src/browser/tests/legacy/html/image.html index 1e3f6aff2..053b2cfa1 100644 --- a/src/browser/tests/legacy/html/image.html +++ b/src/browser/tests/legacy/html/image.html @@ -26,7 +26,7 @@ let lyric = new Image testing.expectEqual('', lyric.src); lyric.src = 'okay'; - testing.expectEqual('okay', lyric.src); + testing.expectEqual('http://localhost:9589/html/okay', lyric.src); lyric.src = 15; - testing.expectEqual('15', lyric.src); + testing.expectEqual('http://localhost:9589/html/15', lyric.src); diff --git a/src/browser/tests/legacy/html/link.html b/src/browser/tests/legacy/html/link.html index 15da64611..958690521 100644 --- a/src/browser/tests/legacy/html/link.html +++ b/src/browser/tests/legacy/html/link.html @@ -9,7 +9,7 @@ testing.expectEqual('_blank', link.target); link.target = ''; - testing.expectEqual('foo', link.href); + testing.expectEqual('http://localhost:9589/html/foo', link.href); link.href = 'https://lightpanda.io/'; testing.expectEqual('https://lightpanda.io/', link.href); diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/window/navigator.html index e00f429bd..11ad9adec 100644 --- a/src/browser/tests/window/navigator.html +++ b/src/browser/tests/window/navigator.html @@ -4,67 +4,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index a665849fe..87019a549 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -345,6 +345,48 @@ pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !vo } } +pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element { + // Traverse document in depth-first order to find the topmost (last in document order) + // element that contains the point (x, y) + var topmost: ?*Element = null; + + const root = self.asNode(); + var stack: std.ArrayList(*Node) = .empty; + try stack.append(page.call_arena, root); + + while (stack.items.len > 0) { + const node = stack.pop() orelse break; + if (node.is(Element)) |element| { + if (try element.checkVisibility(page)) { + const rect = try element.getBoundingClientRect(page); + if (x >= rect._left and x <= rect._right and y >= rect._top and y <= rect._bottom) { + topmost = element; + } + } + } + + // Add children to stack in reverse order so we process them in document order + var child = node.lastChild(); + while (child) |c| { + try stack.append(page.call_arena, c); + child = c.previousSibling(); + } + } + + return topmost; +} + +pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const *Element { + // Get topmost element + var current: ?*Element = (try self.elementFromPoint(x, y, page)) orelse return &.{}; + var result: std.ArrayList(*Element) = .empty; + while (current) |el| { + try result.append(page.call_arena, el); + current = el.parentElement(); + } + return result.items; +} + const ReadyState = enum { loading, interactive, @@ -404,6 +446,8 @@ pub const JsApi = struct { pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); pub const append = bridge.function(Document.append, .{}); pub const prepend = bridge.function(Document.prepend, .{}); + pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{}); + pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{}); pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 23efd49f6..b38d1093f 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -25,15 +25,19 @@ _pad: bool = false, pub const init: Navigator = .{}; pub fn getUserAgent(_: *const Navigator) []const u8 { - return "Mozilla/5.0 (compatible; LiteFetch/0.1)"; + return "Lightpanda/1.0"; } pub fn getAppName(_: *const Navigator) []const u8 { - return "LiteFetch"; + return "Netscape"; +} + +pub fn getAppCodeName(_: *const Navigator) []const u8 { + return "Netscape"; } pub fn getAppVersion(_: *const Navigator) []const u8 { - return "0.1"; + return "1.0"; } pub fn getPlatform(_: *const Navigator) []const u8 { @@ -73,7 +77,7 @@ pub fn getMaxTouchPoints(_: *const Navigator) u32 { /// Returns the vendor name pub fn getVendor(_: *const Navigator) []const u8 { - return "LiteFetch"; + return ""; } /// Returns the product name (typically "Gecko" for compatibility) @@ -104,6 +108,7 @@ pub const JsApi = struct { // Read-only properties pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{}); pub const appName = bridge.accessor(Navigator.getAppName, null, .{}); + pub const appCodeName = bridge.accessor(Navigator.getAppCodeName, null, .{}); pub const appVersion = bridge.accessor(Navigator.getAppVersion, null, .{}); pub const platform = bridge.accessor(Navigator.getPlatform, null, .{}); pub const language = bridge.accessor(Navigator.getLanguage, null, .{}); diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig index 6a0cadc95..4474cb761 100644 --- a/src/browser/webapi/collections/HTMLOptionsCollection.zig +++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig @@ -30,11 +30,6 @@ const HTMLOptionsCollection = @This(); _proto: *HTMLCollection, _select: *@import("../element/html/Select.zig"), -pub fn deinit(self: *HTMLOptionsCollection) void { - const page = Page.current; - page._factory.destroy(self); -} - // Forward length to HTMLCollection pub fn length(self: *HTMLOptionsCollection, page: *Page) u32 { return self._proto.length(page); @@ -102,7 +97,6 @@ pub const JsApi = struct { pub const name = "HTMLOptionsCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const finalizer = HTMLOptionsCollection.deinit; pub const manage = false; }; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 341fbc949..e0220504f 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -120,11 +120,11 @@ pub fn is(self: *HtmlElement, comptime T: type) ?*T { pub fn className(self: *const HtmlElement) []const u8 { return switch (self._type) { - .anchor => "[object HtmlAnchorElement]", - .div => "[object HtmlDivElement]", - .embed => "[object HtmlEmbedElement]", + .anchor => "[object HTMLAnchorElement]", + .div => "[object HTMLDivElement]", + .embed => "[object HTMLEmbedElement]", .form => "[object HTMLFormElement]", - .p => "[object HtmlParagraphElement]", + .p => "[object HTMLParagraphElement]", .custom => "[object CUSTOM-TODO]", .data => "[object HTMLDataElement]", .dialog => "[object HTMLDialogElement]", @@ -137,22 +137,22 @@ pub fn className(self: *const HtmlElement) []const u8 { .ul => "[object HTMLULElement]", .ol => "[object HTMLOLElement]", .generic => "[object HTMLElement]", - .script => "[object HtmlScriptElement]", + .script => "[object HTMLScriptElement]", .select => "[object HTMLSelectElement]", .slot => "[object HTMLSlotElement]", .template => "[object HTMLTemplateElement]", .option => "[object HTMLOptionElement]", - .text_area => "[object HtmlTextAreaElement]", - .input => "[object HtmlInputElement]", - .link => "[object HtmlLinkElement]", - .meta => "[object HtmlMetaElement]", - .hr => "[object HtmlHRElement]", - .style => "[object HtmlSyleElement]", - .title => "[object HtmlTitleElement]", - .body => "[object HtmlBodyElement]", - .html => "[object HtmlHtmlElement]", - .head => "[object HtmlHeadElement]", - .unknown => "[object HtmlUnknownElement]", + .text_area => "[object HTMLTextAreaElement]", + .input => "[object HTMLInputElement]", + .link => "[object HTMLLinkElement]", + .meta => "[object HTMLMetaElement]", + .hr => "[object HTMLHRElement]", + .style => "[object HTMLSyleElement]", + .title => "[object HTMLTitleElement]", + .body => "[object HTMLBodyElement]", + .html => "[object HTMLHtmlElement]", + .head => "[object HTMLHeadElement]", + .unknown => "[object HTMLUnknownElement]", }; } diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 06d2045ed..5b1eb6324 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -77,12 +77,6 @@ pub fn initOne(root: *Node, selector: Selector.Selector, page: *Page) ?*Node { return null; } -pub fn deinit(self: *List) void { - const page = Page.current; - page._mem.releaseArena(self._arena); - page._factory.destroy(self); -} - const OptimizeResult = struct { root: *Node, exclude_root: bool, From fe89aad621edd387b35e0f026ef1ae953af57501 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 5 Dec 2025 16:03:10 +0300 Subject: [PATCH 207/257] add `isEqualNode` rework `isEqualNode` Splits equality logic by node types and groups comparisons nicer. prefer ancestor's`isEqualNode` `nodeType` => `getNodeType` fix attribute comparison logic Also introduces attribute counting. remove debug logging add `isEqualNode` test --- src/browser/tests/node/is_equal_node.html | 40 +++++++++++++++++++++ src/browser/webapi/CData.zig | 4 +++ src/browser/webapi/Element.zig | 43 +++++++++++++++++++++++ src/browser/webapi/Node.zig | 22 ++++++++++++ src/browser/webapi/element/Attribute.zig | 43 +++++++++++++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 src/browser/tests/node/is_equal_node.html diff --git a/src/browser/tests/node/is_equal_node.html b/src/browser/tests/node/is_equal_node.html new file mode 100644 index 000000000..ef192fae2 --- /dev/null +++ b/src/browser/tests/node/is_equal_node.html @@ -0,0 +1,40 @@ + + + +
+ we're no strangers to love + you know the rules + + and so do I +
+ +
+ we're no strangers to love + you know the rules + + and so do I +
+ + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index cb17aaad5..82afee542 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -146,6 +146,10 @@ pub fn getLength(self: *const CData) usize { return self._data.len; } +pub fn isEqualNode(self: *const CData, other: *const CData) bool { + return std.mem.eql(u8, self.getData(), other.getData()); +} + pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { const new_data = try std.mem.concat(page.arena, u8, &.{ self._data, data }); try self.setData(new_data, page); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 00b46cdf7..382bc41f5 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -117,6 +117,49 @@ pub fn className(self: *const Element) []const u8 { }; } +pub fn attributesEql(self: *const Element, other: *Element) bool { + if (self._attributes) |attr_list| { + const other_list = other._attributes orelse return false; + return attr_list.eql(other_list); + } + // Make sure no attrs in both sides. + return other._attributes == null; +} + +/// TODO: localName and prefix comparison. +pub fn isEqualNode(self: *Element, other: *Element) bool { + const self_tag = self.getTagNameDump(); + const other_tag = other.getTagNameDump(); + // Compare namespaces and tags. + const dirty = self._namespace != other._namespace or !std.mem.eql(u8, self_tag, other_tag); + if (dirty) { + return false; + } + + // Compare attributes. + if (!self.attributesEql(other)) { + return false; + } + + // Compare children. + var self_iter = self.asNode().childrenIterator(); + var other_iter = other.asNode().childrenIterator(); + var self_count: usize = 0; + var other_count: usize = 0; + while (self_iter.next()) |self_node| : (self_count += 1) { + const other_node = other_iter.next() orelse return false; + other_count += 1; + if (self_node.isEqualNode(other_node)) { + continue; + } + + return false; + } + + // Make sure both have equal number of children. + return self_count == other_count; +} + pub fn getTagNameLower(self: *const Element) []const u8 { switch (self._type) { .html => |he| switch (he._type) { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index f203c7ae5..564bf5990 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -286,6 +286,27 @@ pub fn getNodeType(self: *const Node) u8 { }; } +pub fn isEqualNode(self: *Node, other: *Node) bool { + // Make sure types match. + if (self.getNodeType() != other.getNodeType()) { + return false; + } + + // TODO: Compare `localName` and prefix. + return switch (self._type) { + .element => self.as(Element).isEqualNode(other.as(Element)), + .attribute => self.as(Element.Attribute).isEqualNode(other.as(Element.Attribute)), + .cdata => self.as(CData).isEqualNode(other.as(CData)), + else => { + log.warn(.browser, "not implemented", .{ + .type = self._type, + .feature = "Node.isEqualNode", + }); + return false; + }, + }; +} + pub fn isInShadowTree(self: *Node) bool { var node = self._parent; while (node) |n| { @@ -822,6 +843,7 @@ pub const JsApi = struct { pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true }); pub const compareDocumentPosition = bridge.function(Node.compareDocumentPosition, .{}); pub const getRootNode = bridge.function(Node.getRootNode, .{}); + pub const isEqualNode = bridge.function(Node.isEqualNode, .{}); pub const toString = bridge.function(_toString, .{}); fn _toString(self: *const Node) []const u8 { diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index d7fa36e6e..693b3842f 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -78,6 +78,10 @@ pub fn getOwnerElement(self: *const Attribute) ?*Element { return self._element; } +pub fn isEqualNode(self: *const Attribute, other: *const Attribute) bool { + return std.mem.eql(u8, self.getName(), other.getName()) and std.mem.eql(u8, self.getValue(), other.getValue()); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Attribute); @@ -119,16 +123,45 @@ pub const JsApi = struct { // attribute in the DOM, and, again, we expect that to almost always be null. pub const List = struct { normalize: bool, + /// Length of items in `_list`. Not usize to increase memory usage. + /// Honestly, this is more than enough. + _len: u32 = 0, _list: std.DoublyLinkedList = .{}, pub fn isEmpty(self: *const List) bool { return self._list.first == null; } + pub fn get(self: *const List, name: []const u8, page: *Page) !?[]const u8 { const entry = (try self.getEntry(name, page)) orelse return null; return entry._value.str(); } + pub inline fn length(self: *const List) usize { + return self._len; + } + + /// Compares 2 attribute lists for equality. + pub fn eql(self: *List, other: *List) bool { + if (self.length() != other.length()) { + return false; + } + + var iter = self.iterator(); + search: while (iter.next()) |attr| { + // Iterate over all `other` attributes. + var other_iter = other.iterator(); + while (other_iter.next()) |other_attr| { + if (attr.eql(other_attr)) { + continue :search; // Found match. + } + } + // Iterated over all `other` and not match. + return false; + } + return true; + } + // meant for internal usage, where the name is known to be properly cased pub fn getSafe(self: *const List, name: []const u8) ?[]const u8 { const entry = self.getEntryWithNormalizedName(name) orelse return null; @@ -180,6 +213,7 @@ pub const List = struct { ._value = try String.init(page.arena, value, .{}), }); self._list.append(&entry._node); + self._len += 1; } if (is_id) { @@ -203,6 +237,7 @@ pub const List = struct { ._value = try String.init(page.arena, value, .{}), }); self._list.append(&entry._node); + self._len += 1; } // not efficient, won't be called often (if ever!) @@ -235,6 +270,7 @@ pub const List = struct { ._value = try String.init(page.arena, value, .{}), }); self._list.append(&entry._node); + self._len += 1; } pub fn delete(self: *List, name: []const u8, element: *Element, page: *Page) !void { @@ -252,6 +288,7 @@ pub const List = struct { page.attributeRemove(element, result.normalized, old_value); _ = page._attribute_lookup.remove(@intFromPtr(entry)); self._list.remove(&entry._node); + self._len -= 1; page._factory.destroy(entry); } @@ -311,6 +348,12 @@ pub const List = struct { return @alignCast(@fieldParentPtr("_node", n)); } + /// Returns true if 2 entries are equal. + /// This doesn't compare `_node` fields. + pub fn eql(self: *const Entry, other: *const Entry) bool { + return self._name.eql(other._name) and self._value.eql(other._value); + } + pub fn format(self: *const Entry, writer: *std.Io.Writer) !void { return formatAttribute(self._name.str(), self._value.str(), writer); } From 4d8d6c10c6281cc3ca02c4fbdff5afeb6ee72737 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Dec 2025 12:13:01 -0800 Subject: [PATCH 208/257] add option inheriting for Events --- src/browser/webapi/Event.zig | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 0a49655b3..e99d71ece 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -18,6 +18,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const reflect = @import("../reflect.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); @@ -195,6 +196,54 @@ pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { return path; } +pub fn populateFromOptions(self: *Event, opts: anytype) void { + self._bubbles = opts.bubbles; + self._cancelable = opts.cancelable; + self._composed = opts.composed; +} + +pub fn inheritOptions(comptime T: type, comptime additions: anytype) type { + var all_fields: []const std.builtin.Type.StructField = &.{}; + + if (@hasField(T, "_proto")) { + const t_fields = @typeInfo(T).@"struct".fields; + + inline for (t_fields) |field| { + if (std.mem.eql(u8, field.name, "_proto")) { + const ProtoType = reflect.Struct(field.type); + if (@hasDecl(ProtoType, "Options")) { + const parent_options = @typeInfo(ProtoType.Options); + all_fields = all_fields ++ parent_options.@"struct".fields; + } + } + } + } + + const additions_info = @typeInfo(additions); + all_fields = all_fields ++ additions_info.@"struct".fields; + + return @Type(.{ + .@"struct" = .{ + .layout = .auto, + .fields = all_fields, + .decls = &.{}, + .is_tuple = false, + }, + }); +} + +pub fn populatePrototypes(self: anytype, opts: anytype) void { + const T = @TypeOf(self.*); + + if (@hasField(T, "_proto")) { + populatePrototypes(self._proto, opts); + } + + if (@hasDecl(T, "populateFromOptions")) { + T.populateFromOptions(self, opts); + } +} + pub const JsApi = struct { pub const bridge = js.Bridge(Event); From b568eb4e1e1c35567dbc39533d98a3213e02e4ee Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Dec 2025 12:13:43 -0800 Subject: [PATCH 209/257] migrate events to use new inheritOptions --- src/browser/webapi/event/CompositionEvent.zig | 14 +++---- src/browser/webapi/event/CustomEvent.zig | 27 ++++++------- src/browser/webapi/event/ErrorEvent.zig | 39 ++++++++++--------- src/browser/webapi/event/MessageEvent.zig | 27 ++++++------- .../NavigationCurrentEntryChangeEvent.zig | 27 +++++++++---- .../webapi/event/PageTransitionEvent.zig | 28 ++++++++----- src/browser/webapi/event/PopStateEvent.zig | 26 ++++++++----- src/browser/webapi/event/ProgressEvent.zig | 28 ++++++++++--- .../webapi/net/XMLHttpRequestEventTarget.zig | 6 ++- 9 files changed, 136 insertions(+), 86 deletions(-) diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index 7fa701bd0..3f2e83156 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -26,23 +26,21 @@ const CompositionEvent = @This(); _proto: *Event, _data: []const u8 = "", -pub const InitOptions = struct { +const CompositionEventOptions = struct { data: ?[]const u8 = null, - bubbles: bool = false, - cancelable: bool = false, }; -pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*CompositionEvent { - const opts = opts_ orelse InitOptions{}; +pub const Options = Event.inheritOptions(CompositionEvent, CompositionEventOptions); + +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { + const opts = opts_ orelse Options{}; const event = try page._factory.event(typ, CompositionEvent{ ._proto = undefined, ._data = if (opts.data) |str| try page.dupeString(str) else "", }); - event._proto._bubbles = opts.bubbles; - event._proto._cancelable = opts.cancelable; - + Event.populatePrototypes(event, opts); return event; } diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index 1c36fc33e..a5f3db01e 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -29,25 +29,26 @@ _proto: *Event, _detail: ?js.Object = null, _arena: Allocator, -pub const InitOptions = struct { +const CustomEventOptions = struct { detail: ?js.Object = null, - bubbles: bool = false, - cancelable: bool = false, }; -pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*CustomEvent { - const arena = page.arena; - const opts = opts_ orelse InitOptions{}; +pub const Options = Event.inheritOptions(CustomEvent, CustomEventOptions); - const event = try page._factory.event(typ, CustomEvent{ - ._arena = arena, - ._proto = undefined, - ._detail = if (opts.detail) |detail| try detail.persist() else null, - }); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CustomEvent { + const arena = page.arena; + const opts = opts_ orelse Options{}; - event._proto._bubbles = opts.bubbles; - event._proto._cancelable = opts.cancelable; + const event = try page._factory.event( + typ, + CustomEvent{ + ._arena = arena, + ._proto = undefined, + ._detail = if (opts.detail) |detail| try detail.persist() else null, + }, + ); + Event.populatePrototypes(event, opts); return event; } diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 9c7f15700..f96073237 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -33,33 +33,34 @@ _column_number: u32 = 0, _error: ?js.Object = null, _arena: Allocator, -pub const InitOptions = struct { +pub const ErrorEventOptions = struct { message: ?[]const u8 = null, filename: ?[]const u8 = null, lineno: u32 = 0, colno: u32 = 0, @"error": ?js.Object = null, - bubbles: bool = false, - cancelable: bool = false, }; -pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*ErrorEvent { - const arena = page.arena; - const opts = opts_ orelse InitOptions{}; - - const event = try page._factory.event(typ, ErrorEvent{ - ._arena = arena, - ._proto = undefined, - ._message = if (opts.message) |str| try arena.dupe(u8, str) else "", - ._filename = if (opts.filename) |str| try arena.dupe(u8, str) else "", - ._line_number = opts.lineno, - ._column_number = opts.colno, - ._error = if (opts.@"error") |err| try err.persist() else null, - }); - - event._proto._bubbles = opts.bubbles; - event._proto._cancelable = opts.cancelable; +pub const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { + const arena = page.arena; + const opts = opts_ orelse Options{}; + + const event = try page._factory.event( + typ, + ErrorEvent{ + ._arena = arena, + ._proto = undefined, + ._message = if (opts.message) |str| try arena.dupe(u8, str) else "", + ._filename = if (opts.filename) |str| try arena.dupe(u8, str) else "", + ._line_number = opts.lineno, + ._column_number = opts.colno, + ._error = if (opts.@"error") |err| try err.persist() else null, + }, + ); + + Event.populatePrototypes(event, opts); return event; } diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index ed59bb2f4..45abe7317 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -29,27 +29,28 @@ _data: ?js.Object = null, _origin: []const u8 = "", _source: ?*Window = null, -pub const InitOptions = struct { +const MessageEventOptions = struct { data: ?js.Object = null, origin: ?[]const u8 = null, source: ?*Window = null, - bubbles: bool = false, - cancelable: bool = false, }; -pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*MessageEvent { - const opts = opts_ orelse InitOptions{}; +pub const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); - const event = try page._factory.event(typ, MessageEvent{ - ._proto = undefined, - ._data = if (opts.data) |d| try d.persist() else null, - ._origin = if (opts.origin) |str| try page.arena.dupe(u8, str) else "", - ._source = opts.source, - }); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { + const opts = opts_ orelse Options{}; - event._proto._bubbles = opts.bubbles; - event._proto._cancelable = opts.cancelable; + const event = try page._factory.event( + typ, + MessageEvent{ + ._proto = undefined, + ._data = if (opts.data) |d| try d.persist() else null, + ._origin = if (opts.origin) |str| try page.arena.dupe(u8, str) else "", + ._source = opts.source, + }, + ); + Event.populatePrototypes(event, opts); return event; } diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index f5d2a9358..6c491a75e 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -30,26 +30,37 @@ _proto: *Event, _from: *NavigationHistoryEntry, _navigation_type: ?NavigationType, -pub const EventInit = struct { +const NavigationCurrentEntryChangeEventOptions = struct { from: *NavigationHistoryEntry, navigationType: ?[]const u8 = null, }; +pub const Options = Event.inheritOptions( + NavigationCurrentEntryChangeEvent, + NavigationCurrentEntryChangeEventOptions, +); + pub fn init( typ: []const u8, - init_obj: EventInit, + opts: Options, page: *Page, ) !*NavigationCurrentEntryChangeEvent { - const navigation_type = if (init_obj.navigationType) |nav_type_str| + const navigation_type = if (opts.navigationType) |nav_type_str| std.meta.stringToEnum(NavigationType, nav_type_str) else null; - return page._factory.event(typ, NavigationCurrentEntryChangeEvent{ - ._proto = undefined, - ._from = init_obj.from, - ._navigation_type = navigation_type, - }); + const event = try page._factory.event( + typ, + NavigationCurrentEntryChangeEvent{ + ._proto = undefined, + ._from = opts.from, + ._navigation_type = navigation_type, + }, + ); + + Event.populatePrototypes(event, opts); + return event; } pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index 44b2c9e71..82470b1ad 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -25,18 +25,28 @@ const Page = @import("../../Page.zig"); // https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent const PageTransitionEvent = @This(); -const EventInit = struct { - persisted: ?bool = null, -}; - _proto: *Event, _persisted: bool, -pub fn init(typ: []const u8, init_obj: EventInit, page: *Page) !*PageTransitionEvent { - return page._factory.event(typ, PageTransitionEvent{ - ._proto = undefined, - ._persisted = init_obj.persisted orelse false, - }); +const PageTransitionEventOptions = struct { + persisted: ?bool = false, +}; + +pub const Options = Event.inheritOptions(PageTransitionEvent, PageTransitionEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PageTransitionEvent { + const opts = _opts orelse Options{}; + + const event = try page._factory.event( + typ, + PageTransitionEvent{ + ._proto = undefined, + ._persisted = opts.persisted orelse false, + }, + ); + + Event.populatePrototypes(event, opts); + return event; } pub fn asEvent(self: *PageTransitionEvent) *Event { diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index 3ecffb997..caceddc73 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -25,20 +25,28 @@ const Page = @import("../../Page.zig"); // https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent const PopStateEvent = @This(); -const EventOptions = struct { +_proto: *Event, +_state: ?[]const u8, + +const PopStateEventOptions = struct { state: ?[]const u8 = null, }; -_proto: *Event, -_state: ?[]const u8, +pub const Options = Event.inheritOptions(PopStateEvent, PopStateEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PopStateEvent { + const opts = _opts orelse Options{}; -pub fn init(typ: []const u8, _options: ?EventOptions, page: *Page) !*PopStateEvent { - const options = _options orelse EventOptions{}; + const event = try page._factory.event( + typ, + PopStateEvent{ + ._proto = undefined, + ._state = opts.state, + }, + ); - return page._factory.event(typ, PopStateEvent{ - ._proto = undefined, - ._state = options.state, - }); + Event.populatePrototypes(event, opts); + return event; } pub fn asEvent(self: *PopStateEvent) *Event { diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index 6e824a787..ea1f6931b 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -25,12 +25,28 @@ _total: usize = 0, _loaded: usize = 0, _length_computable: bool = false, -pub fn init(typ: []const u8, total: usize, loaded: usize, page: *Page) !*ProgressEvent { - return page._factory.event(typ, ProgressEvent{ - ._proto = undefined, - ._total = total, - ._loaded = loaded, - }); +const ProgressEventOptions = struct { + total: usize = 0, + loaded: usize = 0, + lengthComputable: bool = false, +}; + +pub const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*ProgressEvent { + const opts = _opts orelse Options{}; + + const event = try page._factory.event( + typ, + ProgressEvent{ + ._proto = undefined, + ._total = opts.total, + ._loaded = opts.loaded, + }, + ); + + Event.populatePrototypes(event, opts); + return event; } pub fn asEvent(self: *ProgressEvent) *Event { diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index 4bc16b236..af861c741 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -57,7 +57,11 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT }; const progress = progress_ orelse Progress{}; - const event = try ProgressEvent.init(typ, progress.total, progress.loaded, page); + const event = try ProgressEvent.init( + typ, + .{ .total = progress.total, .loaded = progress.loaded }, + page, + ); return page._event_manager.dispatchWithFunction( self.asEventTarget(), From 669c934ae053c5cfbc8e3efeff3fd1a4be1d211a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Dec 2025 12:17:07 -0800 Subject: [PATCH 210/257] Event Options dont need to be pub --- src/browser/webapi/event/CompositionEvent.zig | 2 +- src/browser/webapi/event/CustomEvent.zig | 2 +- src/browser/webapi/event/ErrorEvent.zig | 2 +- src/browser/webapi/event/MessageEvent.zig | 2 +- src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig | 2 +- src/browser/webapi/event/PageTransitionEvent.zig | 2 +- src/browser/webapi/event/PopStateEvent.zig | 2 +- src/browser/webapi/event/ProgressEvent.zig | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index 3f2e83156..a758c6212 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -30,7 +30,7 @@ const CompositionEventOptions = struct { data: ?[]const u8 = null, }; -pub const Options = Event.inheritOptions(CompositionEvent, CompositionEventOptions); +const Options = Event.inheritOptions(CompositionEvent, CompositionEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { const opts = opts_ orelse Options{}; diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index a5f3db01e..1e420e8cb 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -33,7 +33,7 @@ const CustomEventOptions = struct { detail: ?js.Object = null, }; -pub const Options = Event.inheritOptions(CustomEvent, CustomEventOptions); +const Options = Event.inheritOptions(CustomEvent, CustomEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CustomEvent { const arena = page.arena; diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index f96073237..257cf1787 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -41,7 +41,7 @@ pub const ErrorEventOptions = struct { @"error": ?js.Object = null, }; -pub const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); +const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { const arena = page.arena; diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 45abe7317..9f24c517f 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -35,7 +35,7 @@ const MessageEventOptions = struct { source: ?*Window = null, }; -pub const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); +const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { const opts = opts_ orelse Options{}; diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 6c491a75e..40c8122a3 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -35,7 +35,7 @@ const NavigationCurrentEntryChangeEventOptions = struct { navigationType: ?[]const u8 = null, }; -pub const Options = Event.inheritOptions( +const Options = Event.inheritOptions( NavigationCurrentEntryChangeEvent, NavigationCurrentEntryChangeEventOptions, ); diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index 82470b1ad..2b7d063fe 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -32,7 +32,7 @@ const PageTransitionEventOptions = struct { persisted: ?bool = false, }; -pub const Options = Event.inheritOptions(PageTransitionEvent, PageTransitionEventOptions); +const Options = Event.inheritOptions(PageTransitionEvent, PageTransitionEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PageTransitionEvent { const opts = _opts orelse Options{}; diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index caceddc73..45a088c59 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -32,7 +32,7 @@ const PopStateEventOptions = struct { state: ?[]const u8 = null, }; -pub const Options = Event.inheritOptions(PopStateEvent, PopStateEventOptions); +const Options = Event.inheritOptions(PopStateEvent, PopStateEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PopStateEvent { const opts = _opts orelse Options{}; diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index ea1f6931b..8fbfbb87c 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -31,7 +31,7 @@ const ProgressEventOptions = struct { lengthComputable: bool = false, }; -pub const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions); +const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*ProgressEvent { const opts = _opts orelse Options{}; From 5671580c2d4b4759dbedc52c51e7b980268047a3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Dec 2025 12:25:25 -0800 Subject: [PATCH 211/257] properly remove mbedtls submodule --- vendor/mbedtls | 1 - 1 file changed, 1 deletion(-) delete mode 160000 vendor/mbedtls diff --git a/vendor/mbedtls b/vendor/mbedtls deleted file mode 160000 index c765c831e..000000000 --- a/vendor/mbedtls +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c765c831e5c2a0971410692f92f7a81d6ec65ec2 From bd0f1d2884eab2e3c7491a23a487b9d68af56009 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Dec 2025 12:25:34 -0800 Subject: [PATCH 212/257] remove mbedtls stuff for build.zig --- build.zig | 121 ------------------------------------------------------ 1 file changed, 121 deletions(-) diff --git a/build.zig b/build.zig index 2519ec097..0070e5769 100644 --- a/build.zig +++ b/build.zig @@ -454,7 +454,6 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo mod.linkLibrary(ssl); mod.linkLibrary(crypto); - try buildMbedtls(b, mod); try buildNghttp2(b, mod); try buildCurl(b, mod); @@ -526,126 +525,6 @@ fn buildBrotli(b: *Build, m: *Build.Module) !void { } }); } -fn buildMbedtls(b: *Build, m: *Build.Module) !void { - const mbedtls = b.addLibrary(.{ - .name = "mbedtls", - .root_module = m, - }); - - const root = "vendor/mbedtls/"; - mbedtls.addIncludePath(b.path(root ++ "include")); - mbedtls.addIncludePath(b.path(root ++ "library")); - - mbedtls.addCSourceFiles(.{ .flags = &.{}, .files = &.{ - root ++ "library/aes.c", - root ++ "library/aesni.c", - root ++ "library/aesce.c", - root ++ "library/aria.c", - root ++ "library/asn1parse.c", - root ++ "library/asn1write.c", - root ++ "library/base64.c", - root ++ "library/bignum.c", - root ++ "library/bignum_core.c", - root ++ "library/bignum_mod.c", - root ++ "library/bignum_mod_raw.c", - root ++ "library/camellia.c", - root ++ "library/ccm.c", - root ++ "library/chacha20.c", - root ++ "library/chachapoly.c", - root ++ "library/cipher.c", - root ++ "library/cipher_wrap.c", - root ++ "library/constant_time.c", - root ++ "library/cmac.c", - root ++ "library/ctr_drbg.c", - root ++ "library/des.c", - root ++ "library/dhm.c", - root ++ "library/ecdh.c", - root ++ "library/ecdsa.c", - root ++ "library/ecjpake.c", - root ++ "library/ecp.c", - root ++ "library/ecp_curves.c", - root ++ "library/entropy.c", - root ++ "library/entropy_poll.c", - root ++ "library/error.c", - root ++ "library/gcm.c", - root ++ "library/hkdf.c", - root ++ "library/hmac_drbg.c", - root ++ "library/lmots.c", - root ++ "library/lms.c", - root ++ "library/md.c", - root ++ "library/md5.c", - root ++ "library/memory_buffer_alloc.c", - root ++ "library/nist_kw.c", - root ++ "library/oid.c", - root ++ "library/padlock.c", - root ++ "library/pem.c", - root ++ "library/pk.c", - root ++ "library/pk_ecc.c", - root ++ "library/pk_wrap.c", - root ++ "library/pkcs12.c", - root ++ "library/pkcs5.c", - root ++ "library/pkparse.c", - root ++ "library/pkwrite.c", - root ++ "library/platform.c", - root ++ "library/platform_util.c", - root ++ "library/poly1305.c", - root ++ "library/psa_crypto.c", - root ++ "library/psa_crypto_aead.c", - root ++ "library/psa_crypto_cipher.c", - root ++ "library/psa_crypto_client.c", - root ++ "library/psa_crypto_ffdh.c", - root ++ "library/psa_crypto_driver_wrappers_no_static.c", - root ++ "library/psa_crypto_ecp.c", - root ++ "library/psa_crypto_hash.c", - root ++ "library/psa_crypto_mac.c", - root ++ "library/psa_crypto_pake.c", - root ++ "library/psa_crypto_rsa.c", - root ++ "library/psa_crypto_se.c", - root ++ "library/psa_crypto_slot_management.c", - root ++ "library/psa_crypto_storage.c", - root ++ "library/psa_its_file.c", - root ++ "library/psa_util.c", - root ++ "library/ripemd160.c", - root ++ "library/rsa.c", - root ++ "library/rsa_alt_helpers.c", - root ++ "library/sha1.c", - root ++ "library/sha3.c", - root ++ "library/sha256.c", - root ++ "library/sha512.c", - root ++ "library/threading.c", - root ++ "library/timing.c", - root ++ "library/version.c", - root ++ "library/version_features.c", - root ++ "library/pkcs7.c", - root ++ "library/x509.c", - root ++ "library/x509_create.c", - root ++ "library/x509_crl.c", - root ++ "library/x509_crt.c", - root ++ "library/x509_csr.c", - root ++ "library/x509write.c", - root ++ "library/x509write_crt.c", - root ++ "library/x509write_csr.c", - root ++ "library/debug.c", - root ++ "library/mps_reader.c", - root ++ "library/mps_trace.c", - root ++ "library/net_sockets.c", - root ++ "library/ssl_cache.c", - root ++ "library/ssl_ciphersuites.c", - root ++ "library/ssl_client.c", - root ++ "library/ssl_cookie.c", - root ++ "library/ssl_debug_helpers_generated.c", - root ++ "library/ssl_msg.c", - root ++ "library/ssl_ticket.c", - root ++ "library/ssl_tls.c", - root ++ "library/ssl_tls12_client.c", - root ++ "library/ssl_tls12_server.c", - root ++ "library/ssl_tls13_keys.c", - root ++ "library/ssl_tls13_server.c", - root ++ "library/ssl_tls13_client.c", - root ++ "library/ssl_tls13_generic.c", - } }); -} - fn buildNghttp2(b: *Build, m: *Build.Module) !void { const nghttp2 = b.addLibrary(.{ .name = "nghttp2", From a4fa40743abcee0f068cafd0f95ea6414d635308 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 12 Dec 2025 07:58:26 +0800 Subject: [PATCH 213/257] ErrorEvent error as undefined --- src/browser/ScriptManager.zig | 2 +- .../tests/element/html/script/order.html | 35 +++++++++++++++++++ .../tests/element/html/script/order.js | 2 ++ .../tests/element/html/script/order_async.js | 2 ++ .../tests/element/html/script/order_defer.js | 2 ++ src/browser/webapi/event/ErrorEvent.zig | 2 +- 6 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/element/html/script/order.html create mode 100644 src/browser/tests/element/html/script/order.js create mode 100644 src/browser/tests/element/html/script/order_async.js create mode 100644 src/browser/tests/element/html/script/order_defer.js diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 06e940837..5f916fedc 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -218,7 +218,7 @@ pub fn addFromElement(self: *ScriptManager, script_element: *Element.Html.Script .url = remote_url orelse page.url, .mode = blk: { if (source == .@"inline") { - break :blk .normal; + break :blk if (kind == .module) .@"defer" else .normal; } if (element.getAttributeSafe("async") != null) { break :blk .async; diff --git a/src/browser/tests/element/html/script/order.html b/src/browser/tests/element/html/script/order.html new file mode 100644 index 000000000..159e68ccd --- /dev/null +++ b/src/browser/tests/element/html/script/order.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/order.js b/src/browser/tests/element/html/script/order.js new file mode 100644 index 000000000..31e602fc9 --- /dev/null +++ b/src/browser/tests/element/html/script/order.js @@ -0,0 +1,2 @@ +list += 'a'; +testing.expectEqual('a', list); diff --git a/src/browser/tests/element/html/script/order_async.js b/src/browser/tests/element/html/script/order_async.js new file mode 100644 index 000000000..343b7e69b --- /dev/null +++ b/src/browser/tests/element/html/script/order_async.js @@ -0,0 +1,2 @@ +list += 'f'; +testing.expectEqual('abcdef', list); diff --git a/src/browser/tests/element/html/script/order_defer.js b/src/browser/tests/element/html/script/order_defer.js new file mode 100644 index 000000000..3911b6445 --- /dev/null +++ b/src/browser/tests/element/html/script/order_defer.js @@ -0,0 +1,2 @@ +list += 'e'; +testing.expectEqual('abcde', list); diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 257cf1787..08124d7f2 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -103,7 +103,7 @@ pub const JsApi = struct { pub const filename = bridge.accessor(ErrorEvent.getFilename, null, .{}); pub const lineno = bridge.accessor(ErrorEvent.getLineNumber, null, .{}); pub const colno = bridge.accessor(ErrorEvent.getColumnNumber, null, .{}); - pub const @"error" = bridge.accessor(ErrorEvent.getError, null, .{}); + pub const @"error" = bridge.accessor(ErrorEvent.getError, null, .{ .null_as_undefined = true }); }; const testing = @import("../../../testing.zig"); From 5eb54bbc95a23255bee16c6f832df9be342a335f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 12 Dec 2025 17:34:57 +0800 Subject: [PATCH 214/257] Media/Audio/Video elements --- src/browser/Factory.zig | 7 + src/browser/Page.zig | 18 ++ src/browser/js/bridge.zig | 3 + src/browser/tests/element/html/media.html | 249 +++++++++++++++++ src/browser/webapi/Element.zig | 18 ++ src/browser/webapi/element/Html.zig | 45 +-- src/browser/webapi/element/html/Audio.zig | 49 ++++ src/browser/webapi/element/html/Media.zig | 324 ++++++++++++++++++++++ src/browser/webapi/element/html/Video.zig | 81 ++++++ 9 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 src/browser/tests/element/html/media.html create mode 100644 src/browser/webapi/element/html/Audio.zig create mode 100644 src/browser/webapi/element/html/Media.zig create mode 100644 src/browser/webapi/element/html/Video.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 69cf0f529..6a0de8037 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -239,6 +239,13 @@ pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) { ).create(allocator, child); } +pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + return try AutoPrototypeChain( + &.{ EventTarget, Node, Element, Element.Html, Element.Html.Media, @TypeOf(child) }, + ).create(allocator, child); +} + pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); const ChildT = @TypeOf(child); diff --git a/src/browser/Page.zig b/src/browser/Page.zig index aa4cd9e1b..c05fcb14a 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1186,6 +1186,16 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined }, ), + asUint("audio") => return self.createHtmlMediaElementT( + Element.Html.Media.Audio, + namespace, + attribute_iterator, + ), + asUint("video") => return self.createHtmlMediaElementT( + Element.Html.Media.Video, + namespace, + attribute_iterator, + ), else => {}, }, 6 => switch (@as(u48, @bitCast(name[0..6].*))) { @@ -1343,6 +1353,14 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac return node; } +fn createHtmlMediaElementT(self: *Page, comptime E: type, namespace: Element.Namespace, attribute_iterator: anytype) !*Node { + const media_element = try self._factory.htmlMediaElement(E{ ._proto = undefined }); + const element = media_element.asElement(); + element._namespace = namespace; + try self.populateElementAttributes(element, attribute_iterator); + return element.asNode(); +} + fn createSvgElementT(self: *Page, comptime E: type, tag_name: []const u8, attribute_iterator: anytype, svg_element: E) !*Node { const svg_element_ptr = try self._factory.svgElement(tag_name, svg_element); var element = svg_element_ptr.asElement(); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index b79622d06..4059d6a8e 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -526,6 +526,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/Html.zig"), @import("../webapi/element/html/IFrame.zig"), @import("../webapi/element/html/Anchor.zig"), + @import("../webapi/element/html/Audio.zig"), @import("../webapi/element/html/Body.zig"), @import("../webapi/element/html/BR.zig"), @import("../webapi/element/html/Button.zig"), @@ -544,6 +545,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Input.zig"), @import("../webapi/element/html/LI.zig"), @import("../webapi/element/html/Link.zig"), + @import("../webapi/element/html/Media.zig"), @import("../webapi/element/html/Meta.zig"), @import("../webapi/element/html/OL.zig"), @import("../webapi/element/html/Option.zig"), @@ -555,6 +557,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Template.zig"), @import("../webapi/element/html/TextArea.zig"), @import("../webapi/element/html/Title.zig"), + @import("../webapi/element/html/Video.zig"), @import("../webapi/element/html/UL.zig"), @import("../webapi/element/html/Unknown.zig"), @import("../webapi/element/Svg.zig"), diff --git a/src/browser/tests/element/html/media.html b/src/browser/tests/element/html/media.html new file mode 100644 index 000000000..cb6c1523f --- /dev/null +++ b/src/browser/tests/element/html/media.html @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index fd5cd75ca..7de5f14fd 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -188,6 +188,11 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .input => "input", .li => "li", .link => "link", + .media => |m| switch (m._type) { + .audio => "audio", + .video => "video", + .generic => "media", + }, .meta => "meta", .ol => "ol", .option => "option", @@ -236,6 +241,11 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .li => "LI", .link => "LINK", .meta => "META", + .media => |m| switch (m._type) { + .audio => "AUDIO", + .video => "VIDEO", + .generic => "MEDIA", + }, .ol => "OL", .option => "OPTION", .p => "P", @@ -1077,6 +1087,11 @@ pub fn getTag(self: *const Element) Tag { .ul => .ul, .ol => .ol, .generic => |g| g._tag, + .media => |m| switch (m._type) { + .audio => .audio, + .video => .video, + .generic => .media, + }, .script => .script, .select => .select, .slot => .slot, @@ -1103,6 +1118,7 @@ pub fn getTag(self: *const Element) Tag { pub const Tag = enum { anchor, + audio, b, body, br, @@ -1137,6 +1153,7 @@ pub const Tag = enum { link, main, meta, + media, nav, ol, option, @@ -1157,6 +1174,7 @@ pub const Tag = enum { textarea, title, ul, + video, unknown, // If the tag is "unknown", we can't use the optimized tag matching, but diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index e0220504f..8016c8be6 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -43,6 +43,7 @@ pub const Image = @import("html/Image.zig"); pub const Input = @import("html/Input.zig"); pub const LI = @import("html/LI.zig"); pub const Link = @import("html/Link.zig"); +pub const Media = @import("html/Media.zig"); pub const Meta = @import("html/Meta.zig"); pub const OL = @import("html/OL.zig"); pub const Option = @import("html/Option.zig"); @@ -89,6 +90,7 @@ pub const Type = union(enum) { input: *Input, li: *LI, link: *Link, + media: *Media, meta: *Meta, ol: *OL, option: *Option, @@ -121,37 +123,42 @@ pub fn is(self: *HtmlElement, comptime T: type) ?*T { pub fn className(self: *const HtmlElement) []const u8 { return switch (self._type) { .anchor => "[object HTMLAnchorElement]", - .div => "[object HTMLDivElement]", - .embed => "[object HTMLEmbedElement]", - .form => "[object HTMLFormElement]", - .p => "[object HTMLParagraphElement]", + .body => "[object HTMLBodyElement]", + .br => "[object HTMLBRElement]", + .button => "[object HTMLButtonElement]", .custom => "[object CUSTOM-TODO]", .data => "[object HTMLDataElement]", .dialog => "[object HTMLDialogElement]", - .img => "[object HTMLImageElement]", - .iframe => "[object HTMLIFrameElement]", - .br => "[object HTMLBRElement]", - .button => "[object HTMLButtonElement]", + .div => "[object HTMLDivElement]", + .embed => "[object HTMLEmbedElement]", + .form => "[object HTMLFormElement]", + .generic => "[object HTMLElement]", + .head => "[object HTMLHeadElement]", .heading => "[object HTMLHeadingElement]", + .hr => "[object HTMLHRElement]", + .html => "[object HTMLHtmlElement]", + .iframe => "[object HTMLIFrameElement]", + .img => "[object HTMLImageElement]", + .input => "[object HTMLInputElement]", .li => "[object HTMLLIElement]", - .ul => "[object HTMLULElement]", + .link => "[object HTMLLinkElement]", + .meta => "[object HTMLMetaElement]", + .media => |m| switch (m._type) { + .audio => "[object HTMLAudioElement]", + .video => "[object HTMLVideoElement]", + .generic => "[object HTMLMediaElement]", + }, .ol => "[object HTMLOLElement]", - .generic => "[object HTMLElement]", + .option => "[object HTMLOptionElement]", + .p => "[object HTMLParagraphElement]", .script => "[object HTMLScriptElement]", .select => "[object HTMLSelectElement]", .slot => "[object HTMLSlotElement]", + .style => "[object HTMLSyleElement]", .template => "[object HTMLTemplateElement]", - .option => "[object HTMLOptionElement]", .text_area => "[object HTMLTextAreaElement]", - .input => "[object HTMLInputElement]", - .link => "[object HTMLLinkElement]", - .meta => "[object HTMLMetaElement]", - .hr => "[object HTMLHRElement]", - .style => "[object HTMLSyleElement]", .title => "[object HTMLTitleElement]", - .body => "[object HTMLBodyElement]", - .html => "[object HTMLHtmlElement]", - .head => "[object HTMLHeadElement]", + .ul => "[object HTMLULElement]", .unknown => "[object HTMLUnknownElement]", }; } diff --git a/src/browser/webapi/element/html/Audio.zig b/src/browser/webapi/element/html/Audio.zig new file mode 100644 index 000000000..929d6acaf --- /dev/null +++ b/src/browser/webapi/element/html/Audio.zig @@ -0,0 +1,49 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const Media = @import("Media.zig"); + +pub const Audio = @This(); + +_proto: *Media, + +pub fn asMedia(self: *Audio) *Media { + return self._proto; +} + +pub fn asElement(self: *Audio) *Element { + return self._proto.asElement(); +} + +pub fn asNode(self: *Audio) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Audio); + + pub const Meta = struct { + pub const name = "HTMLAudioElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig new file mode 100644 index 000000000..dc29e1609 --- /dev/null +++ b/src/browser/webapi/element/html/Media.zig @@ -0,0 +1,324 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); +pub const Audio = @import("Audio.zig"); +pub const Video = @import("Video.zig"); +const MediaError = @import("../../media/MediaError.zig"); + +const Media = @This(); + +pub const ReadyState = enum(u16) { + HAVE_NOTHING = 0, + HAVE_METADATA = 1, + HAVE_CURRENT_DATA = 2, + HAVE_FUTURE_DATA = 3, + HAVE_ENOUGH_DATA = 4, +}; + +pub const NetworkState = enum(u16) { + NETWORK_EMPTY = 0, + NETWORK_IDLE = 1, + NETWORK_LOADING = 2, + NETWORK_NO_SOURCE = 3, +}; + +pub const Type = union(enum) { + generic, + audio: *Audio, + video: *Video, +}; + +_type: Type, +_proto: *HtmlElement, +_paused: bool = true, +_current_time: f64 = 0, +_volume: f64 = 1.0, +_muted: bool = false, +_playback_rate: f64 = 1.0, +_ready_state: ReadyState = .HAVE_NOTHING, +_network_state: NetworkState = .NETWORK_EMPTY, +_error: ?*MediaError = null, + +pub fn asElement(self: *Media) *Element { + return self._proto._proto; +} +pub fn asConstElement(self: *const Media) *const Element { + return self._proto._proto; +} +pub fn asNode(self: *Media) *Node { + return self.asElement().asNode(); +} + +pub fn is(self: *Media, comptime T: type) ?*T { + const type_name = @typeName(T); + switch (self._type) { + .audio => |a| { + if (T == *Audio) return a; + if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Audio")) { + return a; + } + }, + .video => |v| { + if (T == *Video) return v; + if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Video")) { + return v; + } + }, + .generic => {}, + } + return null; +} + +pub fn as(self: *Media, comptime T: type) *T { + return self.is(T).?; +} + +pub fn canPlayType(_: *const Media, mime_type: []const u8, page: *Page) []const u8 { + const pos = std.mem.indexOfScalar(u8, mime_type, ';') orelse mime_type.len; + const base_type = std.mem.trim(u8, mime_type[0..pos], &std.ascii.whitespace); + + if (base_type.len > page.buf.len) { + return ""; + } + const lower = std.ascii.lowerString(&page.buf, base_type); + + if (isProbablySupported(lower)) { + return "probably"; + } + if (isMaybeSupported(lower)) { + return "maybe"; + } + return ""; +} + +fn isProbablySupported(mime_type: []const u8) bool { + if (std.mem.eql(u8, mime_type, "video/mp4")) return true; + if (std.mem.eql(u8, mime_type, "video/webm")) return true; + if (std.mem.eql(u8, mime_type, "audio/mp4")) return true; + if (std.mem.eql(u8, mime_type, "audio/webm")) return true; + if (std.mem.eql(u8, mime_type, "audio/mpeg")) return true; + if (std.mem.eql(u8, mime_type, "audio/mp3")) return true; + if (std.mem.eql(u8, mime_type, "audio/ogg")) return true; + if (std.mem.eql(u8, mime_type, "video/ogg")) return true; + if (std.mem.eql(u8, mime_type, "audio/wav")) return true; + if (std.mem.eql(u8, mime_type, "audio/wave")) return true; + if (std.mem.eql(u8, mime_type, "audio/x-wav")) return true; + return false; +} + +fn isMaybeSupported(mime_type: []const u8) bool { + if (std.mem.eql(u8, mime_type, "audio/aac")) return true; + if (std.mem.eql(u8, mime_type, "audio/x-m4a")) return true; + if (std.mem.eql(u8, mime_type, "video/x-m4v")) return true; + if (std.mem.eql(u8, mime_type, "audio/flac")) return true; + return false; +} + +pub fn play(self: *Media) void { + self._paused = false; + self._ready_state = .HAVE_ENOUGH_DATA; + self._network_state = .NETWORK_IDLE; + // TODO: Could dispatch 'play' and 'playing' events +} + +pub fn pause(self: *Media) void { + self._paused = true; + // TODO: Could dispatch 'pause' event +} + +pub fn load(self: *Media) void { + self._paused = true; + self._current_time = 0; + self._ready_state = .HAVE_NOTHING; + self._network_state = .NETWORK_LOADING; + self._error = null; + // TODO: Could dispatch events +} + +pub fn getPaused(self: *const Media) bool { + return self._paused; +} + +pub fn getCurrentTime(self: *const Media) f64 { + return self._current_time; +} + +pub fn getDuration(_: *const Media) f64 { + return std.math.nan(f64); +} + +pub fn getReadyState(self: *const Media) u16 { + return @intFromEnum(self._ready_state); +} + +pub fn getNetworkState(self: *const Media) u16 { + return @intFromEnum(self._network_state); +} + +pub fn getEnded(_: *const Media) bool { + return false; +} + +pub fn getSeeking(_: *const Media) bool { + return false; +} + +pub fn getError(self: *const Media) ?*MediaError { + return self._error; +} + +pub fn getVolume(self: *const Media) f64 { + return self._volume; +} + +pub fn setVolume(self: *Media, value: f64) void { + self._volume = @max(0.0, @min(1.0, value)); +} + +pub fn getMuted(self: *const Media) bool { + return self._muted; +} + +pub fn setMuted(self: *Media, value: bool) void { + self._muted = value; +} + +pub fn getPlaybackRate(self: *const Media) f64 { + return self._playback_rate; +} + +pub fn setPlaybackRate(self: *Media, value: f64) void { + self._playback_rate = value; +} + +pub fn setCurrentTime(self: *Media, value: f64) void { + self._current_time = value; +} + +pub fn getSrc(self: *const Media, page: *Page) ![]const u8 { + const element = self.asConstElement(); + const src = element.getAttributeSafe("src") orelse return ""; + if (src.len == 0) { + return ""; + } + const URL = @import("../../URL.zig"); + return URL.resolve(page.call_arena, page.url, src, .{}); +} + +pub fn setSrc(self: *Media, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("src", value, page); +} + +pub fn getAutoplay(self: *const Media) bool { + return self.asConstElement().getAttributeSafe("autoplay") != null; +} + +pub fn setAutoplay(self: *Media, value: bool, page: *Page) !void { + if (value) { + try self.asElement().setAttributeSafe("autoplay", "", page); + } else { + try self.asElement().removeAttribute("autoplay", page); + } +} + +pub fn getControls(self: *const Media) bool { + return self.asConstElement().getAttributeSafe("controls") != null; +} + +pub fn setControls(self: *Media, value: bool, page: *Page) !void { + if (value) { + try self.asElement().setAttributeSafe("controls", "", page); + } else { + try self.asElement().removeAttribute("controls", page); + } +} + +pub fn getLoop(self: *const Media) bool { + return self.asConstElement().getAttributeSafe("loop") != null; +} + +pub fn setLoop(self: *Media, value: bool, page: *Page) !void { + if (value) { + try self.asElement().setAttributeSafe("loop", "", page); + } else { + try self.asElement().removeAttribute("loop", page); + } +} + +pub fn getPreload(self: *const Media) []const u8 { + return self.asConstElement().getAttributeSafe("preload") orelse "auto"; +} + +pub fn setPreload(self: *Media, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("preload", value, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Media); + + pub const Meta = struct { + pub const name = "HTMLMediaElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const NETWORK_EMPTY = bridge.property(@intFromEnum(NetworkState.NETWORK_EMPTY)); + pub const NETWORK_IDLE = bridge.property(@intFromEnum(NetworkState.NETWORK_IDLE)); + pub const NETWORK_LOADING = bridge.property(@intFromEnum(NetworkState.NETWORK_LOADING)); + pub const NETWORK_NO_SOURCE = bridge.property(@intFromEnum(NetworkState.NETWORK_NO_SOURCE)); + + pub const HAVE_NOTHING = bridge.property(@intFromEnum(ReadyState.HAVE_NOTHING)); + pub const HAVE_METADATA = bridge.property(@intFromEnum(ReadyState.HAVE_METADATA)); + pub const HAVE_CURRENT_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_CURRENT_DATA)); + pub const HAVE_FUTURE_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_FUTURE_DATA)); + pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA)); + + pub const src = bridge.accessor(Media.getSrc, Media.setSrc, .{}); + pub const autoplay = bridge.accessor(Media.getAutoplay, Media.setAutoplay, .{}); + pub const controls = bridge.accessor(Media.getControls, Media.setControls, .{}); + pub const loop = bridge.accessor(Media.getLoop, Media.setLoop, .{}); + pub const muted = bridge.accessor(Media.getMuted, Media.setMuted, .{}); + pub const preload = bridge.accessor(Media.getPreload, Media.setPreload, .{}); + pub const volume = bridge.accessor(Media.getVolume, Media.setVolume, .{}); + pub const playbackRate = bridge.accessor(Media.getPlaybackRate, Media.setPlaybackRate, .{}); + pub const currentTime = bridge.accessor(Media.getCurrentTime, Media.setCurrentTime, .{}); + pub const duration = bridge.accessor(Media.getDuration, null, .{}); + pub const paused = bridge.accessor(Media.getPaused, null, .{}); + pub const ended = bridge.accessor(Media.getEnded, null, .{}); + pub const seeking = bridge.accessor(Media.getSeeking, null, .{}); + pub const readyState = bridge.accessor(Media.getReadyState, null, .{}); + pub const networkState = bridge.accessor(Media.getNetworkState, null, .{}); + pub const @"error" = bridge.accessor(Media.getError, null, .{}); + + pub const canPlayType = bridge.function(Media.canPlayType, .{}); + pub const play = bridge.function(Media.play, .{}); + pub const pause = bridge.function(Media.pause, .{}); + pub const load = bridge.function(Media.load, .{}); +}; + +const testing = @import("../../../../testing.zig"); +test "WebApi: Media" { + try testing.htmlRunner("element/html/media.html", .{}); +} diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig new file mode 100644 index 000000000..66eb3f774 --- /dev/null +++ b/src/browser/webapi/element/html/Video.zig @@ -0,0 +1,81 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const Media = @import("Media.zig"); + +pub const Video = @This(); + +_proto: *Media, + +pub fn asMedia(self: *Video) *Media { + return self._proto; +} + +pub fn asElement(self: *Video) *Element { + return self._proto.asElement(); +} + +pub fn asConstElement(self: *const Video) *const Element { + return self._proto.asConstElement(); +} + +pub fn asNode(self: *Video) *Node { + return self.asElement().asNode(); +} + +pub fn getVideoWidth(_: *const Video) u32 { + return 0; +} + +pub fn getVideoHeight(_: *const Video) u32 { + return 0; +} + +pub fn getPoster(self: *const Video, page: *Page) ![]const u8 { + const element = self.asConstElement(); + const poster = element.getAttributeSafe("poster") orelse return ""; + if (poster.len == 0) { + return ""; + } + + const URL = @import("../../URL.zig"); + return URL.resolve(page.call_arena, page.url, poster, .{}); +} + +pub fn setPoster(self: *Video, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("poster", value, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Video); + + pub const Meta = struct { + pub const name = "HTMLVideoElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const poster = bridge.accessor(Video.getPoster, Video.setPoster, .{}); + pub const videoWidth = bridge.accessor(Video.getVideoWidth, null, .{}); + pub const videoHeight = bridge.accessor(Video.getVideoHeight, null, .{}); +}; From a6d3a3d0ab71c94b0f77f0e761095cea78c12625 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 12 Dec 2025 18:01:12 +0800 Subject: [PATCH 215/257] Add properties to HTMLStyleelement --- src/browser/tests/element/html/style.html | 69 +++++++++++++++++++ .../tests/legacy/cssom/css_stylesheet.html | 2 +- src/browser/tests/legacy/html/style.html | 4 +- src/browser/webapi/css/CSSStyleSheet.zig | 18 ++++- src/browser/webapi/element/html/Audio.zig | 2 +- src/browser/webapi/element/html/Style.zig | 60 ++++++++++++++++ src/browser/webapi/element/html/Video.zig | 2 +- 7 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/browser/tests/element/html/style.html diff --git a/src/browser/tests/element/html/style.html b/src/browser/tests/element/html/style.html new file mode 100644 index 000000000..6c406d8a1 --- /dev/null +++ b/src/browser/tests/element/html/style.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/cssom/css_stylesheet.html b/src/browser/tests/legacy/cssom/css_stylesheet.html index 223ee2cdb..ed2e876bf 100644 --- a/src/browser/tests/legacy/cssom/css_stylesheet.html +++ b/src/browser/tests/legacy/cssom/css_stylesheet.html @@ -8,7 +8,7 @@ let index1 = css.insertRule('body { color: red; }', 0); testing.expectEqual(0, index1); - testing.expectEqual(1, css.cssRules.length); + testing.expectEqual(0, css.cssRules.length); let replaced = false; css.replace('body{}').then(() => replaced = true); diff --git a/src/browser/tests/legacy/html/style.html b/src/browser/tests/legacy/html/style.html index 6463cd815..e92cd3cab 100644 --- a/src/browser/tests/legacy/html/style.html +++ b/src/browser/tests/legacy/html/style.html @@ -2,7 +2,5 @@ diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index a377618d5..2f8b76fb5 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -62,6 +62,19 @@ pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void { _ = page; } +pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise { + _ = self; + _ = text; + // TODO: clear self.css_rules + return page.js.resolvePromise({}); +} + +pub fn replaceSync(self: *CSSStyleSheet, text: []const u8) !void { + _ = self; + _ = text; + // TODO: clear self.css_rules +} + pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleSheet); @@ -71,14 +84,17 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const constructor = bridge.constructor(CSSStyleSheet.init, .{}); pub const ownerNode = bridge.accessor(CSSStyleSheet.getOwnerNode, null, .{ .null_as_undefined = true }); pub const href = bridge.accessor(CSSStyleSheet.getHref, null, .{ .null_as_undefined = true }); pub const title = bridge.accessor(CSSStyleSheet.getTitle, null, .{}); pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{}); pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{}); - pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{ .null_as_undefined = true }); + pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{}); pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{}); pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{}); + pub const replace = bridge.function(CSSStyleSheet.replace, .{}); + pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/element/html/Audio.zig b/src/browser/webapi/element/html/Audio.zig index 929d6acaf..11572f99c 100644 --- a/src/browser/webapi/element/html/Audio.zig +++ b/src/browser/webapi/element/html/Audio.zig @@ -22,7 +22,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Media = @import("Media.zig"); -pub const Audio = @This(); +const Audio = @This(); _proto: *Media, diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index d774e93e9..7f3852387 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -17,6 +17,8 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -27,10 +29,57 @@ _proto: *HtmlElement, pub fn asElement(self: *Style) *Element { return self._proto._proto; } +pub fn asConstElement(self: *const Style) *const Element { + return self._proto._proto; +} pub fn asNode(self: *Style) *Node { return self.asElement().asNode(); } +// Attribute-backed properties + +pub fn getBlocking(self: *const Style) []const u8 { + return self.asConstElement().getAttributeSafe("blocking") orelse ""; +} + +pub fn setBlocking(self: *Style, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("blocking", value, page); +} + +pub fn getMedia(self: *const Style) []const u8 { + return self.asConstElement().getAttributeSafe("media") orelse ""; +} + +pub fn setMedia(self: *Style, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("media", value, page); +} + +pub fn getType(self: *const Style) []const u8 { + return self.asConstElement().getAttributeSafe("type") orelse "text/css"; +} + +pub fn setType(self: *Style, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("type", value, page); +} + +pub fn getDisabled(self: *const Style) bool { + return self.asConstElement().getAttributeSafe("disabled") != null; +} + +pub fn setDisabled(self: *Style, disabled: bool, page: *Page) !void { + if (disabled) { + try self.asElement().setAttributeSafe("disabled", "", page); + } else { + try self.asElement().removeAttribute("disabled", page); + } +} + +const CSSStyleSheet = @import("../../css/CSSStyleSheet.zig"); +pub fn getSheet(_: *const Style) ?*CSSStyleSheet { + // TODO? + return null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Style); @@ -39,4 +88,15 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const blocking = bridge.accessor(Style.getBlocking, Style.setBlocking, .{}); + pub const media = bridge.accessor(Style.getMedia, Style.setMedia, .{}); + pub const @"type" = bridge.accessor(Style.getType, Style.setType, .{}); + pub const disabled = bridge.accessor(Style.getDisabled, Style.setDisabled, .{}); + pub const sheet = bridge.accessor(Style.getSheet, null, .{}); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: Style" { + try testing.htmlRunner("element/html/style.html", .{}); +} diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig index 66eb3f774..cfe19da0f 100644 --- a/src/browser/webapi/element/html/Video.zig +++ b/src/browser/webapi/element/html/Video.zig @@ -23,7 +23,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Media = @import("Media.zig"); -pub const Video = @This(); +const Video = @This(); _proto: *Media, From 23146f64abc952b306a8b7267763fa74b9590872 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 12 Dec 2025 18:21:30 +0800 Subject: [PATCH 216/257] Screen and ScreenOrientation (legacy) --- src/browser/EventManager.zig | 2 +- src/browser/Page.zig | 3 + src/browser/tests/legacy/html/screen.html | 2 +- src/browser/tests/legacy/html/template.html | 168 -------------------- src/browser/tests/window/screen.html | 21 +++ src/browser/webapi/EventTarget.zig | 2 + src/browser/webapi/Screen.zig | 77 +++++++-- src/browser/webapi/Window.zig | 4 +- 8 files changed, 97 insertions(+), 182 deletions(-) delete mode 100644 src/browser/tests/legacy/html/template.html create mode 100644 src/browser/tests/window/screen.html diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 1bda2ec8a..d458a3b2e 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -119,7 +119,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation => { + .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation, .screen, .screen_orientation => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c05fcb14a..fab0690b3 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -52,6 +52,7 @@ const Document = @import("webapi/Document.zig"); const DocumentFragment = @import("webapi/DocumentFragment.zig"); const ShadowRoot = @import("webapi/ShadowRoot.zig"); const Performance = @import("webapi/Performance.zig"); +const Screen = @import("webapi/Screen.zig"); const HtmlScript = @import("webapi/Element.zig").Html.Script; const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); @@ -209,12 +210,14 @@ fn reset(self: *Page, comptime initializing: bool) !void { if (comptime initializing == true) { const storage_bucket = try self._factory.create(storage.Bucket{}); + const screen = try Screen.init(self); self.window = try self._factory.eventTarget(Window{ ._document = self.document, ._storage_bucket = storage_bucket, ._performance = Performance.init(), ._proto = undefined, ._location = &default_location, + ._screen = screen, }); } else { self.window._document = self.document; diff --git a/src/browser/tests/legacy/html/screen.html b/src/browser/tests/legacy/html/screen.html index 82f4b71cc..5239ba434 100644 --- a/src/browser/tests/legacy/html/screen.html +++ b/src/browser/tests/legacy/html/screen.html @@ -17,5 +17,5 @@ diff --git a/src/browser/tests/legacy/html/template.html b/src/browser/tests/legacy/html/template.html deleted file mode 100644 index bc6055846..000000000 --- a/src/browser/tests/legacy/html/template.html +++ /dev/null @@ -1,168 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/browser/tests/window/screen.html b/src/browser/tests/window/screen.html new file mode 100644 index 000000000..5239ba434 --- /dev/null +++ b/src/browser/tests/window/screen.html @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 4f0eabfe5..b399f7cb7 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -38,6 +38,8 @@ pub const Type = union(enum) { message_port: *@import("MessagePort.zig"), text_track_cue: *@import("media/TextTrackCue.zig"), navigation: *@import("navigation/NavigationEventTarget.zig"), + screen: *@import("Screen.zig"), + screen_orientation: *@import("Screen.zig").Orientation, }; pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { diff --git a/src/browser/webapi/Screen.zig b/src/browser/webapi/Screen.zig index 1ed5b1396..a9e28e8c5 100644 --- a/src/browser/webapi/Screen.zig +++ b/src/browser/webapi/Screen.zig @@ -17,42 +17,65 @@ // along with this program. If not, see . const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const EventTarget = @import("EventTarget.zig"); + +pub fn registerTypes() []const type { + return &.{ + Screen, + Orientation, + }; +} const Screen = @This(); -_pad: bool = false, -pub const init: Screen = .{}; +_proto: *EventTarget, +_orientation: ?*Orientation = null, + +pub fn init(page: *Page) !*Screen { + return page._factory.eventTarget(Screen{ + ._proto = undefined, + ._orientation = null, + }); +} + +pub fn asEventTarget(self: *Screen) *EventTarget { + return self._proto; +} -/// Total width of the screen in pixels pub fn getWidth(_: *const Screen) u32 { return 1920; } -/// Total height of the screen in pixels pub fn getHeight(_: *const Screen) u32 { return 1080; } -/// Available width (excluding OS UI elements like taskbar) pub fn getAvailWidth(_: *const Screen) u32 { return 1920; } -/// Available height (excluding OS UI elements like taskbar) pub fn getAvailHeight(_: *const Screen) u32 { return 1040; // 40px reserved for taskbar/dock } -/// Color depth in bits per pixel pub fn getColorDepth(_: *const Screen) u32 { return 24; } -/// Pixel depth in bits per pixel (typically same as colorDepth) pub fn getPixelDepth(_: *const Screen) u32 { return 24; } +pub fn getOrientation(self: *Screen, page: *Page) !*Orientation { + if (self._orientation) |orientation| { + return orientation; + } + const orientation = try Orientation.init(page); + self._orientation = orientation; + return orientation; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Screen); @@ -60,14 +83,48 @@ pub const JsApi = struct { pub const name = "Screen"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const empty_with_no_proto = true; }; - // Read-only properties pub const width = bridge.accessor(Screen.getWidth, null, .{}); pub const height = bridge.accessor(Screen.getHeight, null, .{}); pub const availWidth = bridge.accessor(Screen.getAvailWidth, null, .{}); pub const availHeight = bridge.accessor(Screen.getAvailHeight, null, .{}); pub const colorDepth = bridge.accessor(Screen.getColorDepth, null, .{}); pub const pixelDepth = bridge.accessor(Screen.getPixelDepth, null, .{}); + pub const orientation = bridge.accessor(Screen.getOrientation, null, .{}); +}; + +pub const Orientation = struct { + _proto: *EventTarget, + + pub fn init(page: *Page) !*Orientation { + return page._factory.eventTarget(Orientation{ + ._proto = undefined, + }); + } + + pub fn asEventTarget(self: *Orientation) *EventTarget { + return self._proto; + } + + pub fn getAngle(_: *const Orientation) u32 { + return 0; + } + + pub fn getType(_: *const Orientation) []const u8 { + return "landscape-primary"; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(Orientation); + + pub const Meta = struct { + pub const name = "ScreenOrientation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const angle = bridge.accessor(Orientation.getAngle, null, .{}); + pub const @"type" = bridge.accessor(Orientation.getType, null, .{}); + }; }; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 792ca8ddf..51213ecda 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -50,7 +50,7 @@ _css: CSS = .init, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, -_screen: Screen = .init, +_screen: *Screen, _performance: Performance, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, @@ -88,7 +88,7 @@ pub fn getNavigator(self: *Window) *Navigator { } pub fn getScreen(self: *Window) *Screen { - return &self._screen; + return self._screen; } pub fn getCrypto(self: *Window) *Crypto { From eab328e2b5f8ce513853f934566111eba42ef731 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 12 Dec 2025 21:50:13 +0800 Subject: [PATCH 217/257] Tweak URL, refactor Anchor and URL to share more common code --- src/browser/URL.zig | 164 +++++++++++- src/browser/tests/legacy/url/url.html | 10 +- src/browser/tests/url.html | 294 +++++++++++++++++++++ src/browser/webapi/Location.zig | 4 +- src/browser/webapi/URL.zig | 110 +++++++- src/browser/webapi/element/html/Anchor.zig | 121 +-------- src/browser/webapi/net/URLSearchParams.zig | 6 +- 7 files changed, 575 insertions(+), 134 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 4a399a3b1..7a3547c24 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -128,7 +128,7 @@ fn isNullTerminated(comptime value: type) bool { } pub fn isCompleteHTTPUrl(url: []const u8) bool { - if (url.len < 6) { + if (url.len < 3) { // Minimum is "x://" return false; } @@ -137,9 +137,32 @@ pub fn isCompleteHTTPUrl(url: []const u8) bool { return false; } - return std.ascii.startsWithIgnoreCase(url, "https://") or - std.ascii.startsWithIgnoreCase(url, "http://") or - std.ascii.startsWithIgnoreCase(url, "ftp://"); + // Check if there's a scheme (protocol) ending with :// + const colon_pos = std.mem.indexOfScalar(u8, url, ':') orelse return false; + + // Check if it's followed by // + if (colon_pos + 2 >= url.len or url[colon_pos + 1] != '/' or url[colon_pos + 2] != '/') { + return false; + } + + // Validate that everything before the colon is a valid scheme + // A scheme must start with a letter and contain only letters, digits, +, -, . + if (colon_pos == 0) { + return false; + } + + const scheme = url[0..colon_pos]; + if (!std.ascii.isAlphabetic(scheme[0])) { + return false; + } + + for (scheme[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') { + return false; + } + } + + return true; } pub fn getUsername(raw: [:0]const u8) []const u8 { @@ -278,6 +301,139 @@ pub fn eqlDocument(first: [:0]const u8, second: [:0]const u8) bool { return std.mem.eql(u8, first[0..first_hash_index], second[0..second_hash_index]); } +// Helper function to build a URL from components +pub fn buildUrl( + allocator: Allocator, + protocol: []const u8, + host: []const u8, + pathname: []const u8, + search: []const u8, + hash: []const u8, +) ![:0]const u8 { + return std.fmt.allocPrintSentinel(allocator, "{s}//{s}{s}{s}{s}", .{ + protocol, + host, + pathname, + search, + hash, + }, 0); +} + +pub fn setProtocol(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + + // Add : suffix if not present + const protocol = if (value.len > 0 and value[value.len - 1] != ':') + try std.fmt.allocPrint(allocator, "{s}:", .{value}) + else + value; + + return buildUrl(allocator, protocol, host, pathname, search, hash); +} + +pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + + // Check if the host includes a port + const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':'); + const clean_host = if (colon_pos) |pos| blk: { + const port_str = value[pos + 1 ..]; + // Remove default ports + if (std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port_str, "443")) { + break :blk value[0..pos]; + } + if (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port_str, "80")) { + break :blk value[0..pos]; + } + break :blk value; + } else value; + + return buildUrl(allocator, protocol, clean_host, pathname, search, hash); +} + +pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const current_port = getPort(current); + const new_host = if (current_port.len > 0) + try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port }) + else + value; + + return setHost(current, new_host, allocator); +} + +pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 { + const hostname = getHostname(current); + const protocol = getProtocol(current); + + // Handle null or default ports + const new_host = if (value) |port_str| blk: { + if (port_str.len == 0) { + break :blk hostname; + } + // Check if this is a default port for the protocol + if (std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port_str, "443")) { + break :blk hostname; + } + if (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port_str, "80")) { + break :blk hostname; + } + break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str }); + } else hostname; + + return setHost(current, new_host, allocator); +} + +pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const search = getSearch(current); + const hash = getHash(current); + + // Add / prefix if not present and value is not empty + const pathname = if (value.len > 0 and value[0] != '/') + try std.fmt.allocPrint(allocator, "/{s}", .{value}) + else + value; + + return buildUrl(allocator, protocol, host, pathname, search, hash); +} + +pub fn setSearch(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const hash = getHash(current); + + // Add ? prefix if not present and value is not empty + const search = if (value.len > 0 and value[0] != '?') + try std.fmt.allocPrint(allocator, "?{s}", .{value}) + else + value; + + return buildUrl(allocator, protocol, host, pathname, search, hash); +} + +pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + + // Add # prefix if not present and value is not empty + const hash = if (value.len > 0 and value[0] != '#') + try std.fmt.allocPrint(allocator, "#{s}", .{value}) + else + value; + + return buildUrl(allocator, protocol, host, pathname, search, hash); +} + const KnownProtocol = enum { @"http:", @"https:", diff --git a/src/browser/tests/legacy/url/url.html b/src/browser/tests/legacy/url/url.html index ef770e461..72ca45f05 100644 --- a/src/browser/tests/legacy/url/url.html +++ b/src/browser/tests/legacy/url/url.html @@ -31,14 +31,14 @@ diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index 7faefc32b..80b708232 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -313,6 +313,23 @@ url.searchParams.delete('b'); testing.expectEqual('https://example.com/path', url.href); } + + { + let url = new URL("https://foo.bar"); + const searchParams = url.searchParams; + + // SearchParams should be empty. + testing.expectEqual(0, searchParams.size); + + url.href = "https://lightpanda.io?over=9000&light=panda"; + // It won't hurt to check href and host too. + testing.expectEqual("https://lightpanda.io/?over=9000&light=panda", url.href); + testing.expectEqual("lightpanda.io", url.host); + // SearchParams should be updated too when URL is set. + testing.expectEqual(2, searchParams.size); + testing.expectEqual("9000", searchParams.get("over")); + testing.expectEqual("panda", searchParams.get("light")); + } + + + + + + diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index c2c2b1a8f..87d0c2827 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -57,8 +57,8 @@ pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 { return self._url.getOrigin(page); } -pub fn getSearch(self: *const Location) []const u8 { - return self._url.getSearch(); +pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 { + return self._url.getSearch(page); } pub fn getHash(self: *const Location) []const u8 { diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 49c03b1dc..766bd3c20 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -35,6 +35,23 @@ _search_params: ?*URLSearchParams = null, pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; +pub fn canParse(url: []const u8, base_: ?[]const u8, page: *Page) bool { + _ = page; + const url_is_absolute = U.isCompleteHTTPUrl(url); + + if (base_) |b| { + // Base must be valid even if URL is absolute + if (!U.isCompleteHTTPUrl(b)) { + return false; + } + return true; + } else if (!url_is_absolute) { + return false; + } else { + return true; + } +} + pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); @@ -96,7 +113,17 @@ pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 { }; } -pub fn getSearch(self: *const URL) []const u8 { +pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 { + // If searchParams has been accessed, generate search from it + if (self._search_params) |sp| { + if (sp.getSize() == 0) { + return ""; + } + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try buf.writer.writeByte('?'); + try sp.toString(&buf.writer); + return buf.written(); + } return U.getSearch(self._raw); } @@ -110,7 +137,7 @@ pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { } // Get current search string (without the '?') - const search = self.getSearch(); + const search = try self.getSearch(page); const search_value = if (search.len > 0) search[1..] else ""; const params = try URLSearchParams.init(.{ .query_string = search_value }, page); @@ -118,6 +145,61 @@ pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { return params; } +pub fn setHref(self: *URL, value: []const u8, page: *Page) !void { + const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw; + const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true }); + self._raw = raw; + + // Update existing searchParams if it exists + if (self._search_params) |sp| { + const search = U.getSearch(raw); + const search_value = if (search.len > 0) search[1..] else ""; + try sp.updateFromString(search_value, page); + } +} + +pub fn setProtocol(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setProtocol(self._raw, value, allocator); +} + +pub fn setHost(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setHost(self._raw, value, allocator); +} + +pub fn setHostname(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setHostname(self._raw, value, allocator); +} + +pub fn setPort(self: *URL, value: ?[]const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setPort(self._raw, value, allocator); +} + +pub fn setPathname(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setPathname(self._raw, value, allocator); +} + +pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setSearch(self._raw, value, allocator); + + // Update existing searchParams if it exists + if (self._search_params) |sp| { + const search = U.getSearch(self._raw); + const search_value = if (search.len > 0) search[1..] else ""; + try sp.updateFromString(search_value, page); + } +} + +pub fn setHash(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setHash(self._raw, value, allocator); +} + pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { const sp = self._search_params orelse { return self._raw; @@ -137,6 +219,13 @@ pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try buf.writer.writeAll(base); + // Add / if missing (e.g., "https://example.com" -> "https://example.com/") + // Only add if pathname is just "/" and not already in the base + const pathname = U.getPathname(raw); + if (std.mem.eql(u8, pathname, "/") and !std.mem.endsWith(u8, base, "/")) { + try buf.writer.writeByte('/'); + } + // Only add ? if there are params if (sp.getSize() > 0) { try buf.writer.writeByte('?'); @@ -159,19 +248,20 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(URL.init, .{}); + pub const canParse = bridge.function(URL.canParse, .{ .static = true }); pub const toString = bridge.function(URL.toString, .{}); pub const toJSON = bridge.function(URL.toString, .{}); - pub const href = bridge.accessor(URL.toString, null, .{}); - pub const search = bridge.accessor(URL.getSearch, null, .{}); - pub const hash = bridge.accessor(URL.getHash, null, .{}); - pub const pathname = bridge.accessor(URL.getPathname, null, .{}); + pub const href = bridge.accessor(URL.toString, URL.setHref, .{}); + pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{}); + pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{}); + pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{}); pub const username = bridge.accessor(URL.getUsername, null, .{}); pub const password = bridge.accessor(URL.getPassword, null, .{}); - pub const hostname = bridge.accessor(URL.getHostname, null, .{}); - pub const host = bridge.accessor(URL.getHost, null, .{}); - pub const port = bridge.accessor(URL.getPort, null, .{}); + pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{}); + pub const host = bridge.accessor(URL.getHost, URL.setHost, .{}); + pub const port = bridge.accessor(URL.getPort, URL.setPort, .{}); pub const origin = bridge.accessor(URL.getOrigin, null, .{}); - pub const protocol = bridge.accessor(URL.getProtocol, null, .{}); + pub const protocol = bridge.accessor(URL.getProtocol, URL.setProtocol, .{}); pub const searchParams = bridge.accessor(URL.getSearchParams, null, .{}); }; diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 006843db2..75e61c205 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -84,26 +84,7 @@ pub fn getHost(self: *Anchor, page: *Page) ![]const u8 { pub fn setHost(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const protocol = URL.getProtocol(href); - const pathname = URL.getPathname(href); - const search = URL.getSearch(href); - const hash = URL.getHash(href); - - // Check if the host includes a port - const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':'); - const clean_host = if (colon_pos) |pos| blk: { - const port_str = value[pos + 1 ..]; - // Remove default ports - if (std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port_str, "443")) { - break :blk value[0..pos]; - } - if (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port_str, "80")) { - break :blk value[0..pos]; - } - break :blk value; - } else value; - - const new_href = try buildUrl(page.call_arena, protocol, clean_host, pathname, search, hash); + const new_href = try URL.setHost(href, value, page.call_arena); try setHref(self, new_href, page); } @@ -114,13 +95,8 @@ pub fn getHostname(self: *Anchor, page: *Page) ![]const u8 { pub fn setHostname(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const current_port = URL.getPort(href); - const new_host = if (current_port.len > 0) - try std.fmt.allocPrint(page.call_arena, "{s}:{s}", .{ value, current_port }) - else - value; - - try setHost(self, new_host, page); + const new_href = try URL.setHostname(href, value, page.call_arena); + try setHref(self, new_href, page); } pub fn getPort(self: *Anchor, page: *Page) ![]const u8 { @@ -142,25 +118,8 @@ pub fn getPort(self: *Anchor, page: *Page) ![]const u8 { pub fn setPort(self: *Anchor, value: ?[]const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const hostname = URL.getHostname(href); - const protocol = URL.getProtocol(href); - - // Handle null or default ports - const new_host = if (value) |port_str| blk: { - if (port_str.len == 0) { - break :blk hostname; - } - // Check if this is a default port for the protocol - if (std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port_str, "443")) { - break :blk hostname; - } - if (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port_str, "80")) { - break :blk hostname; - } - break :blk try std.fmt.allocPrint(page.call_arena, "{s}:{s}", .{ hostname, port_str }); - } else hostname; - - try setHost(self, new_host, page); + const new_href = try URL.setPort(href, value, page.call_arena); + try setHref(self, new_href, page); } pub fn getSearch(self: *Anchor, page: *Page) ![]const u8 { @@ -170,18 +129,7 @@ pub fn getSearch(self: *Anchor, page: *Page) ![]const u8 { pub fn setSearch(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const protocol = URL.getProtocol(href); - const host = URL.getHost(href); - const pathname = URL.getPathname(href); - const hash = URL.getHash(href); - - // Add ? prefix if not present and value is not empty - const search = if (value.len > 0 and value[0] != '?') - try std.fmt.allocPrint(page.call_arena, "?{s}", .{value}) - else - value; - - const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + const new_href = try URL.setSearch(href, value, page.call_arena); try setHref(self, new_href, page); } @@ -192,18 +140,7 @@ pub fn getHash(self: *Anchor, page: *Page) ![]const u8 { pub fn setHash(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const protocol = URL.getProtocol(href); - const host = URL.getHost(href); - const pathname = URL.getPathname(href); - const search = URL.getSearch(href); - - // Add # prefix if not present and value is not empty - const hash = if (value.len > 0 and value[0] != '#') - try std.fmt.allocPrint(page.call_arena, "#{s}", .{value}) - else - value; - - const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + const new_href = try URL.setHash(href, value, page.call_arena); try setHref(self, new_href, page); } @@ -214,18 +151,7 @@ pub fn getPathname(self: *Anchor, page: *Page) ![]const u8 { pub fn setPathname(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const protocol = URL.getProtocol(href); - const host = URL.getHost(href); - const search = URL.getSearch(href); - const hash = URL.getHash(href); - - // Add / prefix if not present and value is not empty - const pathname = if (value.len > 0 and value[0] != '/') - try std.fmt.allocPrint(page.call_arena, "/{s}", .{value}) - else - value; - - const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + const new_href = try URL.setPathname(href, value, page.call_arena); try setHref(self, new_href, page); } @@ -236,18 +162,7 @@ pub fn getProtocol(self: *Anchor, page: *Page) ![]const u8 { pub fn setProtocol(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; - const host = URL.getHost(href); - const pathname = URL.getPathname(href); - const search = URL.getSearch(href); - const hash = URL.getHash(href); - - // Add : suffix if not present - const protocol = if (value.len > 0 and value[value.len - 1] != ':') - try std.fmt.allocPrint(page.call_arena, "{s}:", .{value}) - else - value; - - const new_href = try buildUrl(page.call_arena, protocol, host, pathname, search, hash); + const new_href = try URL.setProtocol(href, value, page.call_arena); try setHref(self, new_href, page); } @@ -283,24 +198,6 @@ fn getResolvedHref(self: *Anchor, page: *Page) !?[:0]const u8 { return try URL.resolve(page.call_arena, page.url, href, .{}); } -// Helper function to build a new URL from components -fn buildUrl( - allocator: std.mem.Allocator, - protocol: []const u8, - host: []const u8, - pathname: []const u8, - search: []const u8, - hash: []const u8, -) ![:0]const u8 { - return std.fmt.allocPrintSentinel(allocator, "{s}//{s}{s}{s}{s}", .{ - protocol, - host, - pathname, - search, - hash, - }, 0); -} - pub const JsApi = struct { pub const bridge = js.Bridge(Anchor); diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index f3069531d..2cfe3b2c3 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -61,6 +61,10 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { }); } +pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, page: *Page) !void { + self._params = try paramsFromString(self._arena, query_string, &page.buf); +} + pub fn getSize(self: *const URLSearchParams) usize { return self._params.len(); } @@ -277,7 +281,7 @@ fn escape(input: []const u8, writer: *std.Io.Writer) !void { fn isUnreserved(c: u8) bool { return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => true, else => false, }; } From 52dcc6765af407da356333fdcab86ff56e987fb9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 13 Dec 2025 12:47:54 +0800 Subject: [PATCH 218/257] URLSearchParams from FormData --- .../tests/legacy/url/url_search_params.html | 1 - src/browser/tests/net/url_search_params.html | 14 +++++++++++ src/browser/webapi/URL.zig | 24 ++++++------------- src/browser/webapi/net/URLSearchParams.zig | 5 +++- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/browser/tests/legacy/url/url_search_params.html b/src/browser/tests/legacy/url/url_search_params.html index 344f33cc1..738253d25 100644 --- a/src/browser/tests/legacy/url/url_search_params.html +++ b/src/browser/tests/legacy/url/url_search_params.html @@ -81,7 +81,6 @@ testing.expectEqual(3, ups.size); testing.expectEqual(['1', '2'], ups.getAll('a')); - testing.expectEqual(['3'], ups.getAll('b')); fd.delete('a'); // the two aren't linked, it created a copy diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html index 54b66b3d3..74d7c03bb 100644 --- a/src/browser/tests/net/url_search_params.html +++ b/src/browser/tests/net/url_search_params.html @@ -353,3 +353,17 @@ testing.expectEqual(['a', 'a', 'b', 'b', 'c'], Array.from(usp.keys())); } + + diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 766bd3c20..e886c8ebd 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -35,23 +35,6 @@ _search_params: ?*URLSearchParams = null, pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; -pub fn canParse(url: []const u8, base_: ?[]const u8, page: *Page) bool { - _ = page; - const url_is_absolute = U.isCompleteHTTPUrl(url); - - if (base_) |b| { - // Base must be valid even if URL is absolute - if (!U.isCompleteHTTPUrl(b)) { - return false; - } - return true; - } else if (!url_is_absolute) { - return false; - } else { - return true; - } -} - pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); @@ -238,6 +221,13 @@ pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { return buf.written()[0 .. buf.written().len - 1 :0]; } +pub fn canParse(url: []const u8, base_: ?[]const u8) bool { + if (base_) |b| { + return U.isCompleteHTTPUrl(b); + } + return U.isCompleteHTTPUrl(url); +} + pub const JsApi = struct { pub const bridge = js.Bridge(URL); diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 2cfe3b2c3..482c4c7f4 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -24,8 +24,9 @@ const String = @import("../../../string.zig").String; const Allocator = std.mem.Allocator; const Page = @import("../../Page.zig"); -const GenericIterator = @import("../collections/iterator.zig").Entry; +const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); +const GenericIterator = @import("../collections/iterator.zig").Entry; const URLSearchParams = @This(); @@ -33,6 +34,7 @@ _arena: Allocator, _params: KeyValueList, const InitOpts = union(enum) { + form_data: *FormData, value: js.Value, query_string: []const u8, }; @@ -43,6 +45,7 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { const opts = opts_ orelse break :blk .empty; switch (opts) { .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), + .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .value => |js_val| { if (js_val.isObject()) { break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); From c9b4067686771b1157e26afa52e2a75f9ac6ad83 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 13 Dec 2025 17:19:53 +0800 Subject: [PATCH 219/257] Event listener can now be an object with a handleEvent function --- src/browser/EventManager.zig | 64 +++++++++++++--- src/browser/js/Object.zig | 4 + src/browser/tests/events.html | 115 +++++++++++++++++++++++++++++ src/browser/webapi/EventTarget.zig | 70 ++++++++++++++---- 4 files changed, 228 insertions(+), 25 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index d458a3b2e..f4bbdd441 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -57,7 +57,13 @@ pub const RegisterOptions = struct { passive: bool = false, signal: ?*@import("webapi/AbortSignal.zig") = null, }; -pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, opts: RegisterOptions) !void { + +pub const Callback = union(enum) { + function: js.Function, + object: js.Object, +}; + +pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void { if (comptime IS_DEBUG) { log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once }); } @@ -71,11 +77,15 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target)); if (gop.found_existing) { - // check for duplicate functions already registered + // check for duplicate callbacks already registered var node = gop.value_ptr.first; while (node) |n| { const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); - if (listener.function.eql(function) and listener.capture == opts.capture) { + const is_duplicate = switch (callback) { + .object => |obj| listener.function.eqlObject(obj), + .function => |func| listener.function.eqlFunction(func), + }; + if (is_duplicate and listener.capture == opts.capture) { return; } node = n.next; @@ -84,13 +94,18 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func gop.value_ptr.* = .{}; } + const func = switch (callback) { + .function => |f| Function{ .value = f }, + .object => |o| Function{ .object = o }, + }; + const listener = try self.listener_pool.create(); listener.* = .{ .node = .{}, .once = opts.once, .capture = opts.capture, .passive = opts.passive, - .function = .{ .value = function }, + .function = func, .signal = opts.signal, .typ = try String.init(self.arena, typ, .{}), }; @@ -98,9 +113,9 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func gop.value_ptr.append(&listener.node); } -pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, use_capture: bool) void { +pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; - if (findListener(list, typ, function, use_capture)) |listener| { + if (findListener(list, typ, callback, use_capture)) |listener| { self.removeListener(list, listener); } } @@ -119,7 +134,17 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation, .screen, .screen_orientation => { + .xhr, + .window, + .abort_signal, + .media_query_list, + .message_port, + .text_track_cue, + .navigation, + .screen, + .screen_orientation, + .generic, + => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, @@ -304,6 +329,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe const str = try page.call_arena.dupeZ(u8, string.str()); try self.page.js.eval(str, null); }, + .object => |obj| { + if (try obj.getFunction("handleEvent")) |handleEvent| { + try handleEvent.callWithThis(void, obj, .{event}); + } + }, } // Restore original target (only if we changed it) @@ -350,12 +380,16 @@ fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void } } -fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, function: js.Function, capture: bool) ?*Listener { +fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Callback, capture: bool) ?*Listener { var node = list.first; while (node) |n| { node = n.next; const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); - if (!listener.function.eql(function)) { + const matches = switch (callback) { + .object => |obj| listener.function.eqlObject(obj), + .function => |func| listener.function.eqlFunction(func), + }; + if (!matches) { continue; } if (listener.capture != capture) { @@ -383,11 +417,19 @@ const Listener = struct { const Function = union(enum) { value: js.Function, string: String, + object: js.Object, - fn eql(self: Function, func: js.Function) bool { + fn eqlFunction(self: Function, func: js.Function) bool { return switch (self) { - .string => false, .value => |v| return v.id == func.id, + else => false, + }; + } + + fn eqlObject(self: Function, obj: js.Object) bool { + return switch (self) { + .object => |o| return o.getId() == obj.getId(), + else => false, }; } }; diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 2e77a54af..0e25b963e 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -32,6 +32,10 @@ const Object = @This(); js_obj: v8.Object, context: *js.Context, +pub fn getId(self: Object) u32 { + return self.js_obj.getIdentityHash(); +} + pub const SetOpts = packed struct(u32) { READ_ONLY: bool = false, DONT_ENUM: bool = false, diff --git a/src/browser/tests/events.html b/src/browser/tests/events.html index a3682ae1b..843002175 100644 --- a/src/browser/tests/events.html +++ b/src/browser/tests/events.html @@ -497,3 +497,118 @@ testing.expectEqual('inner1', nested_calls[4]); testing.expectEqual(5, nested_calls.length); + +

+ diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index b399f7cb7..75996fe31 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -20,7 +20,8 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); -const RegisterOptions = @import("../EventManager.zig").RegisterOptions; +const EventManager = @import("../EventManager.zig"); +const RegisterOptions = EventManager.RegisterOptions; const Event = @import("Event.zig"); @@ -30,6 +31,7 @@ const _prototype_root = true; _type: Type, pub const Type = union(enum) { + generic: void, node: *@import("Node.zig"), window: *@import("Window.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"), @@ -42,6 +44,12 @@ pub const Type = union(enum) { screen_orientation: *@import("Screen.zig").Orientation, }; +pub fn init(page: *Page) !*EventTarget { + return page._factory.create(EventTarget{ + ._type = .generic, + }); +} + pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { try page._event_manager.dispatch(self, event); return !event._cancelable or !event._prevent_default; @@ -59,9 +67,15 @@ pub const EventListenerCallback = union(enum) { pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void { const callback = callback_ orelse return; - const actual_callback = switch (callback) { - .function => |func| func, - .object => |obj| (try obj.getFunction("handleEvent")) orelse return, + if (callback == .object) { + if (try callback.object.getFunction("handleEvent") == null) { + return; + } + } + + const em_callback = switch (callback) { + .function => |func| EventManager.Callback{ .function = func }, + .object => |obj| EventManager.Callback{ .object = try obj.persist() }, }; const options = blk: { @@ -71,7 +85,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi .capture => |capture| RegisterOptions{ .capture = capture }, }; }; - return page._event_manager.register(self, typ, actual_callback, options); + return page._event_manager.register(self, typ, em_callback, options); } const RemoveEventListenerOptions = union(enum) { @@ -79,36 +93,63 @@ const RemoveEventListenerOptions = union(enum) { options: Options, const Options = struct { - useCapture: bool = false, + capture: bool = false, }; }; pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void { const callback = callback_ orelse return; - const actual_callback = switch (callback) { - .function => |func| func, - .object => |obj| (try obj.getFunction("handleEvent")) orelse return, + // For object callbacks, check if handleEvent exists + if (callback == .object) { + if (try callback.object.getFunction("handleEvent") == null) { + return; + } + } + + const em_callback = switch (callback) { + .function => |func| EventManager.Callback{ .function = func }, + .object => |obj| EventManager.Callback{ .object = try obj.persist() }, }; const use_capture = blk: { const o = opts_ orelse break :blk false; break :blk switch (o) { .capture => |capture| capture, - .options => |opts| opts.useCapture, + .options => |opts| opts.capture, }; }; - return page._event_manager.remove(self, typ, actual_callback, use_capture); + return page._event_manager.remove(self, typ, em_callback, use_capture); } pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { return switch (self._type) { .node => |n| n.format(writer), - .window => writer.writeAll(""), + .generic => writer.writeAll(""), + .window => writer.writeAll(""), .xhr => writer.writeAll(""), - .abort_signal => writer.writeAll(""), + .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), .message_port => writer.writeAll(""), .text_track_cue => writer.writeAll(""), + .navigation => writer.writeAll(""), + .screen => writer.writeAll(""), + .screen_orientation => writer.writeAll(""), + }; +} + +pub fn toString(self: *EventTarget) []const u8 { + return switch (self._type) { + .node => |n| return n.className(), + .generic => return "[object EventTarget]", + .window => return "[object Window]", + .xhr => return "[object XMLHttpRequestEventTarget]", + .abort_signal => return "[object AbortSignal]", + .media_query_list => return "[object MediaQueryList]", + .message_port => return "[object MessagePort]", + .text_track_cue => return "[object TextTrackCue]", + .navigation => return "[object Navigation]", + .screen => return "[object Screen]", + .screen_orientation => return "[object ScreenOrientation]", }; } @@ -122,15 +163,16 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const constructor = bridge.constructor(EventTarget.init, .{}); pub const dispatchEvent = bridge.function(EventTarget.dispatchEvent, .{}); pub const addEventListener = bridge.function(EventTarget.addEventListener, .{}); pub const removeEventListener = bridge.function(EventTarget.removeEventListener, .{}); + pub const toString = bridge.function(EventTarget.toString, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: EventTarget" { // we create thousands of these per page. Nothing should bloat it. try testing.expectEqual(16, @sizeOf(EventTarget)); - try testing.htmlRunner("events.html", .{}); } From 0d3055716e3cf263625bfdbb8a0c83a2b7b3036a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 13 Dec 2025 20:33:43 +0800 Subject: [PATCH 220/257] tweak timing of intersection observer and how it handles disconnected nodes --- src/browser/Page.zig | 27 +++++++++++++---- src/browser/js/Context.zig | 9 ++++++ .../tests/intersection_observer/basic.html | 30 +++++++++++++++++++ .../legacy/dom/intersection_observer.html | 5 ++-- src/browser/webapi/IntersectionObserver.zig | 22 ++++++++------ 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index fab0690b3..f6eb4b06c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -106,6 +106,7 @@ _mutation_delivery_depth: u32 = 0, // List of active IntersectionObservers _intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, +_intersection_check_scheduled: bool = false, _intersection_delivery_scheduled: bool = false, // Lookup for customized built-in elements. Maps element pointer to definition. @@ -250,6 +251,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._mutation_delivery_scheduled = false; self._mutation_delivery_depth = 0; self._intersection_observers = .{}; + self._intersection_check_scheduled = false; self._intersection_delivery_scheduled = false; self._customized_builtin_definitions = .{}; self._customized_builtin_connected_callback_invoked = .{}; @@ -781,6 +783,15 @@ pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void { pub fn domChanged(self: *Page) void { self.version += 1; + + if (self._intersection_check_scheduled) { + return; + } + + self._intersection_check_scheduled = true; + self.js.queueIntersectionChecks() catch |err| { + log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + }; } pub fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) { @@ -849,27 +860,31 @@ pub fn checkIntersections(self: *Page) !void { } pub fn scheduleMutationDelivery(self: *Page) !void { - // Only queue if not already scheduled if (self._mutation_delivery_scheduled) { return; } self._mutation_delivery_scheduled = true; - - // Queue mutation delivery as a microtask try self.js.queueMutationDelivery(); } pub fn scheduleIntersectionDelivery(self: *Page) !void { - // Only queue if not already scheduled if (self._intersection_delivery_scheduled) { return; } self._intersection_delivery_scheduled = true; - - // Queue intersection delivery as a microtask try self.js.queueIntersectionDelivery(); } +pub fn performScheduledIntersectionChecks(self: *Page) void { + if (!self._intersection_check_scheduled) { + return; + } + self._intersection_check_scheduled = false; + self.checkIntersections() catch |err| { + log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + }; +} + pub fn deliverIntersections(self: *Page) void { if (!self._intersection_delivery_scheduled) { return; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 904ab17f8..63a6d7515 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1973,6 +1973,15 @@ pub fn queueMutationDelivery(self: *Context) !void { }.run, self.page); } +pub fn queueIntersectionChecks(self: *Context) !void { + self.isolate.enqueueMicrotask(struct { + fn run(data: ?*anyopaque) callconv(.c) void { + const page: *Page = @ptrCast(@alignCast(data.?)); + page.performScheduledIntersectionChecks(); + } + }.run, self.page); +} + pub fn queueIntersectionDelivery(self: *Context) !void { self.isolate.enqueueMicrotask(struct { fn run(data: ?*anyopaque) callconv(.c) void { diff --git a/src/browser/tests/intersection_observer/basic.html b/src/browser/tests/intersection_observer/basic.html index 4131d1dde..dde36231f 100644 --- a/src/browser/tests/intersection_observer/basic.html +++ b/src/browser/tests/intersection_observer/basic.html @@ -29,3 +29,33 @@ observer.disconnect(); }); + + diff --git a/src/browser/tests/legacy/dom/intersection_observer.html b/src/browser/tests/legacy/dom/intersection_observer.html index 4067edba2..9f4b88ecc 100644 --- a/src/browser/tests/legacy/dom/intersection_observer.html +++ b/src/browser/tests/legacy/dom/intersection_observer.html @@ -22,7 +22,6 @@ const div1 = document.createElement('div'); const div2 = document.createElement('div'); new IntersectionObserver((entries) => { - console.log(entries[0]); count += 1; }).observe(div1); @@ -33,7 +32,7 @@ } - --> diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 4666e5266..7cf65d0cf 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -161,11 +161,13 @@ fn calculateIntersection( // For a headless browser without real layout, we treat all elements as fully visible. // This avoids fingerprinting issues (massive viewports) and matches the behavior // scripts expect when querying element visibility. - const is_intersecting = true; - const intersection_ratio: f64 = 1.0; + // However, elements without a parent cannot intersect (they have no containing block). + const has_parent = target.asNode().parentNode() != null; + const is_intersecting = has_parent; + const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0; - // Intersection rect is the same as the target rect (fully visible) - const intersection_rect = target_rect; + // Intersection rect is the same as the target rect if visible, otherwise zero rect + const intersection_rect = if (has_parent) target_rect else &zero_rect; return .{ .is_intersecting = is_intersecting, @@ -199,11 +201,10 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio); // Create entry if: - // 1. First time observing this target (was_intersecting_opt == null) + // 1. First time observing this target AND it's intersecting // 2. State changed - // 3. Currently intersecting - const should_report = was_intersecting_opt == null or - was_intersecting_opt.? != is_now_intersecting; + const should_report = (was_intersecting_opt == null and is_now_intersecting) or + (was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting); if (should_report) { const entry = try page.arena.create(IntersectionObserverEntry); @@ -218,8 +219,11 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) }; try self._pending_entries.append(page.arena, entry); - try self._previous_states.put(page.arena, target, is_now_intersecting); } + + // Always update the previous state, even if we didn't report + // This ensures we can detect state changes on subsequent checks + try self._previous_states.put(page.arena, target, is_now_intersecting); } pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void { From 82cd5d4bab9b692d57a7d4940e67ce73b3b77731 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 13 Dec 2025 21:23:16 +0800 Subject: [PATCH 221/257] fix legacy intersection observer test --- .../tests/legacy/dom/intersection_observer.html | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/browser/tests/legacy/dom/intersection_observer.html b/src/browser/tests/legacy/dom/intersection_observer.html index 9f4b88ecc..c39cc0d66 100644 --- a/src/browser/tests/legacy/dom/intersection_observer.html +++ b/src/browser/tests/legacy/dom/intersection_observer.html @@ -22,6 +22,7 @@ const div1 = document.createElement('div'); const div2 = document.createElement('div'); new IntersectionObserver((entries) => { + console.log(entries[0]); count += 1; }).observe(div1); @@ -32,7 +33,7 @@ } - + From f93403d3dc13d5f6b0e6e76195eb9305be8ca777 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 14 Dec 2025 16:16:54 +0800 Subject: [PATCH 222/257] Remove thread local Rework node.isConnected(), this now [correctly] returns true as long as a node is part of _a_ document (it doesn't have to be the 'main' document). This requires changes around id lookup optimization. --- src/browser/Page.zig | 64 ++++++---- src/browser/tests/domparser.html | 119 +++++++++++++++++++ src/browser/tests/element/html/template.html | 9 +- src/browser/tests/node/is_connected.html | 108 +++++++++++++++++ src/browser/tests/node/node_iterator.html | 1 - src/browser/tests/node/normalize.html | 1 + src/browser/tests/node/owner.html | 36 ++++++ src/browser/webapi/Node.zig | 35 ++++-- 8 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 src/browser/tests/node/is_connected.html create mode 100644 src/browser/tests/node/owner.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index f6eb4b06c..0fd81e81a 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -64,7 +64,6 @@ const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; -pub threadlocal var current: *Page = undefined; var default_url = URL{ ._raw = "about:blank" }; pub var default_location: Location = Location{ ._url = &default_url }; @@ -171,7 +170,6 @@ pub fn init(arena: Allocator, call_arena: Allocator, session: *Session) !*Page { page.scheduler = Scheduler.init(page.arena); try page.reset(true); - current = page; return page; } @@ -794,19 +792,25 @@ pub fn domChanged(self: *Page) void { }; } -pub fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) { - if (node.is(ShadowRoot)) |shadow_root| { - return &shadow_root._elements_by_id; - } - - var parent = node._parent; - while (parent) |n| { - if (n.is(ShadowRoot)) |shadow_root| { +fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) { + // Walk up the tree checking for ShadowRoot and tracking the root + var current = node; + while (true) { + if (current.is(ShadowRoot)) |shadow_root| { return &shadow_root._elements_by_id; } - parent = n._parent; + + const parent = current._parent orelse { + if (current._type == .document) { + return ¤t._type.document._elements_by_id; + } + // Detached nodes should not have IDs registered + std.debug.assert(false); + return &page.document._elements_by_id; + }; + + current = parent; } - return &page.document._elements_by_id; } pub fn addElementId(self: *Page, parent: *Node, element: *Element, id: []const u8) !void { @@ -823,8 +827,18 @@ pub fn removeElementId(self: *Page, element: *Element, id: []const u8) void { } pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Element { - const id_map = self.getElementIdMap(node); - return id_map.get(id); + if (node.isConnected() or node.isInShadowTree()) { + const id_map = self.getElementIdMap(node); + return id_map.get(id); + } + var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{}); + while (tw.next()) |el| { + const element_id = el.getAttributeSafe("id") orelse continue; + if (std.mem.eql(u8, element_id, id)) { + return el; + } + } + return null; } pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { @@ -1509,6 +1523,8 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts } // grab this before we null the parent const was_connected = child.isConnected(); + // Capture the ID map before disconnecting, so we can remove IDs from the correct document + const id_map = if (was_connected) self.getElementIdMap(child) else null; child._parent = null; child._child_link = .{}; @@ -1537,7 +1553,7 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { if (el.getAttributeSafe("id")) |id| { - self.removeElementId(el, id); + _ = id_map.?.remove(id); } Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); @@ -1588,7 +1604,10 @@ const InsertNodeRelative = union(enum) { after: *Node, before: *Node, }; -const InsertNodeOpts = struct { child_already_connected: bool = false }; +const InsertNodeOpts = struct { + child_already_connected: bool = false, + adopting_to_new_document: bool = false, +}; pub fn insertNodeRelative(self: *Page, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void { return self._insertNodeRelative(false, parent, child, relative, opts); } @@ -1666,22 +1685,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod if (comptime from_parser) { if (child.is(Element)) |el| { - if (el.getAttributeSafe("id")) |id| { - try self.addElementId(parent, el, id); - } - // Invoke connectedCallback for custom elements during parsing // For main document parsing, we know nodes are connected (fast path) // For fragment parsing (innerHTML), we need to check connectivity - if (self._parse_mode == .document or child.isConnected()) { + if (child.isConnected() or child.isInShadowTree()) { + if (el.getAttributeSafe("id")) |id| { + try self.addElementId(parent, el, id); + } try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self); } } return; } - if (opts.child_already_connected) { - // The child is already connected, we don't have to reconnect it + if (opts.child_already_connected and !opts.adopting_to_new_document) { + // The child is already connected in the same document, we don't have to reconnect it return; } diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html index 660143889..7f65ebd99 100644 --- a/src/browser/tests/domparser.html +++ b/src/browser/tests/domparser.html @@ -1,5 +1,6 @@ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/template.html b/src/browser/tests/element/html/template.html index 52db20fdd..2a92534f7 100644 --- a/src/browser/tests/element/html/template.html +++ b/src/browser/tests/element/html/template.html @@ -17,6 +17,13 @@

Hello Template

+ + + --> diff --git a/src/browser/tests/node/is_connected.html b/src/browser/tests/node/is_connected.html new file mode 100644 index 000000000..35c536003 --- /dev/null +++ b/src/browser/tests/node/is_connected.html @@ -0,0 +1,108 @@ + + + +
+

Connected paragraph

+
+ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/node/node_iterator.html b/src/browser/tests/node/node_iterator.html index 992df5ad9..36472cf46 100644 --- a/src/browser/tests/node/node_iterator.html +++ b/src/browser/tests/node/node_iterator.html @@ -470,4 +470,3 @@ testing.expectEqual(null, iterator.nextNode()); } - diff --git a/src/browser/tests/node/normalize.html b/src/browser/tests/node/normalize.html index ead599a6b..b85f8324a 100644 --- a/src/browser/tests/node/normalize.html +++ b/src/browser/tests/node/normalize.html @@ -1,3 +1,4 @@ +
diff --git a/src/browser/tests/node/owner.html b/src/browser/tests/node/owner.html new file mode 100644 index 000000000..1dd2dff24 --- /dev/null +++ b/src/browser/tests/node/owner.html @@ -0,0 +1,36 @@ + + + +
+

+ I am the original reference node. +

+
+ + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 564bf5990..532a335cf 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -202,6 +202,10 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node { // then we can remove + add a bit more efficiently (we don't have to fully // disconnect then reconnect) const child_connected = child.isConnected(); + // Check if we're adopting the node to a different document + const child_root = child.getRootNode(null); + const parent_root = self.getRootNode(null); + const adopting_to_new_document = child_connected and child_root != parent_root; if (child._parent) |parent| { // we can signal removeNode that the child will remain connected @@ -209,7 +213,10 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node { page.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() }); } - try page.appendNode(self, child, .{ .child_already_connected = child_connected }); + try page.appendNode(self, child, .{ + .child_already_connected = child_connected, + .adopting_to_new_document = adopting_to_new_document, + }); return child; } @@ -319,19 +326,14 @@ pub fn isInShadowTree(self: *Node) bool { } pub fn isConnected(self: *const Node) bool { - const target = Page.current.document.asNode(); - if (self == target) { - return true; + // Walk up to find the root node + var root = self; + while (root._parent) |parent| { + root = parent; } - var node = self._parent; - while (node) |n| { - if (n == target) { - return true; - } - node = n._parent; - } - return false; + // A node is connected if its root is a document + return root._type == .document; } const GetRootNodeOpts = struct { @@ -432,6 +434,10 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page } const child_already_connected = new_node.isConnected(); + // Check if we're adopting the node to a different document + const child_root = new_node.getRootNode(null); + const parent_root = self.getRootNode(null); + const adopting_to_new_document = child_already_connected and child_root != parent_root; page.domChanged(); const will_be_reconnected = self.isConnected(); @@ -443,7 +449,10 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page self, new_node, .{ .before = ref_node }, - .{ .child_already_connected = child_already_connected }, + .{ + .child_already_connected = child_already_connected, + .adopting_to_new_document = adopting_to_new_document, + }, ); return new_node; From 6040cd3338c3ab6fe217deba87b9fd0a1940b95e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 14 Dec 2025 20:02:39 +0800 Subject: [PATCH 223/257] improve Form, notably form.elements --- src/browser/tests/element/html/form.html | 299 ++++++++++++++++++ src/browser/tests/legacy/xhr/form_data.html | 79 ++--- src/browser/tests/net/form_data.html | 131 ++++++++ src/browser/webapi/Node.zig | 4 +- src/browser/webapi/collections.zig | 2 + .../webapi/collections/HTMLCollection.zig | 24 +- .../HTMLFormControlsCollection.zig | 57 ++++ src/browser/webapi/collections/node_live.zig | 63 +++- src/browser/webapi/element/html/Form.zig | 131 +++----- src/browser/webapi/net/FormData.zig | 97 +++++- 10 files changed, 750 insertions(+), 137 deletions(-) create mode 100644 src/browser/tests/element/html/form.html create mode 100644 src/browser/webapi/collections/HTMLFormControlsCollection.zig diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html new file mode 100644 index 000000000..9c5055e31 --- /dev/null +++ b/src/browser/tests/element/html/form.html @@ -0,0 +1,299 @@ + + + + +
+
+ + + + + + +
+
+
+
+ + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+ + + + +
+ + + +
+ + + + + + + +
+ + + + +
+ + + diff --git a/src/browser/tests/legacy/xhr/form_data.html b/src/browser/tests/legacy/xhr/form_data.html index 94bf8a272..cda34c06e 100644 --- a/src/browser/tests/legacy/xhr/form_data.html +++ b/src/browser/tests/legacy/xhr/form_data.html @@ -1,5 +1,42 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- diff --git a/src/browser/tests/net/form_data.html b/src/browser/tests/net/form_data.html index 71aae77cb..515b8d93e 100644 --- a/src/browser/tests/net/form_data.html +++ b/src/browser/tests/net/form_data.html @@ -250,3 +250,134 @@ testing.expectEqual(3, context.sum); } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 532a335cf..e27ca5052 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -339,7 +339,7 @@ pub fn isConnected(self: *const Node) bool { const GetRootNodeOpts = struct { composed: bool = false, }; -pub fn getRootNode(self: *const Node, opts_: ?GetRootNodeOpts) *const Node { +pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node { const opts = opts_ orelse GetRootNodeOpts{}; var root = self; @@ -613,7 +613,7 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, Str } } -pub fn compareDocumentPosition(self: *const Node, other: *const Node) u16 { +pub fn compareDocumentPosition(self: *Node, other: *Node) u16 { const DISCONNECTED: u16 = 0x01; const PRECEDING: u16 = 0x02; const FOLLOWING: u16 = 0x04; diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index 3e36e05f9..d0dd81ceb 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -21,6 +21,7 @@ pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); +pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig"); pub fn registerTypes() []const type { return &.{ @@ -33,6 +34,7 @@ pub fn registerTypes() []const type { @import("collections/HTMLAllCollection.zig"), @import("collections/HTMLAllCollection.zig").Iterator, HTMLOptionsCollection, + HTMLFormControlsCollection, DOMTokenList, DOMTokenList.KeyIterator, DOMTokenList.ValueIterator, diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 3160524da..462f1b371 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -23,6 +23,7 @@ const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const NodeLive = @import("node_live.zig").NodeLive; +const Form = @import("../element/html/Form.zig"); const Mode = enum { tag, @@ -35,11 +36,13 @@ const Mode = enum { selected_options, links, anchors, + form, }; const HTMLCollection = @This(); -data: union(Mode) { +_type: Type = .{ .generic = {} }, +_data: union(Mode) { tag: NodeLive(.tag), tag_name: NodeLive(.tag_name), class_name: NodeLive(.class_name), @@ -50,22 +53,28 @@ data: union(Mode) { selected_options: NodeLive(.selected_options), links: NodeLive(.links), anchors: NodeLive(.anchors), + form: NodeLive(.form), }, +const Type = union(enum) { + generic: void, + form: *Form, +}; + pub fn length(self: *HTMLCollection, page: *const Page) u32 { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.length(page), }; } pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.getAtIndex(index, page), }; } pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.getByName(name, page), }; } @@ -73,7 +82,7 @@ pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { return Iterator.init(.{ .list = self, - .tw = switch (self.data) { + .tw = switch (self._data) { .tag => |*impl| .{ .tag = impl._tw.clone() }, .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, @@ -84,6 +93,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, .links => |*impl| .{ .links = impl._tw.clone() }, .anchors => |*impl| .{ .anchors = impl._tw.clone() }, + .form => |*impl| .{ .form = impl._tw.clone() }, }, }, page); } @@ -102,10 +112,11 @@ pub const Iterator = GenericIterator(struct { selected_options: TreeWalker.Children, links: TreeWalker.FullExcludeSelf, anchors: TreeWalker.FullExcludeSelf, + form: TreeWalker.FullExcludeSelf, }, pub fn next(self: *@This(), _: *Page) ?*Element { - return switch (self.list.data) { + return switch (self.list._data) { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .class_name => |*impl| impl.nextTw(&self.tw.class_name), @@ -116,6 +127,7 @@ pub const Iterator = GenericIterator(struct { .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), .links => |*impl| impl.nextTw(&self.tw.links), .anchors => |*impl| impl.nextTw(&self.tw.anchors), + .form => |*impl| impl.nextTw(&self.tw.form), }; } }, null); diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig new file mode 100644 index 000000000..e7fd14209 --- /dev/null +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -0,0 +1,57 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const Element = @import("../Element.zig"); +const HTMLCollection = @import("HTMLCollection.zig"); + +const HTMLFormControlsCollection = @This(); + +_proto: *HTMLCollection, + +pub fn length(self: *HTMLFormControlsCollection, page: *Page) u32 { + return self._proto.length(page); +} + +pub fn getAtIndex(self: *HTMLFormControlsCollection, index: usize, page: *Page) ?*Element { + return self._proto.getAtIndex(index, page); +} + +pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) ?*Element { + // TODO: When multiple elements have same name (radio buttons), + // should return RadioNodeList instead of first element + return self._proto.getByName(name, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(HTMLFormControlsCollection); + + pub const Meta = struct { + pub const name = "HTMLFormControlsCollection"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const manage = false; + }; + + pub const length = bridge.accessor(HTMLFormControlsCollection.length, null, .{}); + pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, .{ .null_as_undefined = true }); + pub const namedItem = bridge.function(HTMLFormControlsCollection.namedItem, .{}); +}; diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index f3f4dd1a1..f02b66fb3 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -28,6 +28,7 @@ const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const Selector = @import("../selector/Selector.zig"); +const Form = @import("../element/html/Form.zig"); const Allocator = std.mem.Allocator; @@ -42,6 +43,7 @@ const Mode = enum { selected_options, links, anchors, + form, }; const Filters = union(Mode) { @@ -55,6 +57,7 @@ const Filters = union(Mode) { selected_options, links, anchors, + form: *Form, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -82,7 +85,7 @@ const Filters = union(Mode) { pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { - .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors => TreeWalker.FullExcludeSelf, + .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf, .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { @@ -259,9 +262,46 @@ pub fn NodeLive(comptime mode: Mode) type { if (el.is(Anchor) == null) return false; return el.hasAttributeSafe("name"); }, + .form => { + const el = node.is(Element) orelse return false; + if (!isFormControl(el)) { + return false; + } + + if (el.getAttributeSafe("form")) |form_attr| { + const form_id = self._filter.asElement().getAttributeSafe("id") orelse return false; + return std.mem.eql(u8, form_attr, form_id); + } + + // No form attribute - match if descendant of our form + // This does an O(depth) ancestor walk for each control in the form. + // + // TODO: If profiling shows this is a bottleneck: + // When we first encounter the form element during tree walk, we could + // do a one-time reverse walk to find the LAST control that belongs to + // this form (checking both form controls and their form= attributes). + // Store that element in a new FormState. Then as we traverse + // forward: + // - Set is_within_form = true when we enter the form element + // - Return true immediately for any control while is_within_form + // - Set is_within_form = false when we reach that last element + // This trades one O(form_size) reverse walk for N O(depth) ancestor + // checks, where N = number of controls. For forms with many nested + // controls, this could be significantly faster. + return self._filter.asNode().contains(node); + }, } } + fn isFormControl(el: *Element) bool { + if (el._type != .html) return false; + const html = el._type.html; + return switch (html._type) { + .input, .button, .select, .text_area => true, + else => false, + }; + } + fn versionCheck(self: *Self, page: *const Page) bool { const current = page.version; if (current == self._cached_version) { @@ -278,16 +318,17 @@ pub fn NodeLive(comptime mode: Mode) type { const HTMLCollection = @import("HTMLCollection.zig"); pub fn runtimeGenericWrap(self: Self, page: *Page) !*HTMLCollection { const collection = switch (mode) { - .tag => HTMLCollection{ .data = .{ .tag = self } }, - .tag_name => HTMLCollection{ .data = .{ .tag_name = self } }, - .class_name => HTMLCollection{ .data = .{ .class_name = self } }, - .name => HTMLCollection{ .data = .{ .name = self } }, - .all_elements => HTMLCollection{ .data = .{ .all_elements = self } }, - .child_elements => HTMLCollection{ .data = .{ .child_elements = self } }, - .child_tag => HTMLCollection{ .data = .{ .child_tag = self } }, - .selected_options => HTMLCollection{ .data = .{ .selected_options = self } }, - .links => HTMLCollection{ .data = .{ .links = self } }, - .anchors => HTMLCollection{ .data = .{ .anchors = self } }, + .tag => HTMLCollection{ ._data = .{ .tag = self } }, + .tag_name => HTMLCollection{ ._data = .{ .tag_name = self } }, + .class_name => HTMLCollection{ ._data = .{ .class_name = self } }, + .name => HTMLCollection{ ._data = .{ .name = self } }, + .all_elements => HTMLCollection{ ._data = .{ .all_elements = self } }, + .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, + .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, + .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, + .links => HTMLCollection{ ._data = .{ .links = self } }, + .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, + .form => HTMLCollection{ ._type = .{ .form = self._filter }, ._data = .{ .form = self } }, }; return page._factory.create(collection); } diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 66f23ddec..4e3186a2b 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -23,6 +23,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TreeWalker = @import("../../TreeWalker.zig"); +const collections = @import("../../collections.zig"); const Input = @import("Input.zig"); const Button = @import("Button.zig"); @@ -32,6 +33,9 @@ const TextArea = @import("TextArea.zig"); const Form = @This(); _proto: *HtmlElement, +fn asConstElement(self: *const Form) *const Element { + return self._proto._proto; +} pub fn asElement(self: *Form) *Element { return self._proto._proto; } @@ -39,90 +43,49 @@ pub fn asNode(self: *Form) *Node { return self.asElement().asNode(); } -// Untested / unused right now. Iterates over all the controls of a form, -// including those outside the
...
but with a form=$FORM_ID attribute -pub const Iterator = struct { - _form_id: ?[]const u8, - _walkers: union(enum) { - nested: TreeWalker.FullExcludeSelf, - names: TreeWalker.FullExcludeSelf, - }, - - pub fn init(form: *Form) Iterator { - const form_element = form.asElement(); - const form_id = form_element.getAttributeSafe("id"); - - return .{ - ._form_id = form_id, - ._walkers = .{ - .nested = TreeWalker.FullExcludeSelf.init(form.asNode(), .{}), - }, - }; - } +pub fn getName(self: *const Form) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} - pub fn next(self: *Iterator) ?FormControl { - switch (self._walkers) { - .nested => |*tw| { - // find controls nested directly in the form - while (tw.next()) |node| { - const element = node.is(Element) orelse continue; - const control = asFormControl(element) orelse continue; - // Skip if it has a form attribute (will be handled in phase 2) - if (element.getAttributeSafe("form") == null) { - return control; - } - } - if (self._form_id == null) { - return null; - } - - const doc = tw._root.getRootNode(); - self._walkers = .{ - .names = TreeWalker.FullExcludeSelf(doc, .{}), - }; - return self.next(); - }, - .names => |*tw| { - // find controls with a name matching the form id - while (tw.next()) |node| { - const input = node.is(Input) orelse continue; - if (input._type != .radio) { - continue; - } - const input_form = input.asElement().getAttributeSafe("form") orelse continue; - // must have a self._form_id, else we never would have transitioned - // from a nested walker to a namew walker - if (!std.mem.eql(u8, input_form, self._form_id.?)) { - continue; - } - return .{ .input = input }; - } - - return null; - }, - } - } -}; +pub fn setName(self: *Form, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} -pub const FormControl = union(enum) { - input: *Input, - button: *Button, - select: *Select, - textarea: *TextArea, -}; +pub fn getMethod(self: *const Form) []const u8 { + const method = self.asConstElement().getAttributeSafe("method") orelse return "get"; -fn asFormControl(element: *Element) ?FormControl { - if (element._type != .html) { - return null; + if (std.ascii.eqlIgnoreCase(method, "post")) { + return "post"; } - const html = element._type.html; - switch (html._type) { - .input => |cntrl| return .{ .input = cntrl }, - .button => |cntrl| return .{ .button = cntrl }, - .select => |cntrl| return .{ .select = cntrl }, - .textarea => |cntrl| return .{ .textarea = cntrl }, - else => return null, + if (std.ascii.eqlIgnoreCase(method, "dialog")) { + return "dialog"; } + // invalid, or it was get all along + return "get"; +} + +pub fn setMethod(self: *Form, method: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("method", method, page); +} + +pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsCollection { + const form_id = self.asElement().getAttributeSafe("id"); + const root = if (form_id != null) + self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls + else + self.asNode(); // No ID: walk only form subtree (no external controls possible) + + const node_live = collections.NodeLive(.form).init(root, self, page); + const html_collection = try node_live.runtimeGenericWrap(page); + + return page._factory.create(collections.HTMLFormControlsCollection{ + ._proto = html_collection, + }); +} + +pub fn getLength(self: *Form, page: *Page) !u32 { + const elements = try self.getElements(page); + return elements.length(page); } pub const JsApi = struct { @@ -132,4 +95,14 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const name = bridge.accessor(Form.getName, Form.setName, .{}); + pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); + pub const elements = bridge.accessor(Form.getElements, null, .{}); + pub const length = bridge.accessor(Form.getLength, null, .{}); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Form" { + try testing.htmlRunner("element/html/form.html", .{}); +} diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 610c88bf3..f86552462 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -22,6 +22,8 @@ const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Form = @import("../element/html/Form.zig"); +const Element = @import("../Element.zig"); const KeyValueList = @import("../KeyValueList.zig"); const Alloctor = std.mem.Allocator; @@ -31,7 +33,9 @@ const FormData = @This(); _arena: Alloctor, _list: KeyValueList, -pub fn init(page: *Page) !*FormData { +pub fn init(form_: ?*Form, submitter_: ?*Element, page: *Page) !*FormData { + _ = form_; + _ = submitter_; return page._factory.create(FormData{ ._arena = page.arena, ._list = KeyValueList.init(), @@ -127,6 +131,97 @@ pub const JsApi = struct { pub const forEach = bridge.function(FormData.forEach, .{}); }; +// fn collectForm(form: *Form, submitter_: ?*Element, page: *Page) !KeyValueList { +// const arena = page.arena; + +// // Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements) +// // It doesn't work with dynamically added elements, because their form +// // property doesn't get set. We should fix that. +// // However, even once fixed, there are other form-collection features we +// // probably want to implement (like disabled fieldsets), so we might want +// // to stick with our own walker even if fix libdom to properly support +// // dynamically added elements. +// const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @ptrCast(@alignCast(form)), "input,select,button,textarea"); +// const nodes = node_list.nodes.items; + +// var entries: kv.List = .{}; +// try entries.ensureTotalCapacity(arena, nodes.len); + +// var submitter_included = false; +// const submitter_name_ = try getSubmitterName(submitter_); + +// for (nodes) |node| { +// const element = parser.nodeToElement(node); + +// // must have a name +// const name = try parser.elementGetAttribute(element, "name") orelse continue; +// if (try parser.elementGetAttribute(element, "disabled") != null) { +// continue; +// } + +// const tag = try parser.elementTag(element); +// switch (tag) { +// .input => { +// const tpe = try parser.inputGetType(@ptrCast(element)); +// if (std.ascii.eqlIgnoreCase(tpe, "image")) { +// if (submitter_name_) |submitter_name| { +// if (std.mem.eql(u8, submitter_name, name)) { +// const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name}); +// const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name}); +// try entries.appendOwned(arena, key_x, "0"); +// try entries.appendOwned(arena, key_y, "0"); +// submitter_included = true; +// } +// } +// continue; +// } + +// if (std.ascii.eqlIgnoreCase(tpe, "checkbox") or std.ascii.eqlIgnoreCase(tpe, "radio")) { +// if (try parser.inputGetChecked(@ptrCast(element)) == false) { +// continue; +// } +// } +// if (std.ascii.eqlIgnoreCase(tpe, "submit")) { +// if (submitter_name_ == null or !std.mem.eql(u8, submitter_name_.?, name)) { +// continue; +// } +// submitter_included = true; +// } +// const value = try parser.inputGetValue(@ptrCast(element)); +// try entries.appendOwned(arena, name, value); +// }, +// .select => { +// const select: *parser.Select = @ptrCast(node); +// try collectSelectValues(arena, select, name, &entries, page); +// }, +// .textarea => { +// const textarea: *parser.TextArea = @ptrCast(node); +// const value = try parser.textareaGetValue(textarea); +// try entries.appendOwned(arena, name, value); +// }, +// .button => if (submitter_name_) |submitter_name| { +// if (std.mem.eql(u8, submitter_name, name)) { +// const value = (try parser.elementGetAttribute(element, "value")) orelse ""; +// try entries.appendOwned(arena, name, value); +// submitter_included = true; +// } +// }, +// else => unreachable, +// } +// } + +// if (submitter_included == false) { +// if (submitter_name_) |submitter_name| { +// // this can happen if the submitter is outside the form, but associated +// // with the form via a form=ID attribute +// const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse ""; +// try entries.appendOwned(arena, submitter_name, value); +// } +// } + +// return entries; +// } + const testing = @import("../../../testing.zig"); test "WebApi: FormData" { try testing.htmlRunner("net/form_data.html", .{}); From ac0601b141c900ed43e55c2558604e5a856870d8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 15 Dec 2025 10:31:44 +0800 Subject: [PATCH 224/257] add RadioNodeList --- .../tests/collections/radio_node_list.html | 213 ++++++++++++++++++ src/browser/tests/element/html/form.html | 40 +++- src/browser/webapi/collections.zig | 2 + .../HTMLFormControlsCollection.zig | 91 +++++++- src/browser/webapi/collections/NodeList.zig | 7 + .../webapi/collections/RadioNodeList.zig | 133 +++++++++++ 6 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 src/browser/tests/collections/radio_node_list.html create mode 100644 src/browser/webapi/collections/RadioNodeList.zig diff --git a/src/browser/tests/collections/radio_node_list.html b/src/browser/tests/collections/radio_node_list.html new file mode 100644 index 000000000..b90b6494a --- /dev/null +++ b/src/browser/tests/collections/radio_node_list.html @@ -0,0 +1,213 @@ + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index 9c5055e31..f148fae0f 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -249,11 +249,22 @@ testing.expectEqual('b', form.elements[1].value) testing.expectEqual('c', form.elements[2].value) - // Note: In spec-compliant browsers, namedItem with duplicate names returns RadioNodeList - // RadioNodeList.value returns the checked radio's value (or "" if none checked) - // Our implementation currently returns the first element (TODO: implement RadioNodeList) - // For now, test that we can access by index which works in all browsers - testing.expectEqual('choice', form.elements[0].name) + // Ensure all radios are unchecked at start (cleanup from any previous tests) + form.elements[0].checked = false + form.elements[1].checked = false + form.elements[2].checked = false + + // namedItem with duplicate names returns RadioNodeList + const result = form.elements.namedItem('choice') + testing.expectEqual('RadioNodeList', result.constructor.name) + testing.expectEqual(3, result.length) + testing.expectEqual('', result.value) + + form.elements[1].checked = true + testing.expectEqual('b', result.value) + + result.value = 'c' + testing.expectEqual(true, form.elements[2].checked) } @@ -297,3 +308,22 @@ testing.expectEqual(0, form.elements.length) } + +
+ + + +
+ + diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index d0dd81ceb..13cd911f5 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -19,6 +19,7 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive; pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); +pub const RadioNodeList = @import("collections/RadioNodeList.zig"); pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig"); @@ -35,6 +36,7 @@ pub fn registerTypes() []const type { @import("collections/HTMLAllCollection.zig").Iterator, HTMLOptionsCollection, HTMLFormControlsCollection, + RadioNodeList, DOMTokenList, DOMTokenList.KeyIterator, DOMTokenList.ValueIterator, diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig index e7fd14209..513b5c55d 100644 --- a/src/browser/webapi/collections/HTMLFormControlsCollection.zig +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -20,12 +20,20 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); + +const NodeList = @import("NodeList.zig"); +const RadioNodeList = @import("RadioNodeList.zig"); const HTMLCollection = @import("HTMLCollection.zig"); const HTMLFormControlsCollection = @This(); _proto: *HTMLCollection, +pub const NamedItemResult = union(enum) { + element: *Element, + radio_node_list: *RadioNodeList, +}; + pub fn length(self: *HTMLFormControlsCollection, page: *Page) u32 { return self._proto.length(page); } @@ -34,12 +42,87 @@ pub fn getAtIndex(self: *HTMLFormControlsCollection, index: usize, page: *Page) return self._proto.getAtIndex(index, page); } -pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) ?*Element { - // TODO: When multiple elements have same name (radio buttons), - // should return RadioNodeList instead of first element - return self._proto.getByName(name, page); +pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) !?NamedItemResult { + if (name.len == 0) { + return null; + } + + // We need special handling for radio, where multiple inputs can have the + // same name, but we also need to handle the [incorrect] case where non- + // radios share names. + + var count: u32 = 0; + var first_element: ?*Element = null; + + var it = try self.iterator(); + while (it.next()) |element| { + const is_match = blk: { + if (element.getAttributeSafe("id")) |id| { + if (std.mem.eql(u8, id, name)) { + break :blk true; + } + } + if (element.getAttributeSafe("name")) |elem_name| { + if (std.mem.eql(u8, elem_name, name)) { + break :blk true; + } + } + break :blk false; + }; + + if (is_match) { + if (first_element == null) { + first_element = element; + } + count += 1; + + if (count == 2) { + const radio_node_list = try page._factory.create(RadioNodeList{ + ._proto = undefined, + ._form_collection = self, + ._name = try page.dupeString(name), + }); + + radio_node_list._proto = try page._factory.create(NodeList{ .data = .{ .radio_node_list = radio_node_list } }); + + return .{ .radio_node_list = radio_node_list }; + } + } + } + + if (count == 0) { + return null; + } + + // case == 2 was handled inside the loop + std.debug.assert(count == 1); + + return .{ .element = first_element.? }; +} + +// used internally, by HTMLFormControlsCollection and RadioNodeList +pub fn iterator(self: *HTMLFormControlsCollection) !Iterator { + const form_collection = self._proto._data.form; + return .{ + .tw = form_collection._tw.clone(), + .nodes = form_collection, + }; } +// Used internally. Presents a nicer (more zig-like) iterator and strips away +// some of the abstraction. +pub const Iterator = struct { + tw: TreeWalker, + nodes: NodeLive, + + const NodeLive = @import("node_live.zig").NodeLive(.form); + const TreeWalker = @import("../TreeWalker.zig").FullExcludeSelf; + + pub fn next(self: *Iterator) ?*Element { + return self.nodes.nextTw(&self.tw); + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(HTMLFormControlsCollection); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 8ee8b104c..cd8ec2504 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -22,13 +22,17 @@ const log = @import("../../..//log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); +const Element = @import("../Element.zig"); const ChildNodes = @import("ChildNodes.zig"); +const RadioNodeList = @import("RadioNodeList.zig"); const SelectorList = @import("../selector/List.zig"); +const HTMLFormControlsCollection = @import("HTMLFormControlsCollection.zig"); const Mode = enum { child_nodes, selector_list, + radio_node_list, }; const NodeList = @This(); @@ -36,12 +40,14 @@ const NodeList = @This(); data: union(Mode) { child_nodes: *ChildNodes, selector_list: *SelectorList, + radio_node_list: *RadioNodeList, }, pub fn length(self: *NodeList, page: *Page) !u32 { return switch (self.data) { .child_nodes => |impl| impl.length(page), .selector_list => |impl| @intCast(impl.getLength()), + .radio_node_list => |impl| impl.getLength(), }; } @@ -49,6 +55,7 @@ pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { return switch (self.data) { .child_nodes => |impl| impl.getAtIndex(index, page), .selector_list => |impl| impl.getAtIndex(index), + .radio_node_list => |impl| impl.getAtIndex(index, page), }; } diff --git a/src/browser/webapi/collections/RadioNodeList.zig b/src/browser/webapi/collections/RadioNodeList.zig new file mode 100644 index 000000000..b126b2a17 --- /dev/null +++ b/src/browser/webapi/collections/RadioNodeList.zig @@ -0,0 +1,133 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Node = @import("../Node.zig"); +const Element = @import("../Element.zig"); +const Input = @import("../element/html/Input.zig"); + +const NodeList = @import("NodeList.zig"); +const HTMLFormControlsCollection = @import("HTMLFormControlsCollection.zig"); + +const RadioNodeList = @This(); + +_proto: *NodeList, +_name: []const u8, +_form_collection: *HTMLFormControlsCollection, + +pub fn getLength(self: *RadioNodeList) !u32 { + var i: u32 = 0; + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + if (self.matches(element)) { + i += 1; + } + } + return i; +} + +pub fn getAtIndex(self: *RadioNodeList, index: usize, page: *Page) !?*Node { + var i: usize = 0; + var current: usize = 0; + while (self._form_collection.getAtIndex(i, page)) |element| : (i += 1) { + if (!self.matches(element)) { + continue; + } + if (current == index) { + return element.asNode(); + } + current += 1; + } + return null; +} + +pub fn getValue(self: *RadioNodeList) ![]const u8 { + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + const input = element.is(Input) orelse continue; + if (input._input_type != .radio) { + continue; + } + if (!input.getChecked()) { + continue; + } + return element.getAttributeSafe("value") orelse "on"; + } + return ""; +} + +pub fn setValue(self: *RadioNodeList, value: []const u8, page: *Page) !void { + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + const input = element.is(Input) orelse continue; + if (input._input_type != .radio) { + continue; + } + + const input_value = element.getAttributeSafe("value"); + const matches_value = blk: { + if (std.mem.eql(u8, value, "on")) { + break :blk input_value == null or (input_value != null and std.mem.eql(u8, input_value.?, "on")); + } else { + break :blk input_value != null and std.mem.eql(u8, input_value.?, value); + } + }; + + if (matches_value) { + try input.setChecked(true, page); + return; + } + } +} + +fn matches(self: *const RadioNodeList, element: *Element) bool { + if (element.getAttributeSafe("id")) |id| { + if (std.mem.eql(u8, id, self._name)) { + return true; + } + } + if (element.getAttributeSafe("name")) |elem_name| { + if (std.mem.eql(u8, elem_name, self._name)) { + return true; + } + } + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(RadioNodeList); + + pub const Meta = struct { + pub const name = "RadioNodeList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(RadioNodeList.getLength, null, .{}); + pub const @"[]" = bridge.indexed(RadioNodeList.getAtIndex, .{ .null_as_undefined = true }); + pub const item = bridge.function(RadioNodeList.getAtIndex, .{}); + pub const value = bridge.accessor(RadioNodeList.getValue, RadioNodeList.setValue, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: RadioNodeList" { + try testing.htmlRunner("collections/radio_node_list.html", .{}); +} From 4bebc4c142cc1d134c6198b47398dff983abf63b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 15 Dec 2025 10:35:41 +0800 Subject: [PATCH 225/257] simplify / standardized how HTMLFormControlsCollection 'inherits' from HTMLCollection --- src/browser/webapi/collections/HTMLCollection.zig | 6 ------ src/browser/webapi/collections/node_live.zig | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 462f1b371..e1b0f5477 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -41,7 +41,6 @@ const Mode = enum { const HTMLCollection = @This(); -_type: Type = .{ .generic = {} }, _data: union(Mode) { tag: NodeLive(.tag), tag_name: NodeLive(.tag_name), @@ -56,11 +55,6 @@ _data: union(Mode) { form: NodeLive(.form), }, -const Type = union(enum) { - generic: void, - form: *Form, -}; - pub fn length(self: *HTMLCollection, page: *const Page) u32 { return switch (self._data) { inline else => |*impl| impl.length(page), diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index f02b66fb3..5975f428d 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -328,7 +328,7 @@ pub fn NodeLive(comptime mode: Mode) type { .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, .links => HTMLCollection{ ._data = .{ .links = self } }, .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, - .form => HTMLCollection{ ._type = .{ .form = self._filter }, ._data = .{ .form = self } }, + .form => HTMLCollection{ ._data = .{ .form = self } }, }; return page._factory.create(collection); } From 9b3107d4fe5041077ca94504650444d771c11e00 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 15 Dec 2025 12:31:30 +0800 Subject: [PATCH 226/257] build FormData from optional form and optional submitter --- src/browser/tests/net/form_data.html | 249 ++++++++++++++++++- src/browser/webapi/Element.zig | 6 +- src/browser/webapi/collections/node_live.zig | 6 +- src/browser/webapi/element/Html.zig | 4 +- src/browser/webapi/element/html/Button.zig | 9 + src/browser/webapi/element/html/Form.zig | 8 +- src/browser/webapi/element/html/Select.zig | 10 +- src/browser/webapi/net/FormData.zig | 177 ++++++------- 8 files changed, 357 insertions(+), 112 deletions(-) diff --git a/src/browser/tests/net/form_data.html b/src/browser/tests/net/form_data.html index 515b8d93e..97814209f 100644 --- a/src/browser/tests/net/form_data.html +++ b/src/browser/tests/net/form_data.html @@ -349,7 +349,7 @@ testing.expectEqual([['b', '3']], acc); - + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 7de5f14fd..97a2ecb4a 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -202,7 +202,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .slot => "slot", .style => "style", .template => "template", - .text_area => "textarea", + .textarea => "textarea", .title => "title", .ul => "ul", .unknown => |e| e._tag_name.str(), @@ -254,7 +254,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .slot => "SLOT", .style => "STYLE", .template => "TEMPLATE", - .text_area => "TEXTAREA", + .textarea => "TEXTAREA", .title => "TITLE", .ul => "UL", .unknown => |e| switch (self._namespace) { @@ -1097,7 +1097,7 @@ pub fn getTag(self: *const Element) Tag { .slot => .slot, .option => .option, .template => .template, - .text_area => .textarea, + .textarea => .textarea, .input => .input, .link => .link, .meta => .meta, diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 5975f428d..68ca3b739 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -194,6 +194,10 @@ pub fn NodeLive(comptime mode: Mode) type { return null; } + pub fn next(self: *Self) ?*Element { + return self.nextTw(&self._tw); + } + pub fn nextTw(self: *Self, tw: *TW) ?*Element { while (tw.next()) |node| { if (self.matches(node)) { @@ -297,7 +301,7 @@ pub fn NodeLive(comptime mode: Mode) type { if (el._type != .html) return false; const html = el._type.html; return switch (html._type) { - .input, .button, .select, .text_area => true, + .input, .button, .select, .textarea => true, else => false, }; } diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 8016c8be6..fe88e96cb 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -100,7 +100,7 @@ pub const Type = union(enum) { slot: *Slot, style: *Style, template: *Template, - text_area: *TextArea, + textarea: *TextArea, title: *Title, ul: *UL, unknown: *Unknown, @@ -156,7 +156,7 @@ pub fn className(self: *const HtmlElement) []const u8 { .slot => "[object HTMLSlotElement]", .style => "[object HTMLSyleElement]", .template => "[object HTMLTemplateElement]", - .text_area => "[object HTMLTextAreaElement]", + .textarea => "[object HTMLTextAreaElement]", .title => "[object HTMLTitleElement]", .ul => "[object HTMLULElement]", .unknown => "[object HTMLUnknownElement]", diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index acf076e61..d58e0cbb7 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -58,6 +58,14 @@ pub fn setName(self: *Button, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe("name", name, page); } +pub fn getValue(self: *const Button) []const u8 { + return self.asConstElement().getAttributeSafe("value") orelse ""; +} + +pub fn setValue(self: *Button, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("value", value, page); +} + pub fn getRequired(self: *const Button) bool { return self.asConstElement().getAttributeSafe("required") != null; } @@ -107,6 +115,7 @@ pub const JsApi = struct { pub const name = bridge.accessor(Button.getName, Button.setName, .{}); pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); + pub const value = bridge.accessor(Button.getValue, Button.setValue, .{}); }; pub const Build = struct { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 4e3186a2b..ac89948b8 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -25,10 +25,10 @@ const HtmlElement = @import("../Html.zig"); const TreeWalker = @import("../../TreeWalker.zig"); const collections = @import("../../collections.zig"); -const Input = @import("Input.zig"); -const Button = @import("Button.zig"); -const Select = @import("Select.zig"); -const TextArea = @import("TextArea.zig"); +pub const Input = @import("Input.zig"); +pub const Button = @import("Button.zig"); +pub const Select = @import("Select.zig"); +pub const TextArea = @import("TextArea.zig"); const Form = @This(); _proto: *HtmlElement, diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 8a5ef4d25..ddac4b6de 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -24,7 +24,7 @@ const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const collections = @import("../../collections.zig"); const Form = @import("Form.zig"); -const Option = @import("Option.zig"); +pub const Option = @import("Option.zig"); const Select = @This(); @@ -50,12 +50,16 @@ pub fn getValue(self: *Select, page: *Page) []const u8 { var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; - if (first_option == null) { - first_option = option; + if (option.getDisabled()) { + continue; } + if (option.getSelected()) { return option.getValue(page); } + if (first_option == null) { + first_option = option; + } } // No explicitly selected option, return first option's value if (first_option) |opt| { diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index f86552462..1c5b8314e 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -26,19 +26,17 @@ const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); const KeyValueList = @import("../KeyValueList.zig"); -const Alloctor = std.mem.Allocator; +const Allocator = std.mem.Allocator; const FormData = @This(); -_arena: Alloctor, +_arena: Allocator, _list: KeyValueList, -pub fn init(form_: ?*Form, submitter_: ?*Element, page: *Page) !*FormData { - _ = form_; - _ = submitter_; +pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData { return page._factory.create(FormData{ ._arena = page.arena, - ._list = KeyValueList.init(), + ._list = try collectForm(page.arena, form, submitter, page), }); } @@ -108,6 +106,82 @@ pub const Iterator = struct { } }; +fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Page) !KeyValueList { + var list: KeyValueList = .empty; + const form = form_ orelse return list; + + var elements = try form.getElements(page); + var it = try elements.iterator(); + while (it.next()) |element| { + if (element.getAttributeSafe("disabled") != null) { + continue; + } + + // Handle image submitters first - they can submit without a name + if (element.is(Form.Input)) |input| { + if (input._input_type == .image) { + const submitter = submitter_ orelse continue; + if (submitter != element) { + continue; + } + + const name = element.getAttributeSafe("name"); + const x_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.x", .{n}) else "x"; + const y_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.y", .{n}) else "y"; + try list.append(arena, x_key, "0"); + try list.append(arena, y_key, "0"); + continue; + } + } + + const name = element.getAttributeSafe("name") orelse continue; + const value = blk: { + if (element.is(Form.Input)) |input| { + const input_type = input._input_type; + if (input_type == .checkbox or input_type == .radio) { + if (!input.getChecked()) { + continue; + } + } + if (input_type == .submit) { + const submitter = submitter_ orelse continue; + if (submitter != element) { + continue; + } + } + break :blk input.getValue(); + } + + if (element.is(Form.Select)) |select| { + if (select.getMultiple() == false) { + break :blk select.getValue(page); + } + + var options = try select.getSelectedOptions(page); + while (options.next()) |option| { + try list.append(arena, name, option.as(Form.Select.Option).getValue(page)); + } + continue; + } + + if (element.is(Form.TextArea)) |textarea| { + break :blk textarea.getValue(); + } + + if (submitter_) |submitter| { + if (submitter == element) { + // The form iterator only yields form controls. If we're here + // all other control types have been handled. So the cast is safe. + break :blk element.as(Form.Button).getValue(); + } + } + continue; + }; + try list.append(arena, name, value); + } + return list; +} + pub const JsApi = struct { pub const bridge = js.Bridge(FormData); @@ -131,97 +205,6 @@ pub const JsApi = struct { pub const forEach = bridge.function(FormData.forEach, .{}); }; -// fn collectForm(form: *Form, submitter_: ?*Element, page: *Page) !KeyValueList { -// const arena = page.arena; - -// // Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements) -// // It doesn't work with dynamically added elements, because their form -// // property doesn't get set. We should fix that. -// // However, even once fixed, there are other form-collection features we -// // probably want to implement (like disabled fieldsets), so we might want -// // to stick with our own walker even if fix libdom to properly support -// // dynamically added elements. -// const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @ptrCast(@alignCast(form)), "input,select,button,textarea"); -// const nodes = node_list.nodes.items; - -// var entries: kv.List = .{}; -// try entries.ensureTotalCapacity(arena, nodes.len); - -// var submitter_included = false; -// const submitter_name_ = try getSubmitterName(submitter_); - -// for (nodes) |node| { -// const element = parser.nodeToElement(node); - -// // must have a name -// const name = try parser.elementGetAttribute(element, "name") orelse continue; -// if (try parser.elementGetAttribute(element, "disabled") != null) { -// continue; -// } - -// const tag = try parser.elementTag(element); -// switch (tag) { -// .input => { -// const tpe = try parser.inputGetType(@ptrCast(element)); -// if (std.ascii.eqlIgnoreCase(tpe, "image")) { -// if (submitter_name_) |submitter_name| { -// if (std.mem.eql(u8, submitter_name, name)) { -// const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name}); -// const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name}); -// try entries.appendOwned(arena, key_x, "0"); -// try entries.appendOwned(arena, key_y, "0"); -// submitter_included = true; -// } -// } -// continue; -// } - -// if (std.ascii.eqlIgnoreCase(tpe, "checkbox") or std.ascii.eqlIgnoreCase(tpe, "radio")) { -// if (try parser.inputGetChecked(@ptrCast(element)) == false) { -// continue; -// } -// } -// if (std.ascii.eqlIgnoreCase(tpe, "submit")) { -// if (submitter_name_ == null or !std.mem.eql(u8, submitter_name_.?, name)) { -// continue; -// } -// submitter_included = true; -// } -// const value = try parser.inputGetValue(@ptrCast(element)); -// try entries.appendOwned(arena, name, value); -// }, -// .select => { -// const select: *parser.Select = @ptrCast(node); -// try collectSelectValues(arena, select, name, &entries, page); -// }, -// .textarea => { -// const textarea: *parser.TextArea = @ptrCast(node); -// const value = try parser.textareaGetValue(textarea); -// try entries.appendOwned(arena, name, value); -// }, -// .button => if (submitter_name_) |submitter_name| { -// if (std.mem.eql(u8, submitter_name, name)) { -// const value = (try parser.elementGetAttribute(element, "value")) orelse ""; -// try entries.appendOwned(arena, name, value); -// submitter_included = true; -// } -// }, -// else => unreachable, -// } -// } - -// if (submitter_included == false) { -// if (submitter_name_) |submitter_name| { -// // this can happen if the submitter is outside the form, but associated -// // with the form via a form=ID attribute -// const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse ""; -// try entries.appendOwned(arena, submitter_name, value); -// } -// } - -// return entries; -// } - const testing = @import("../../../testing.zig"); test "WebApi: FormData" { try testing.htmlRunner("net/form_data.html", .{}); From 1639ff1b98a7a69562856e9f43727b26a9fa212c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 15 Dec 2025 17:56:23 +0800 Subject: [PATCH 227/257] improve XMLHTTPRequest. Legacy xhr.html pass --- src/browser/js/Env.zig | 6 +- src/browser/tests/legacy/xhr/xhr.html | 12 +- src/browser/tests/net/xhr.html | 191 ++++++++++++++++++++++ src/browser/tests/testing.js | 6 +- src/browser/webapi/net/XMLHttpRequest.zig | 185 +++++++++++++++------ src/main_legacy_test.zig | 16 ++ src/testing.zig | 34 +++- 7 files changed, 384 insertions(+), 66 deletions(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 505eb6e8d..5bc1f7fcc 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -201,7 +201,11 @@ fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void { else "no value"; - log.debug(.js, "unhandled rejection", .{ .value = value, .stack = context.stackTrace() catch |err| @errorName(err) orelse "???" }); + log.debug(.js, "unhandled rejection", .{ + .value = value, + .stack = context.stackTrace() catch |err| @errorName(err) orelse "???", + .note = "This should be updated to call window.unhandledrejection", + }); } // Give it a Zig struct, get back a v8.FunctionTemplate. diff --git a/src/browser/tests/legacy/xhr/xhr.html b/src/browser/tests/legacy/xhr/xhr.html index 13ab6216e..2ff428b78 100644 --- a/src/browser/tests/legacy/xhr/xhr.html +++ b/src/browser/tests/legacy/xhr/xhr.html @@ -11,13 +11,12 @@ testing.expectEqual(cbk, req.onload); req.onload = cbk; - req.open('GET', 'http://127.0.0.1:9582/xhr'); + req.open('GET', 'http://127.0.0.1:9589/xhr'); testing.expectEqual(0, req.status); testing.expectEqual('', req.statusText); testing.expectEqual('', req.getAllResponseHeaders()); testing.expectEqual(null, req.getResponseHeader('Content-Type')); testing.expectEqual('', req.responseText); - req.send(); }); @@ -31,7 +30,6 @@ testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders()); testing.expectEqual(100, req.responseText.length); testing.expectEqual(req.responseText.length, req.response.length); - testing.expectEqual(true, req.responseXML instanceof Document); }); @@ -39,7 +37,7 @@ const req2 = new XMLHttpRequest() const promise2 = new Promise((resolve) => { req2.onload = resolve; - req2.open('GET', 'http://127.0.0.1:9582/xhr') + req2.open('GET', 'http://127.0.0.1:9589/xhr') req2.responseType = 'document'; req2.send() }); @@ -56,7 +54,7 @@ const req3 = new XMLHttpRequest() const promise3 = new Promise((resolve) => { req3.onload = resolve; - req3.open('GET', 'http://127.0.0.1:9582/xhr/json') + req3.open('GET', 'http://127.0.0.1:9589/xhr/json') req3.responseType = 'json'; req3.send() }); @@ -72,7 +70,7 @@ const req4 = new XMLHttpRequest() const promise4 = new Promise((resolve) => { req4.onload = resolve; - req4.open('POST', 'http://127.0.0.1:9582/xhr') + req4.open('POST', 'http://127.0.0.1:9589/xhr') req4.send('foo') }); @@ -94,7 +92,7 @@ } } - req5.open('GET', 'http://127.0.0.1:9582/xhr'); + req5.open('GET', 'http://127.0.0.1:9589/xhr'); req5.send(); }); diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index bb73aa735..82e9b6d13 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -7,4 +7,195 @@ testing.expectEqual(2, XMLHttpRequest.HEADERS_RECEIVED); testing.expectEqual(3, XMLHttpRequest.LOADING); testing.expectEqual(4, XMLHttpRequest.DONE); + + testing.async(async (restore) => { + const req = new XMLHttpRequest(); + const event = await new Promise((resolve) => { + function cbk(event) { + resolve(event) + } + + req.onload = cbk; + testing.expectEqual(cbk, req.onload); + req.onload = cbk; + + req.open('GET', 'http://127.0.0.1:9582/xhr'); + testing.expectEqual(0, req.status); + testing.expectEqual('', req.statusText); + testing.expectEqual('', req.getAllResponseHeaders()); + testing.expectEqual(null, req.getResponseHeader('Content-Type')); + testing.expectEqual('', req.responseText); + testing.expectEqual('', req.responseURL); + req.send(); + }); + + restore(); + testing.expectEqual('load', event.type); + testing.expectEqual(true, event.loaded > 0); + testing.expectEqual(true, event instanceof ProgressEvent); + testing.expectEqual(200, req.status); + testing.expectEqual('OK', req.statusText); + testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type')); + testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders()); + testing.expectEqual(100, req.responseText.length); + testing.expectEqual(req.responseText.length, req.response.length); + testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL); + }); + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 5cb6220a0..afc1fa694 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -64,10 +64,8 @@ } async function async(cb) { - const script_id = document.currentScript.id; - const stack = new Error().stack; - async_capture = {script_id: script_id, stack: stack}; - await cb(); + let capture = {script_id: document.currentScript.id, stack: new Error().stack}; + await cb(() => { async_capture = capture; }); async_capture = null; } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 4959d5638..f8181e10a 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -25,6 +25,7 @@ const Http = @import("../../../http/Http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); +const Node = @import("../Node.zig"); const Event = @import("../Event.zig"); const Headers = @import("Headers.zig"); const EventTarget = @import("../EventTarget.zig"); @@ -44,17 +45,19 @@ _method: Http.Method = .GET, _request_headers: *Headers, _request_body: ?[]const u8 = null, -_response: std.ArrayList(u8) = .empty, +_response: ?Response = null, +_response_data: std.ArrayList(u8) = .empty, _response_status: u16 = 0, _response_len: ?usize = 0, +_response_url: [:0]const u8 = "", _response_mime: ?Mime = null, _response_headers: std.ArrayList([]const u8) = .empty, _response_type: ResponseType = .text, -_state: State = .unsent, +_ready_state: ReadyState = .unsent, _on_ready_state_change: ?js.Function = null, -const State = enum(u8) { +const ReadyState = enum(u8) { unsent = 0, opened = 1, headers_received = 2, @@ -62,9 +65,16 @@ const State = enum(u8) { done = 4, }; +const Response = union(ResponseType) { + text: []const u8, + json: std.json.Value, + document: *Node.Document, +}; + const ResponseType = enum { text, json, + document, // TODO: other types to support }; @@ -100,30 +110,6 @@ pub fn setOnReadyStateChange(self: *XMLHttpRequest, cb_: ?js.Function) !void { } } -pub fn getResponseType(self: *const XMLHttpRequest) []const u8 { - return @tagName(self._response_type); -} - -pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void { - if (std.meta.stringToEnum(ResponseType, value)) |rt| { - self._response_type = rt; - } -} - -pub fn getStatus(self: *const XMLHttpRequest) u16 { - return self._response_status; -} - -pub fn getResponse(self: *const XMLHttpRequest, page: *Page) !Response { - switch (self._response_type) { - .text => return .{ .text = self._response.items }, - .json => { - const parsed = try std.json.parseFromSliceLeaky(std.json.Value, page.call_arena, self._response.items, .{}); - return .{ .json = parsed }; - }, - } -} - // TODO: this takes an opitonal 3 more parameters // TODO: url should be a union, as it can be multiple things pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void { @@ -168,6 +154,105 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { .error_callback = httpErrorCallback, }); } +pub fn getReadyState(self: *const XMLHttpRequest) u32 { + return @intFromEnum(self._ready_state); +} + +pub fn getResponseHeader(self: *const XMLHttpRequest, name: []const u8) ?[]const u8 { + for (self._response_headers.items) |entry| { + if (entry.len <= name.len) { + continue; + } + if (std.ascii.eqlIgnoreCase(name, entry[0..name.len]) == false) { + continue; + } + if (entry[name.len] != ':') { + continue; + } + return std.mem.trimLeft(u8, entry[name.len + 1 ..], " "); + } + return null; +} + +pub fn getAllResponseHeaders(self: *const XMLHttpRequest, page: *Page) ![]const u8 { + if (self._ready_state != .done) { + // MDN says this should return null, but it seems to return an empty string + // in every browser. Specs are too hard for a dumbo like me to understand. + return ""; + } + + var buf = std.Io.Writer.Allocating.init(page.call_arena); + for (self._response_headers.items) |entry| { + try buf.writer.writeAll(entry); + try buf.writer.writeAll("\r\n"); + } + return buf.written(); +} + +pub fn getResponseType(self: *const XMLHttpRequest) []const u8 { + if (self._ready_state != .done) { + return ""; + } + return @tagName(self._response_type); +} + +pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void { + if (std.meta.stringToEnum(ResponseType, value)) |rt| { + self._response_type = rt; + } +} + +pub fn getResponseText(self: *const XMLHttpRequest) []const u8 { + return self._response_data.items; +} + +pub fn getStatus(self: *const XMLHttpRequest) u16 { + return self._response_status; +} + +pub fn getStatusText(self: *const XMLHttpRequest) []const u8 { + return std.http.Status.phrase(@enumFromInt(self._response_status)) orelse ""; +} + +pub fn getResponseURL(self: *XMLHttpRequest) []const u8 { + return self._response_url; +} + +pub fn getResponse(self: *XMLHttpRequest, page: *Page) !?Response { + if (self._ready_state != .done) { + return null; + } + + if (self._response) |res| { + // was already loaded + return res; + } + + const data = self._response_data.items; + const res: Response = switch (self._response_type) { + .text => .{ .text = data }, + .json => blk: { + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, page.call_arena, data, .{}); + break :blk .{ .json = parsed }; + }, + .document => blk: { + const document = try page._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); + try page.parseHtmlAsChildren(document.asNode(), data); + break :blk .{ .document = document }; + }, + }; + + self._response = res; + return res; +} + +pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document { + const res = (try self.getResponse(page)) orelse return null; + return switch (res) { + .document => |doc| doc, + else => null, + }; +} fn httpStartCallback(transfer: *Http.Transfer) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); @@ -211,8 +296,9 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { self._response_status = header.status; if (transfer.getContentLength()) |cl| { self._response_len = cl; - try self._response.ensureTotalCapacity(self._arena, cl); + try self._response_data.ensureTotalCapacity(self._arena, cl); } + self._response_url = try self._arena.dupeZ(u8, std.mem.span(header.url)); try self.stateChanged(.headers_received, self._page); try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, self._page); @@ -221,11 +307,11 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); - try self._response.appendSlice(self._arena, data); + try self._response_data.appendSlice(self._arena, data); try self._proto.dispatch(.progress, .{ .total = self._response_len orelse 0, - .loaded = self._response.items.len, + .loaded = self._response_data.items.len, }, self._page); } @@ -236,7 +322,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void { .source = "xhr", .url = self._url, .status = self._response_status, - .len = self._response.items.len, + .len = self._response_data.items.len, }); // Not that the request is done, the http/client will free the transfer @@ -244,7 +330,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void { self._transfer = null; try self.stateChanged(.done, self._page); - const loaded = self._response.items.len; + const loaded = self._response_data.items.len; try self._proto.dispatch(.load, .{ .total = loaded, .loaded = loaded, @@ -262,7 +348,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { self.handleError(err); } -pub fn _abort(self: *XMLHttpRequest) void { +pub fn abort(self: *XMLHttpRequest) void { self.handleError(error.Abort); if (self._transfer) |transfer| { transfer.abort(); @@ -281,13 +367,14 @@ fn handleError(self: *XMLHttpRequest, err: anyerror) void { fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { const is_abort = err == error.Abort; - const new_state: State = if (is_abort) .unsent else .done; - if (new_state != self._state) { + const new_state: ReadyState = if (is_abort) .unsent else .done; + if (new_state != self._ready_state) { const page = self._page; try self.stateChanged(new_state, page); if (is_abort) { try self._proto.dispatch(.abort, null, page); } + try self._proto.dispatch(.err, null, page); try self._proto.dispatch(.load_end, null, page); } @@ -299,9 +386,10 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { }); } -fn stateChanged(self: *XMLHttpRequest, state: State, page: *Page) !void { +fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void { // there are more rules than this, but it's a start - std.debug.assert(state != self._state); + std.debug.assert(state != self._ready_state); + self._ready_state = state; const event = try Event.init("readystatechange", .{}, page); try page._event_manager.dispatchWithFunction( @@ -328,11 +416,6 @@ fn parseMethod(method: []const u8) !Http.Method { return error.InvalidMethod; } -const Response = union(enum) { - text: []const u8, - json: std.json.Value, -}; - pub const JsApi = struct { pub const bridge = js.Bridge(XMLHttpRequest); @@ -343,19 +426,27 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(XMLHttpRequest.init, .{}); - pub const UNSENT = bridge.property(@intFromEnum(XMLHttpRequest.State.unsent)); - pub const OPENED = bridge.property(@intFromEnum(XMLHttpRequest.State.opened)); - pub const HEADERS_RECEIVED = bridge.property(@intFromEnum(XMLHttpRequest.State.headers_received)); - pub const LOADING = bridge.property(@intFromEnum(XMLHttpRequest.State.loading)); - pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.State.done)); + pub const UNSENT = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.unsent)); + pub const OPENED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.opened)); + pub const HEADERS_RECEIVED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.headers_received)); + pub const LOADING = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.loading)); + pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done)); pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{}); pub const open = bridge.function(XMLHttpRequest.open, .{}); pub const send = bridge.function(XMLHttpRequest.send, .{}); pub const responseType = bridge.accessor(XMLHttpRequest.getResponseType, XMLHttpRequest.setResponseType, .{}); pub const status = bridge.accessor(XMLHttpRequest.getStatus, null, .{}); + pub const statusText = bridge.accessor(XMLHttpRequest.getStatusText, null, .{}); + pub const readyState = bridge.accessor(XMLHttpRequest.getReadyState, null, .{}); pub const response = bridge.accessor(XMLHttpRequest.getResponse, null, .{}); + pub const responseText = bridge.accessor(XMLHttpRequest.getResponseText, null, .{}); + pub const responseXML = bridge.accessor(XMLHttpRequest.getResponseXML, null, .{}); + pub const responseURL = bridge.accessor(XMLHttpRequest.getResponseURL, null, .{}); pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{}); + pub const getResponseHeader = bridge.function(XMLHttpRequest.getResponseHeader, .{}); + pub const getAllResponseHeaders = bridge.function(XMLHttpRequest.getAllResponseHeaders, .{}); + pub const abort = bridge.function(XMLHttpRequest.abort, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 5b1cf8628..0690d8980 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -170,6 +170,22 @@ const TestHTTPServer = struct { fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void { const path = req.head.target; + if (std.mem.eql(u8, path, "/xhr")) { + return req.respond("1234567890" ** 10, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/xhr/json")) { + return req.respond("{\"over\":\"9000!!!\"}", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/json" }, + }, + }); + } + // strip out leading '/' to make the path relative const file = try server.dir.openFile(path[1..], .{}); defer file.close(); diff --git a/src/testing.zig b/src/testing.zig index f3c1aeac3..256d9728e 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -407,7 +407,6 @@ fn runWebApiTest(test_file: [:0]const u8) !void { test_session.fetchWait(2000); page._session.browser.runMicrotasks(); - page._session.browser.runMessageLoop(); js_context.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const msg = try_catch.err(arena_allocator) catch @errorName(err) orelse "unknown"; @@ -508,12 +507,6 @@ fn serveCDP(wg: *std.Thread.WaitGroup) !void { fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; - if (std.mem.eql(u8, path, "/loader")) { - return req.respond("Hello!", .{ - .extra_headers = &.{.{ .name = "Connection", .value = "close" }}, - }); - } - if (std.mem.eql(u8, path, "/xhr")) { return req.respond("1234567890" ** 10, .{ .extra_headers = &.{ @@ -530,6 +523,33 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/xhr/redirect")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "http://127.0.0.1:9582/xhr" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/xhr/404")) { + return req.respond("Not Found", .{ + .status = .not_found, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/plain" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/xhr/500")) { + return req.respond("Internal Server Error", .{ + .status = .internal_server_error, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/plain" }, + }, + }); + } + if (std.mem.startsWith(u8, path, "/src/browser/tests/")) { // strip off leading / so that it's relative to CWD return TestHTTPServer.sendFile(req, path[1..]); From d26869278fa0a926cad830e741e872a70d1c0834 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 11:13:57 +0800 Subject: [PATCH 228/257] dummy HTMLCanvasElement --- src/browser/Page.zig | 6 + src/browser/js/Context.zig | 12 +- src/browser/js/bridge.zig | 1 + src/browser/tests/legacy/html/canvas.html | 16 -- src/browser/webapi/Element.zig | 4 + src/browser/webapi/element/Html.zig | 3 + src/browser/webapi/element/html/Canvas.zig | 225 +++++++++++++++++++++ 7 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 src/browser/webapi/element/html/Canvas.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0fd81e81a..2c2d77104 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1243,6 +1243,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined }, ), + asUint("canvas") => return self.createHtmlElementT( + Element.Html.Canvas, + namespace, + attribute_iterator, + .{ ._proto = undefined }, + ), asUint("dialog") => return self.createHtmlElementT( Element.Html.Dialog, namespace, diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 63a6d7515..dc297b212 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1122,8 +1122,18 @@ fn _debugValue(self: *const Context, js_val: v8.Value, seen: *std.AutoHashMapUnm if (depth > 20) { return writer.writeAll("...deeply nested object..."); } + const own_len = js_obj.getOwnPropertyNames(v8_context).length(); + if (own_len == 0) { + const js_val_str = try self.valueToString(js_val, .{}); + if (js_val_str.len > 2000) { + try writer.writeAll(js_val_str[0..2000]); + return writer.writeAll(" ... (truncated)"); + } + return writer.writeAll(js_val_str); + } - try writer.print("({d}/{d})", .{ js_obj.getOwnPropertyNames(v8_context).length(), js_obj.getPropertyNames(v8_context).length() }); + const all_len = js_obj.getPropertyNames(v8_context).length(); + try writer.print("({d}/{d})", .{ own_len, all_len }); for (0..len) |i| { if (i == 0) { try writer.writeByte('\n'); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4059d6a8e..d379ebf28 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -530,6 +530,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Body.zig"), @import("../webapi/element/html/BR.zig"), @import("../webapi/element/html/Button.zig"), + @import("../webapi/element/html/Canvas.zig"), @import("../webapi/element/html/Custom.zig"), @import("../webapi/element/html/Data.zig"), @import("../webapi/element/html/Dialog.zig"), diff --git a/src/browser/tests/legacy/html/canvas.html b/src/browser/tests/legacy/html/canvas.html index ab076487c..ed1980eb5 100644 --- a/src/browser/tests/legacy/html/canvas.html +++ b/src/browser/tests/legacy/html/canvas.html @@ -11,19 +11,3 @@ } - diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 97a2ecb4a..c47896ebd 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -172,6 +172,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .body => "body", .br => "br", .button => "button", + .canvas => "canvas", .custom => |e| e._tag_name.str(), .data => "data", .dialog => "dialog", @@ -224,6 +225,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .body => "BODY", .br => "BR", .button => "BUTTON", + .canvas => "CANVAS", .custom => |e| upperTagName(&e._tag_name, buf), .data => "DATA", .dialog => "DIALOG", @@ -1082,6 +1084,7 @@ pub fn getTag(self: *const Element) Tag { .img => .img, .br => .br, .button => .button, + .canvas => .canvas, .heading => |h| h._tag, .li => .li, .ul => .ul, @@ -1123,6 +1126,7 @@ pub const Tag = enum { body, br, button, + canvas, circle, custom, data, diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index fe88e96cb..8201a68ec 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -27,6 +27,7 @@ pub const Anchor = @import("html/Anchor.zig"); pub const Body = @import("html/Body.zig"); pub const BR = @import("html/BR.zig"); pub const Button = @import("html/Button.zig"); +pub const Canvas = @import("html/Canvas.zig"); pub const Custom = @import("html/Custom.zig"); pub const Data = @import("html/Data.zig"); pub const Dialog = @import("html/Dialog.zig"); @@ -74,6 +75,7 @@ pub const Type = union(enum) { body: *Body, br: *BR, button: *Button, + canvas: *Canvas, custom: *Custom, data: *Data, dialog: *Dialog, @@ -126,6 +128,7 @@ pub fn className(self: *const HtmlElement) []const u8 { .body => "[object HTMLBodyElement]", .br => "[object HTMLBRElement]", .button => "[object HTMLButtonElement]", + .canvas => "[object HTMLCanvasElement]", .custom => "[object CUSTOM-TODO]", .data => "[object HTMLDataElement]", .dialog => "[object HTMLDialogElement]", diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig new file mode 100644 index 000000000..eb60befda --- /dev/null +++ b/src/browser/webapi/element/html/Canvas.zig @@ -0,0 +1,225 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); + +pub fn registerTypes() []const type { + return &.{ + Canvas, + RenderingContext2D, + }; +} + +const Canvas = @This(); +_proto: *HtmlElement, + +pub const RenderingContext2D = struct { + pub fn save(_: *RenderingContext2D) void {} + pub fn restore(_: *RenderingContext2D) void {} + + pub fn scale(_: *RenderingContext2D, _: f64, _: f64) void {} + pub fn rotate(_: *RenderingContext2D, _: f64) void {} + pub fn translate(_: *RenderingContext2D, _: f64, _: f64) void {} + pub fn transform(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} + pub fn setTransform(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} + pub fn resetTransform(_: *RenderingContext2D) void {} + + pub fn getGlobalAlpha(_: *const RenderingContext2D) f64 { + return 1.0; + } + pub fn setGlobalAlpha(_: *RenderingContext2D, _: f64) void {} + pub fn getGlobalCompositeOperation(_: *const RenderingContext2D) []const u8 { + return "source-over"; + } + pub fn setGlobalCompositeOperation(_: *RenderingContext2D, _: []const u8) void {} + + pub fn getFillStyle(_: *const RenderingContext2D) []const u8 { + return "#000000"; + } + pub fn setFillStyle(_: *RenderingContext2D, _: []const u8) void {} + pub fn getStrokeStyle(_: *const RenderingContext2D) []const u8 { + return "#000000"; + } + pub fn setStrokeStyle(_: *RenderingContext2D, _: []const u8) void {} + + pub fn getLineWidth(_: *const RenderingContext2D) f64 { + return 1.0; + } + pub fn setLineWidth(_: *RenderingContext2D, _: f64) void {} + pub fn getLineCap(_: *const RenderingContext2D) []const u8 { + return "butt"; + } + pub fn setLineCap(_: *RenderingContext2D, _: []const u8) void {} + pub fn getLineJoin(_: *const RenderingContext2D) []const u8 { + return "miter"; + } + pub fn setLineJoin(_: *RenderingContext2D, _: []const u8) void {} + pub fn getMiterLimit(_: *const RenderingContext2D) f64 { + return 10.0; + } + pub fn setMiterLimit(_: *RenderingContext2D, _: f64) void {} + + pub fn clearRect(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} + pub fn fillRect(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} + pub fn strokeRect(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} + + pub fn beginPath(_: *RenderingContext2D) void {} + pub fn closePath(_: *RenderingContext2D) void {} + pub fn moveTo(_: *RenderingContext2D, _: f64, _: f64) void {} + pub fn lineTo(_: *RenderingContext2D, _: f64, _: f64) void {} + pub fn quadraticCurveTo(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} + pub fn bezierCurveTo(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} + pub fn arc(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {} + pub fn arcTo(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {} + pub fn rect(_: *RenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} + + pub fn fill(_: *RenderingContext2D) void {} + pub fn stroke(_: *RenderingContext2D) void {} + pub fn clip(_: *RenderingContext2D) void {} + + pub fn getFont(_: *const RenderingContext2D) []const u8 { + return "10px sans-serif"; + } + pub fn setFont(_: *RenderingContext2D, _: []const u8) void {} + pub fn getTextAlign(_: *const RenderingContext2D) []const u8 { + return "start"; + } + pub fn setTextAlign(_: *RenderingContext2D, _: []const u8) void {} + pub fn getTextBaseline(_: *const RenderingContext2D) []const u8 { + return "alphabetic"; + } + pub fn setTextBaseline(_: *RenderingContext2D, _: []const u8) void {} + pub fn fillText(_: *RenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} + pub fn strokeText(_: *RenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} + + pub const JsApi = struct { + pub const bridge = js.Bridge(RenderingContext2D); + + pub const Meta = struct { + pub const name = "CanvasRenderingContext2D"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const save = bridge.function(RenderingContext2D.save, .{}); + pub const restore = bridge.function(RenderingContext2D.restore, .{}); + + pub const scale = bridge.function(RenderingContext2D.scale, .{}); + pub const rotate = bridge.function(RenderingContext2D.rotate, .{}); + pub const translate = bridge.function(RenderingContext2D.translate, .{}); + pub const transform = bridge.function(RenderingContext2D.transform, .{}); + pub const setTransform = bridge.function(RenderingContext2D.setTransform, .{}); + pub const resetTransform = bridge.function(RenderingContext2D.resetTransform, .{}); + + pub const globalAlpha = bridge.accessor(RenderingContext2D.getGlobalAlpha, RenderingContext2D.setGlobalAlpha, .{}); + pub const globalCompositeOperation = bridge.accessor(RenderingContext2D.getGlobalCompositeOperation, RenderingContext2D.setGlobalCompositeOperation, .{}); + + pub const fillStyle = bridge.accessor(RenderingContext2D.getFillStyle, RenderingContext2D.setFillStyle, .{}); + pub const strokeStyle = bridge.accessor(RenderingContext2D.getStrokeStyle, RenderingContext2D.setStrokeStyle, .{}); + + pub const lineWidth = bridge.accessor(RenderingContext2D.getLineWidth, RenderingContext2D.setLineWidth, .{}); + pub const lineCap = bridge.accessor(RenderingContext2D.getLineCap, RenderingContext2D.setLineCap, .{}); + pub const lineJoin = bridge.accessor(RenderingContext2D.getLineJoin, RenderingContext2D.setLineJoin, .{}); + pub const miterLimit = bridge.accessor(RenderingContext2D.getMiterLimit, RenderingContext2D.setMiterLimit, .{}); + + pub const clearRect = bridge.function(RenderingContext2D.clearRect, .{}); + pub const fillRect = bridge.function(RenderingContext2D.fillRect, .{}); + pub const strokeRect = bridge.function(RenderingContext2D.strokeRect, .{}); + + pub const beginPath = bridge.function(RenderingContext2D.beginPath, .{}); + pub const closePath = bridge.function(RenderingContext2D.closePath, .{}); + pub const moveTo = bridge.function(RenderingContext2D.moveTo, .{}); + pub const lineTo = bridge.function(RenderingContext2D.lineTo, .{}); + pub const quadraticCurveTo = bridge.function(RenderingContext2D.quadraticCurveTo, .{}); + pub const bezierCurveTo = bridge.function(RenderingContext2D.bezierCurveTo, .{}); + pub const arc = bridge.function(RenderingContext2D.arc, .{}); + pub const arcTo = bridge.function(RenderingContext2D.arcTo, .{}); + pub const rect = bridge.function(RenderingContext2D.rect, .{}); + + pub const fill = bridge.function(RenderingContext2D.fill, .{}); + pub const stroke = bridge.function(RenderingContext2D.stroke, .{}); + pub const clip = bridge.function(RenderingContext2D.clip, .{}); + + pub const font = bridge.accessor(RenderingContext2D.getFont, RenderingContext2D.setFont, .{}); + pub const textAlign = bridge.accessor(RenderingContext2D.getTextAlign, RenderingContext2D.setTextAlign, .{}); + pub const textBaseline = bridge.accessor(RenderingContext2D.getTextBaseline, RenderingContext2D.setTextBaseline, .{}); + pub const fillText = bridge.function(RenderingContext2D.fillText, .{}); + pub const strokeText = bridge.function(RenderingContext2D.strokeText, .{}); + }; +}; + +pub fn asElement(self: *Canvas) *Element { + return self._proto._proto; +} +pub fn asConstElement(self: *const Canvas) *const Element { + return self._proto._proto; +} +pub fn asNode(self: *Canvas) *Node { + return self.asElement().asNode(); +} + +pub fn getWidth(self: *const Canvas) u32 { + const attr = self.asConstElement().getAttributeSafe("width") orelse return 300; + return std.fmt.parseUnsigned(u32, attr, 10) catch 300; +} + +pub fn setWidth(self: *Canvas, value: u32, page: *Page) !void { + const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); + try self.asElement().setAttributeSafe("width", str, page); +} + +pub fn getHeight(self: *const Canvas) u32 { + const attr = self.asConstElement().getAttributeSafe("height") orelse return 150; + return std.fmt.parseUnsigned(u32, attr, 10) catch 150; +} + +pub fn setHeight(self: *Canvas, value: u32, page: *Page) !void { + const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); + try self.asElement().setAttributeSafe("height", str, page); +} + +pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?*RenderingContext2D { + _ = self; + + if (!std.mem.eql(u8, context_type, "2d")) { + return null; + } + + const ctx = try page.arena.create(RenderingContext2D); + ctx.* = .{}; + return ctx; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Canvas); + + pub const Meta = struct { + pub const name = "HTMLCanvasElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const width = bridge.accessor(Canvas.getWidth, Canvas.setWidth, .{}); + pub const height = bridge.accessor(Canvas.getHeight, Canvas.setHeight, .{}); + pub const getContext = bridge.function(Canvas.getContext, .{}); +}; From ea399390ef286fcef20342db98fe164af320c7e0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 14:58:21 +0800 Subject: [PATCH 229/257] Improve DOMImplementation, DocumentType and DOMException --- src/browser/Page.zig | 29 +++ src/browser/dump.zig | 6 + src/browser/js/bridge.zig | 2 + src/browser/tests/domexception.html | 135 ++++++++++++++ src/browser/tests/domimplementation.html | 174 +++++++++++++++++- .../tests/legacy/dom/implementation.html | 2 +- src/browser/tests/node/replace_child.html | 2 +- src/browser/tests/processing_instruction.html | 171 +++++++++++++++++ src/browser/webapi/CData.zig | 16 ++ src/browser/webapi/DOMException.zig | 123 ++++++++++++- src/browser/webapi/DOMImplementation.zig | 64 ++++++- src/browser/webapi/Document.zig | 15 ++ src/browser/webapi/DocumentType.zig | 5 - src/browser/webapi/Element.zig | 2 + src/browser/webapi/Node.zig | 7 +- src/browser/webapi/XMLDocument.zig | 52 ++++++ .../webapi/cdata/ProcessingInstruction.zig | 47 +++++ 17 files changed, 832 insertions(+), 20 deletions(-) create mode 100644 src/browser/tests/domexception.html create mode 100644 src/browser/tests/processing_instruction.html create mode 100644 src/browser/webapi/XMLDocument.zig create mode 100644 src/browser/webapi/cdata/ProcessingInstruction.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 2c2d77104..843ca037b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1492,6 +1492,35 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node { return cd.asNode(); } +pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []const u8) !*Node { + // Validate target doesn't contain "?>" + if (std.mem.indexOf(u8, target, "?>") != null) { + return error.InvalidCharacterError; + } + + // Validate target follows XML name rules (similar to attribute name validation) + try Element.Attribute.validateAttributeName(target); + + const owned_target = try self.dupeString(target); + const owned_data = try self.dupeString(data); + + const pi = try self._factory.create(CData.ProcessingInstruction{ + ._proto = undefined, + ._target = owned_target, + }); + + const cd = try self._factory.node(CData{ + ._proto = undefined, + ._type = .{ .processing_instruction = pi }, + ._data = owned_data, + }); + + // Set up the back pointer from ProcessingInstruction to CData + pi._proto = cd; + + return cd.asNode(); +} + pub fn dupeString(self: *Page, value: []const u8) ![]const u8 { if (String.intern(value)) |v| { return v; diff --git a/src/browser/dump.zig b/src/browser/dump.zig index adcef5861..b1ca4b29c 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -74,6 +74,12 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri try writer.writeAll(""); + } else if (node.is(Node.CData.ProcessingInstruction)) |pi| { + try writer.writeAll(""); } else { if (shouldEscapeText(node._parent)) { try writeEscapedText(cd.getData(), writer); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index d379ebf28..a37097e17 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -492,6 +492,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/cdata/Comment.zig"), @import("../webapi/cdata/Text.zig"), @import("../webapi/cdata/CDATASection.zig"), + @import("../webapi/cdata/ProcessingInstruction.zig"), @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), @@ -506,6 +507,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/StyleSheetList.zig"), @import("../webapi/Document.zig"), @import("../webapi/HTMLDocument.zig"), + @import("../webapi/XMLDocument.zig"), @import("../webapi/History.zig"), @import("../webapi/KeyValueList.zig"), @import("../webapi/DocumentFragment.zig"), diff --git a/src/browser/tests/domexception.html b/src/browser/tests/domexception.html new file mode 100644 index 000000000..3f2825fd5 --- /dev/null +++ b/src/browser/tests/domexception.html @@ -0,0 +1,135 @@ + + + DOMException Test + + + + + + + + + + + + + + + + + + + + +
+ OK +
+ diff --git a/src/browser/tests/domimplementation.html b/src/browser/tests/domimplementation.html index 7ff1b0e08..d8980a8f3 100644 --- a/src/browser/tests/domimplementation.html +++ b/src/browser/tests/domimplementation.html @@ -55,7 +55,177 @@ const impl = document.implementation; const doctype = impl.createDocumentType('html', null, null); testing.expectEqual('html', doctype.name); - testing.expectEqual('', doctype.publicId); - testing.expectEqual('', doctype.systemId); + testing.expectEqual('null', doctype.publicId); + testing.expectEqual('null', doctype.systemId); + } + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/legacy/dom/implementation.html b/src/browser/tests/legacy/dom/implementation.html index 81cce8041..6dcc0838a 100644 --- a/src/browser/tests/legacy/dom/implementation.html +++ b/src/browser/tests/legacy/dom/implementation.html @@ -8,7 +8,7 @@ testing.expectEqual("[object HTMLDocument]", doc.toString()); testing.expectEqual("foo", doc.title); testing.expectEqual("[object HTMLBodyElement]", doc.body.toString()); - testing.expectEqual("[object Document]", impl.createDocument(null, 'foo').toString()); + testing.expectEqual("[object XMLDocument]", impl.createDocument(null, 'foo').toString()); testing.expectEqual("[object DocumentType]", impl.createDocumentType('foo', 'bar', 'baz').toString()); testing.expectEqual(true, impl.hasFeature()); diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html index deea60a8b..18832fd94 100644 --- a/src/browser/tests/node/replace_child.html +++ b/src/browser/tests/node/replace_child.html @@ -24,7 +24,7 @@ testing.withError((err) => { testing.expectEqual(3, err.code); - testing.expectEqual("HierarchyError", err.name); + testing.expectEqual("HierarchyRequestError", err.name); testing.expectEqual("Hierarchy Error", err.message); }, () => d1.replaceChild(c4, c3)); diff --git a/src/browser/tests/processing_instruction.html b/src/browser/tests/processing_instruction.html new file mode 100644 index 000000000..9a6ef1199 --- /dev/null +++ b/src/browser/tests/processing_instruction.html @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 82afee542..c7ac88ccd 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -25,6 +25,7 @@ const Node = @import("Node.zig"); pub const Text = @import("cdata/Text.zig"); pub const Comment = @import("cdata/Comment.zig"); pub const CDATASection = @import("cdata/CDATASection.zig"); +pub const ProcessingInstruction = @import("cdata/ProcessingInstruction.zig"); const CData = @This(); @@ -38,6 +39,7 @@ pub const Type = union(enum) { // This should be under Text, but that would require storing a _type union // in text, which would add 8 bytes to every text node. cdata_section: CDATASection, + processing_instruction: *ProcessingInstruction, }; pub fn asNode(self: *CData) *Node { @@ -58,6 +60,7 @@ pub fn className(self: *const CData) []const u8 { .text => "[object Text]", .comment => "[object Comment]", .cdata_section => "[object CDATASection]", + .processing_instruction => "[object ProcessingInstruction]", }; } @@ -139,6 +142,7 @@ pub fn format(self: *const CData, writer: *std.io.Writer) !void { .text => writer.print("{s}", .{self._data}), .comment => writer.print("", .{self._data}), .cdata_section => writer.print("", .{self._data}), + .processing_instruction => |pi| writer.print("", .{ pi._target, self._data }), }; } @@ -147,6 +151,18 @@ pub fn getLength(self: *const CData) usize { } pub fn isEqualNode(self: *const CData, other: *const CData) bool { + if (std.meta.activeTag(self._type) != std.meta.activeTag(other._type)) { + return false; + } + + if (self._type == .processing_instruction) { + @branchHint(.unlikely); + if (std.mem.eql(u8, self._type.processing_instruction._target, other._type.processing_instruction._target) == false) { + return false; + } + // if the _targets are equal, we still want to compare the data + } + return std.mem.eql(u8, self.getData(), other.getData()); } diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 7ae241d2d..f25587524 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -16,14 +16,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const DOMException = @This(); + _code: Code = .none, +_custom_message: ?[]const u8 = null, +_custom_name: ?[]const u8 = null, -pub fn init() DOMException { - return .{}; +pub fn init(message: ?[]const u8, name: ?[]const u8) DOMException { + // If name is provided, try to map it to a legacy code + const code = if (name) |n| Code.fromName(n) else .none; + return .{ + ._code = code, + ._custom_message = message, + ._custom_name = name, + }; } pub fn fromError(err: anyerror) ?DOMException { @@ -34,6 +44,21 @@ pub fn fromError(err: anyerror) ?DOMException { error.NotSupported => .{ ._code = .not_supported }, error.HierarchyError => .{ ._code = .hierarchy_error }, error.IndexSizeError => .{ ._code = .index_size_error }, + error.InvalidStateError => .{ ._code = .invalid_state_error }, + error.WrongDocument => .{ ._code = .wrong_document_error }, + error.NoModificationAllowed => .{ ._code = .no_modification_allowed_error }, + error.InUseAttribute => .{ ._code = .inuse_attribute_error }, + error.InvalidModification => .{ ._code = .invalid_modification_error }, + error.NamespaceError => .{ ._code = .namespace_error }, + error.InvalidAccess => .{ ._code = .invalid_access_error }, + error.SecurityError => .{ ._code = .security_error }, + error.NetworkError => .{ ._code = .network_error }, + error.AbortError => .{ ._code = .abort_error }, + error.URLMismatch => .{ ._code = .url_mismatch_error }, + error.QuotaExceeded => .{ ._code = .quota_exceeded_error }, + error.TimeoutError => .{ ._code = .timeout_error }, + error.InvalidNodeType => .{ ._code = .invalid_node_type_error }, + error.DataClone => .{ ._code = .data_clone_error }, else => null, }; } @@ -43,18 +68,40 @@ pub fn getCode(self: *const DOMException) u8 { } pub fn getName(self: *const DOMException) []const u8 { + if (self._custom_name) |name| { + return name; + } + return switch (self._code) { .none => "Error", + .index_size_error => "IndexSizeError", + .hierarchy_error => "HierarchyRequestError", + .wrong_document_error => "WrongDocumentError", .invalid_character_error => "InvalidCharacterError", - .index_size_error => "IndexSizeErorr", - .syntax_error => "SyntaxError", + .no_modification_allowed_error => "NoModificationAllowedError", .not_found => "NotFoundError", .not_supported => "NotSupportedError", - .hierarchy_error => "HierarchyError", + .inuse_attribute_error => "InUseAttributeError", + .invalid_state_error => "InvalidStateError", + .syntax_error => "SyntaxError", + .invalid_modification_error => "InvalidModificationError", + .namespace_error => "NamespaceError", + .invalid_access_error => "InvalidAccessError", + .security_error => "SecurityError", + .network_error => "NetworkError", + .abort_error => "AbortError", + .url_mismatch_error => "URLMismatchError", + .quota_exceeded_error => "QuotaExceededError", + .timeout_error => "TimeoutError", + .invalid_node_type_error => "InvalidNodeTypeError", + .data_clone_error => "DataCloneError", }; } pub fn getMessage(self: *const DOMException) []const u8 { + if (self._custom_message) |msg| { + return msg; + } return switch (self._code) { .none => "", .invalid_character_error => "Error: Invalid Character", @@ -63,17 +110,76 @@ pub fn getMessage(self: *const DOMException) []const u8 { .not_supported => "Not Supported", .not_found => "Not Found", .hierarchy_error => "Hierarchy Error", + else => @tagName(self._code), }; } +pub fn toString(self: *const DOMException) []const u8 { + if (self._custom_message) |msg| { + return msg; + } + return switch (self._code) { + .none => "Error", + else => self.getMessage(), + }; +} + +pub fn className(_: *const DOMException) []const u8 { + return "[object DOMException]"; +} + const Code = enum(u8) { none = 0, index_size_error = 1, hierarchy_error = 3, + wrong_document_error = 4, invalid_character_error = 5, + no_modification_allowed_error = 7, not_found = 8, not_supported = 9, + inuse_attribute_error = 10, + invalid_state_error = 11, syntax_error = 12, + invalid_modification_error = 13, + namespace_error = 14, + invalid_access_error = 15, + security_error = 18, + network_error = 19, + abort_error = 20, + url_mismatch_error = 21, + quota_exceeded_error = 22, + timeout_error = 23, + invalid_node_type_error = 24, + data_clone_error = 25, + + /// Maps a standard error name to its legacy code + /// Returns .none (code 0) for non-legacy error names + pub fn fromName(name: []const u8) Code { + const lookup = std.StaticStringMap(Code).initComptime(.{ + .{ "IndexSizeError", .index_size_error }, + .{ "HierarchyRequestError", .hierarchy_error }, + .{ "WrongDocumentError", .wrong_document_error }, + .{ "InvalidCharacterError", .invalid_character_error }, + .{ "NoModificationAllowedError", .no_modification_allowed_error }, + .{ "NotFoundError", .not_found }, + .{ "NotSupportedError", .not_supported }, + .{ "InUseAttributeError", .inuse_attribute_error }, + .{ "InvalidStateError", .invalid_state_error }, + .{ "SyntaxError", .syntax_error }, + .{ "InvalidModificationError", .invalid_modification_error }, + .{ "NamespaceError", .namespace_error }, + .{ "InvalidAccessError", .invalid_access_error }, + .{ "SecurityError", .security_error }, + .{ "NetworkError", .network_error }, + .{ "AbortError", .abort_error }, + .{ "URLMismatchError", .url_mismatch_error }, + .{ "QuotaExceededError", .quota_exceeded_error }, + .{ "TimeoutError", .timeout_error }, + .{ "InvalidNodeTypeError", .invalid_node_type_error }, + .{ "DataCloneError", .data_clone_error }, + }); + return lookup.get(name) orelse .none; + } }; pub const JsApi = struct { @@ -89,5 +195,10 @@ pub const JsApi = struct { pub const code = bridge.accessor(DOMException.getCode, null, .{}); pub const name = bridge.accessor(DOMException.getName, null, .{}); pub const message = bridge.accessor(DOMException.getMessage, null, .{}); - pub const toString = bridge.function(DOMException.getMessage, .{}); + pub const toString = bridge.function(DOMException.toString, .{}); }; + +const testing = @import("../../testing.zig"); +test "WebApi: DOMException" { + try testing.htmlRunner("domexception.html", .{}); +} diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index e2a863571..467b5ae23 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -21,14 +21,17 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); +const Document = @import("Document.zig"); +const HTMLDocument = @import("HTMLDocument.zig"); const DocumentType = @import("DocumentType.zig"); const DOMImplementation = @This(); pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, page: *Page) !*DocumentType { const name = try page.dupeString(qualified_name); - const pub_id = try page.dupeString(public_id orelse ""); - const sys_id = try page.dupeString(system_id orelse ""); + // Firefox converts null to the string "null", not empty string + const pub_id = if (public_id) |p| try page.dupeString(p) else "null"; + const sys_id = if (system_id) |s| try page.dupeString(s) else "null"; const doctype = try page._factory.node(DocumentType{ ._proto = undefined, @@ -40,7 +43,60 @@ pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u return doctype; } -pub fn hasFeature(_: *const DOMImplementation, _: []const u8, _: ?[]const u8) bool { +pub fn createHTMLDocument(_: *const DOMImplementation, title: ?[]const u8, page: *Page) !*Document { + const document = (try page._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument(); + document._ready_state = .complete; + + { + const doctype = try page._factory.node(DocumentType{ + ._proto = undefined, + ._name = "html", + ._public_id = "", + ._system_id = "", + }); + _ = try document.asNode().appendChild(doctype.asNode(), page); + } + + const html_node = try page.createElement(null, "html", null); + _ = try document.asNode().appendChild(html_node, page); + + const head_node = try page.createElement(null, "head", null); + _ = try html_node.appendChild(head_node, page); + + if (title) |t| { + const title_node = try page.createElement(null, "title", null); + _ = try head_node.appendChild(title_node, page); + const text_node = try page.createTextNode(t); + _ = try title_node.appendChild(text_node, page); + } + + const body_node = try page.createElement(null, "body", null); + _ = try html_node.appendChild(body_node, page); + + return document; +} + +pub fn createDocument(_: *const DOMImplementation, namespace: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, page: *Page) !*Document { + // Create XML Document + const document = (try page._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument(); + + // Append doctype if provided + if (doctype) |dt| { + _ = try document.asNode().appendChild(dt.asNode(), page); + } + + // Create and append root element if qualified_name provided + if (qualified_name) |qname| { + if (qname.len > 0) { + const root = try page.createElement(namespace, qname, null); + _ = try document.asNode().appendChild(root, page); + } + } + + return document; +} + +pub fn hasFeature(_: *const DOMImplementation, _: ?[]const u8, _: ?[]const u8) bool { // Modern DOM spec says this should always return true // This method is deprecated and kept for compatibility only return true; @@ -61,6 +117,8 @@ pub const JsApi = struct { }; pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{ .dom_exception = true }); + pub const createDocument = bridge.function(DOMImplementation.createDocument, .{}); + pub const createHTMLDocument = bridge.function(DOMImplementation.createHTMLDocument, .{}); pub const hasFeature = bridge.function(DOMImplementation.hasFeature, .{}); pub const toString = bridge.function(_toString, .{}); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 87019a549..5f3fab951 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -35,6 +35,7 @@ const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); pub const HTMLDocument = @import("HTMLDocument.zig"); +pub const XMLDocument = @import("XMLDocument.zig"); const Document = @This(); @@ -50,6 +51,7 @@ _style_sheets: ?*StyleSheetList = null, pub const Type = union(enum) { generic, html: *HTMLDocument, + xml: *XMLDocument, }; pub fn is(self: *Document, comptime T: type) ?*T { @@ -59,6 +61,11 @@ pub fn is(self: *Document, comptime T: type) ?*T { return html; } }, + .xml => |xml| { + if (T == XMLDocument) { + return xml; + } + }, .generic => {}, } return null; @@ -83,6 +90,7 @@ pub fn getURL(_: *const Document, page: *const Page) [:0]const u8 { pub fn getContentType(self: *const Document) []const u8 { return switch (self._type) { .html => "text/html", + .xml => "application/xml", .generic => "application/xml", }; } @@ -217,6 +225,7 @@ pub fn className(self: *const Document) []const u8 { return switch (self._type) { .generic => "[object Document]", .html => "[object HTMLDocument]", + .xml => "[object XMLDocument]", }; } @@ -239,10 +248,15 @@ pub fn createTextNode(_: *const Document, data: []const u8, page: *Page) !*Node pub fn createCDATASection(self: *const Document, data: []const u8, page: *Page) !*Node { switch (self._type) { .html => return error.NotSupported, + .xml => return page.createCDATASection(data), .generic => return page.createCDATASection(data), } } +pub fn createProcessingInstruction(_: *const Document, target: []const u8, data: []const u8, page: *Page) !*Node { + return page.createProcessingInstruction(target, data); +} + const Range = @import("Range.zig"); pub fn createRange(_: *const Document, page: *Page) !*Range { return Range.init(page); @@ -432,6 +446,7 @@ pub const JsApi = struct { pub const createTextNode = bridge.function(Document.createTextNode, .{}); pub const createAttribute = bridge.function(Document.createAttribute, .{ .dom_exception = true }); pub const createCDATASection = bridge.function(Document.createCDATASection, .{ .dom_exception = true }); + pub const createProcessingInstruction = bridge.function(Document.createProcessingInstruction, .{ .dom_exception = true }); pub const createRange = bridge.function(Document.createRange, .{}); pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true }); pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{}); diff --git a/src/browser/webapi/DocumentType.zig b/src/browser/webapi/DocumentType.zig index aab8052eb..0bced6806 100644 --- a/src/browser/webapi/DocumentType.zig +++ b/src/browser/webapi/DocumentType.zig @@ -71,8 +71,3 @@ pub const JsApi = struct { return self.className(); } }; - -const testing = @import("../../testing.zig"); -test "WebApi: DOMImplementation" { - try testing.htmlRunner("domimplementation.html", .{}); -} diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c47896ebd..0e68d7fd4 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -332,6 +332,8 @@ fn _getInnerText(self: *Element, writer: *std.Io.Writer, state: *innerTextState) // CDATA sections should not be used within HTML. They are // considered comments and are not displayed. .cdata_section => {}, + // Processing instructions are not displayed in innerText + .processing_instruction => {}, }, .document => {}, .document_type => {}, diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index e27ca5052..1495cb094 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -229,8 +229,8 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo .element => { var it = self.childrenIterator(); while (it.next()) |child| { - // ignore comments and TODO processing instructions. - if (child.is(CData.Comment) != null) { + // ignore comments and processing instructions. + if (child.is(CData.Comment) != null or child.is(CData.ProcessingInstruction) != null) { continue; } try child.getTextContent(writer); @@ -270,6 +270,7 @@ pub fn getNodeName(self: *const Node, buf: []u8) []const u8 { .text => "#text", .cdata_section => "#cdata-section", .comment => "#comment", + .processing_instruction => |pi| pi._target, }, .document => "#document", .document_type => |dt| dt.getName(), @@ -285,6 +286,7 @@ pub fn getNodeType(self: *const Node) u8 { .cdata => |cd| switch (cd._type) { .text => 3, .cdata_section => 4, + .processing_instruction => 7, .comment => 8, }, .document => 9, @@ -603,6 +605,7 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, Str .text => page.createTextNode(data), .cdata_section => page.createCDATASection(data), .comment => page.createComment(data), + .processing_instruction => |pi| page.createProcessingInstruction(pi._target, data), }; }, .element => |el| return el.cloneElement(deep, page), diff --git a/src/browser/webapi/XMLDocument.zig b/src/browser/webapi/XMLDocument.zig new file mode 100644 index 000000000..437e4fa4a --- /dev/null +++ b/src/browser/webapi/XMLDocument.zig @@ -0,0 +1,52 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const Document = @import("Document.zig"); +const Node = @import("Node.zig"); + +const XMLDocument = @This(); + +_proto: *Document, + +pub fn asDocument(self: *XMLDocument) *Document { + return self._proto; +} + +pub fn asNode(self: *XMLDocument) *Node { + return self._proto.asNode(); +} + +pub fn asEventTarget(self: *XMLDocument) *@import("EventTarget.zig") { + return self._proto.asEventTarget(); +} + +pub fn className(_: *const XMLDocument) []const u8 { + return "[object XMLDocument]"; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(XMLDocument); + + pub const Meta = struct { + pub const name = "XMLDocument"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/cdata/ProcessingInstruction.zig b/src/browser/webapi/cdata/ProcessingInstruction.zig new file mode 100644 index 000000000..97024d4ed --- /dev/null +++ b/src/browser/webapi/cdata/ProcessingInstruction.zig @@ -0,0 +1,47 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); + +const CData = @import("../CData.zig"); + +const ProcessingInstruction = @This(); + +_proto: *CData, +_target: []const u8, + +pub fn getTarget(self: *const ProcessingInstruction) []const u8 { + return self._target; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ProcessingInstruction); + + pub const Meta = struct { + pub const name = "ProcessingInstruction"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const target = bridge.accessor(ProcessingInstruction.getTarget, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: ProcessingInstruction" { + try testing.htmlRunner("processing_instruction.html", .{}); +} From e47091f9a144b371eded522b1a495dd2ee8c445c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 16:24:49 +0800 Subject: [PATCH 230/257] legacy for request/response/fetch --- src/browser/js/Context.zig | 6 +++ src/browser/tests/legacy/fetch/fetch.html | 6 +-- src/browser/tests/legacy/fetch/request.html | 3 +- src/browser/tests/legacy/fetch/response.html | 5 +- src/browser/tests/net/request.html | 22 +++++++++ src/browser/tests/net/response.html | 50 ++++++++++++++++++++ src/browser/webapi/net/Request.zig | 33 +++++++++++++ src/browser/webapi/net/Response.zig | 33 +++++++++++-- 8 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 src/browser/tests/net/response.html diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index dc297b212..1273a4b78 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -807,6 +807,12 @@ pub fn jsValueToZig(self: *Context, comptime T: type, js_value: v8.Value) !T { unreachable; }, .@"enum" => |e| { + if (@hasDecl(T, "js_enum_from_string")) { + if (!js_value.isString()) { + return error.InvalidArgument; + } + return std.meta.stringToEnum(T, try self.valueToString(js_value, .{})) orelse return error.InvalidArgument; + } switch (@typeInfo(e.tag_type)) { .int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)), else => @compileError("unsupported enum parameter type: " ++ @typeName(T)), diff --git a/src/browser/tests/legacy/fetch/fetch.html b/src/browser/tests/legacy/fetch/fetch.html index 877f887b6..5e4a46e0c 100644 --- a/src/browser/tests/legacy/fetch/fetch.html +++ b/src/browser/tests/legacy/fetch/fetch.html @@ -1,9 +1,9 @@ + diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html new file mode 100644 index 000000000..c632c3ff0 --- /dev/null +++ b/src/browser/tests/net/response.html @@ -0,0 +1,50 @@ + + + + + + diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index d053b4347..235773d76 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -33,6 +33,8 @@ _method: Http.Method, _headers: ?*Headers, _body: ?[]const u8, _arena: Allocator, +_cache: Cache, +_credentials: Credentials, pub const Input = union(enum) { request: *Request, @@ -43,6 +45,25 @@ pub const InitOpts = struct { method: ?[]const u8 = null, headers: ?Headers.InitOpts = null, body: ?[]const u8 = null, + cache: Cache = .default, + credentials: Credentials = .@"same-origin", +}; + +const Credentials = enum { + omit, + include, + @"same-origin", + pub const js_enum_from_string = true; +}; + +const Cache = enum { + default, + @"no-store", + @"reload", + @"no-cache", + @"force-cache", + @"only-if-cached", + pub const js_enum_from_string = true; }; pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { @@ -80,6 +101,8 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { ._arena = arena, ._method = method, ._headers = headers, + ._cache = opts.cache, + ._credentials = opts.credentials, ._body = body, }); } @@ -111,6 +134,14 @@ pub fn getMethod(self: *const Request) []const u8 { return @tagName(self._method); } +pub fn getCache(self: *const Request) []const u8 { + return @tagName(self._cache); +} + +pub fn getCredentials(self: *const Request) []const u8 { + return @tagName(self._credentials); +} + pub fn getHeaders(self: *Request, page: *Page) !*Headers { if (self._headers) |headers| { return headers; @@ -134,6 +165,8 @@ pub const JsApi = struct { pub const url = bridge.accessor(Request.getUrl, null, .{}); pub const method = bridge.accessor(Request.getMethod, null, .{}); pub const headers = bridge.accessor(Request.getHeaders, null, .{}); + pub const cache = bridge.accessor(Request.getCache, null, .{}); + pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index dffa4c606..68dd8ed71 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -39,10 +39,11 @@ _arena: Allocator, _headers: *Headers, _body: ?[]const u8, _type: Type, +_status_text: []const u8, const InitOpts = struct { status: u16 = 200, - headers: ?*Headers = null, + headers: ?Headers.InitOpts = null, statusText: ?[]const u8 = null, }; @@ -51,13 +52,15 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { // Store empty string as empty string, not null const body = if (body_) |b| try page.arena.dupe(u8, b) else null; + const status_text = if (opts.statusText) |st| try page.dupeString(st) else ""; return page._factory.create(Response{ ._arena = page.arena, ._status = opts.status, + ._status_text = status_text, ._body = body, - ._headers = opts.headers orelse try Headers.init(null, page), - ._type = .basic, // @ZIGDOM: todo + ._type = .basic, + ._headers = try Headers.init(opts.headers, page), }); } @@ -65,6 +68,21 @@ pub fn getStatus(self: *const Response) u16 { return self._status; } +pub fn getStatusText(self: *const Response) []const u8 { + // This property is meant to actually capture the response status text, not + // just return the text representation of self._status. If we do, + // new Response(null, {status: 200}).statusText, we should get empty string. + return self._status_text; +} + +pub fn getURL(_: *const Response) []const u8 { + return ""; +} + +pub fn isRedirected(_: *const Response) bool { + return false; +} + pub fn getHeaders(self: *const Response) *Headers { return self._headers; } @@ -90,6 +108,7 @@ pub fn isOK(self: *const Response) bool { return self._status >= 200 and self._status <= 299; } + pub fn getText(self: *const Response, page: *Page) !js.Promise { const body = self._body orelse ""; return page.js.resolvePromise(body); @@ -120,9 +139,17 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Response.init, .{}); pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); + pub const statusText = bridge.accessor(Response.getStatusText, null, .{}); pub const @"type" = bridge.accessor(Response.getType, null, .{}); pub const text = bridge.function(Response.getText, .{}); pub const json = bridge.function(Response.getJson, .{}); pub const headers = bridge.accessor(Response.getHeaders, null, .{}); pub const body = bridge.accessor(Response.getBody, null, .{}); + pub const url = bridge.accessor(Response.getURL, null, .{}); + pub const redirected = bridge.accessor(Response.isRedirected, null, .{}); }; + +const testing = @import("../../../testing.zig"); +test "WebApi: Response" { + try testing.htmlRunner("net/response.html", .{}); +} From 8a2641d213ef8358401da39cfa25d55ca8f9b86b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 17:54:05 +0800 Subject: [PATCH 231/257] fetch/request/response improvement (legacy) --- src/browser/Page.zig | 2 +- src/browser/URL.zig | 73 ++++++-- src/browser/tests/legacy/fetch/fetch.html | 2 +- src/browser/tests/net/fetch.html | 196 ++++++++++++++++++++++ src/browser/webapi/net/Fetch.zig | 33 +++- src/browser/webapi/net/Response.zig | 13 +- src/http/Client.zig | 6 + 7 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 src/browser/tests/net/fetch.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 843ca037b..400f1ba00 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2006,7 +2006,7 @@ const IdleNotification = union(enum) { pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { const URLRaw = @import("URL.zig"); - const current_origin = (try URLRaw.getOrigin(self.arena, self.url)) orelse return false; + const current_origin = (try URLRaw.getOrigin(self.call_arena, self.url)) orelse return false; return std.mem.startsWith(u8, url, current_origin); } diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 7a3547c24..c105ae9e9 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -240,29 +240,66 @@ pub fn getHash(raw: [:0]const u8) []const u8 { } pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 { - const port = getPort(raw); - const protocol = getProtocol(raw); - const hostname = getHostname(raw); + const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; - const p = std.meta.stringToEnum(KnownProtocol, getProtocol(raw)) orelse return null; + // Only HTTP and HTTPS schemes have origins + const protocol = raw[0..scheme_end + 1]; + if (!std.mem.eql(u8, protocol, "http:") and !std.mem.eql(u8, protocol, "https:")) { + return null; + } - const include_port = blk: { - if (port.len == 0) { - break :blk false; - } - if (p == .@"https:" and std.mem.eql(u8, port, "443")) { - break :blk false; - } - if (p == .@"http:" and std.mem.eql(u8, port, "80")) { - break :blk false; - } + var authority_start = scheme_end + 3; + const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: { + authority_start += pos + 1; break :blk true; - }; + } else false; + + // Find end of authority (start of path/query/fragment or end of string) + const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#"); + const authority_end = if (authority_end_relative) |end| + authority_start + end + else + raw.len; + + // Check for port in the host:port section + const host_part = raw[authority_start..authority_end]; + if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| { + const port = host_part[colon_pos_in_host + 1..]; + + // Validate it's actually a port (all digits) + for (port) |c| { + if (c < '0' or c > '9') { + // Not a port (probably IPv6) + if (has_user_info) { + // Need to allocate to exclude user info + return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ raw[0 .. scheme_end + 1], host_part }); + } + // Can return a slice + return raw[0..authority_end]; + } + } + + // Check if it's a default port that should be excluded from origin + const is_default = + (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80")) or + (std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")); - if (include_port) { - return try std.fmt.allocPrint(allocator, "{s}//{s}:{s}", .{ protocol, hostname, port }); + if (is_default or has_user_info) { + // Need to allocate to build origin without default port and/or user info + const hostname = host_part[0..colon_pos_in_host]; + if (is_default) { + return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ protocol, hostname }); + } else { + return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ protocol, host_part }); + } + } + } else if (has_user_info) { + // No port, but has user info - need to allocate + return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ raw[0 .. scheme_end + 1], host_part }); } - return try std.fmt.allocPrint(allocator, "{s}//{s}", .{ protocol, hostname }); + + // Common case: no user info, no default port - return slice (zero allocation!) + return raw[0..authority_end]; } fn getUserInfo(raw: [:0]const u8) ?[]const u8 { diff --git a/src/browser/tests/legacy/fetch/fetch.html b/src/browser/tests/legacy/fetch/fetch.html index 5e4a46e0c..d66498605 100644 --- a/src/browser/tests/legacy/fetch/fetch.html +++ b/src/browser/tests/legacy/fetch/fetch.html @@ -3,7 +3,7 @@ const promise1 = new Promise((resolve) => { fetch('http://127.0.0.1:9589/xhr/json') .then((res) => { - testing.expectEqual('basic', res.type); + testing.expectEqual('cors', res.type); return res.json() }) .then((json) => { diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html new file mode 100644 index 000000000..a81599590 --- /dev/null +++ b/src/browser/tests/net/fetch.html @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 9f708e0a2..312bb9f38 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -23,6 +23,7 @@ const Http = @import("../../../http/Http.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const URL = @import("../../URL.zig"); const Headers = @import("Headers.zig"); const Request = @import("Request.zig"); @@ -96,7 +97,30 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { } const res = self._response; - res._status = transfer.response_header.?.status; + const header = transfer.response_header.?; + + res._status = header.status; + res._url = try self._page.arena.dupeZ(u8, std.mem.span(header.url)); + res._is_redirected = header.redirect_count > 0; + + // Determine response type based on origin comparison + const page_origin = URL.getOrigin(self._page.call_arena, self._page.url) catch null; + const response_origin = URL.getOrigin(self._page.call_arena, res._url) catch null; + + if (page_origin) |po| { + if (response_origin) |ro| { + if (std.mem.eql(u8, po, ro)) { + res._type = .basic; // Same-origin + } else { + res._type = .cors; // Cross-origin (for simplicity, assume CORS passed) + } + } else { + res._type = .basic; + } + } else { + res._type = .basic; + } + var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { try res._headers.append(hdr.name, hdr.value, self._page); @@ -116,5 +140,12 @@ fn httpDoneCallback(ctx: *anyopaque) !void { fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { const self: *Fetch = @ptrCast(@alignCast(ctx)); + self._response._type = .@"error"; // Set type to error for network failures self._resolver.reject("fetch error", @errorName(err)); } + + +const testing = @import("../../../testing.zig"); +test "WebApi: fetch" { + try testing.htmlRunner("net/fetch.html", .{}); +} diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 68dd8ed71..561a23efa 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -40,6 +40,8 @@ _headers: *Headers, _body: ?[]const u8, _type: Type, _status_text: []const u8, +_url: [:0]const u8, +_is_redirected: bool, const InitOpts = struct { status: u16 = 200, @@ -58,8 +60,10 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { ._arena = page.arena, ._status = opts.status, ._status_text = status_text, + ._url = "", ._body = body, ._type = .basic, + ._is_redirected = false, ._headers = try Headers.init(opts.headers, page), }); } @@ -69,18 +73,19 @@ pub fn getStatus(self: *const Response) u16 { } pub fn getStatusText(self: *const Response) []const u8 { + // @TODO // This property is meant to actually capture the response status text, not // just return the text representation of self._status. If we do, // new Response(null, {status: 200}).statusText, we should get empty string. return self._status_text; } -pub fn getURL(_: *const Response) []const u8 { - return ""; +pub fn getURL(self: *const Response) []const u8 { + return self._url; } -pub fn isRedirected(_: *const Response) bool { - return false; +pub fn isRedirected(self: *const Response) bool { + return self._is_redirected; } pub fn getHeaders(self: *const Response) *Headers { diff --git a/src/http/Client.zig b/src/http/Client.zig index f59ad0a02..e9812cf4b 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -781,9 +781,13 @@ pub const Transfer = struct { try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_RESPONSE_CODE, &status)); } + var redirect_count: c_long = undefined; + try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_REDIRECT_COUNT, &redirect_count)); + self.response_header = .{ .url = url, .status = @intCast(status), + .redirect_count = @intCast(redirect_count), }; if (getResponseHeader(easy, "content-type", 0)) |ct| { @@ -1122,6 +1126,7 @@ pub const Transfer = struct { transfer.response_header = .{ .status = status, .url = req.url, + .redirect_count = 0, ._injected_headers = headers, }; for (headers) |hdr| { @@ -1177,6 +1182,7 @@ pub const ResponseHeader = struct { status: u16, url: [*c]const u8, + redirect_count: u32, _content_type_len: usize = 0, _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, // this is normally an empty list, but if the response is being injected From 761b35b19966a58b9a0cbbc803abb8a62ab3d362 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 17:54:14 +0800 Subject: [PATCH 232/257] zig fmt --- src/browser/URL.zig | 4 ++-- src/browser/webapi/net/Fetch.zig | 1 - src/browser/webapi/net/Request.zig | 2 +- src/browser/webapi/net/Response.zig | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index c105ae9e9..ba5d02de7 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -243,7 +243,7 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 { const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; // Only HTTP and HTTPS schemes have origins - const protocol = raw[0..scheme_end + 1]; + const protocol = raw[0 .. scheme_end + 1]; if (!std.mem.eql(u8, protocol, "http:") and !std.mem.eql(u8, protocol, "https:")) { return null; } @@ -264,7 +264,7 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 { // Check for port in the host:port section const host_part = raw[authority_start..authority_end]; if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| { - const port = host_part[colon_pos_in_host + 1..]; + const port = host_part[colon_pos_in_host + 1 ..]; // Validate it's actually a port (all digits) for (port) |c| { diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 312bb9f38..58fc9e8f9 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -144,7 +144,6 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { self._resolver.reject("fetch error", @errorName(err)); } - const testing = @import("../../../testing.zig"); test "WebApi: fetch" { try testing.htmlRunner("net/fetch.html", .{}); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 235773d76..13c7a977a 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -59,7 +59,7 @@ const Credentials = enum { const Cache = enum { default, @"no-store", - @"reload", + reload, @"no-cache", @"force-cache", @"only-if-cached", diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 561a23efa..3e423691c 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -113,7 +113,6 @@ pub fn isOK(self: *const Response) bool { return self._status >= 200 and self._status <= 299; } - pub fn getText(self: *const Response, page: *Page) !js.Promise { const body = self._body orelse ""; return page.js.resolvePromise(body); From 8873e613d207b142c404416a814517898b307e6b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Dec 2025 19:16:42 +0800 Subject: [PATCH 233/257] improve domexception --- src/browser/js/ExecutionWorld.zig | 23 +++++++--------- .../tests/document/query_selector.html | 2 +- .../tests/document/query_selector_all.html | 2 +- src/browser/tests/domexception.html | 9 +++---- src/browser/tests/element/matches.html | 2 +- src/browser/tests/element/query_selector.html | 2 +- .../tests/element/query_selector_all.html | 2 +- .../tests/element/selector_invalid.html | 4 +-- src/browser/tests/legacy/dom/exceptions.html | 5 ++-- src/browser/webapi/DOMException.zig | 25 +++++++++-------- src/browser/webapi/Node.zig | 27 ++++++++++++++----- 11 files changed, 56 insertions(+), 47 deletions(-) diff --git a/src/browser/js/ExecutionWorld.zig b/src/browser/js/ExecutionWorld.zig index 77a5865dd..e9a8786dc 100644 --- a/src/browser/js/ExecutionWorld.zig +++ b/src/browser/js/ExecutionWorld.zig @@ -91,8 +91,7 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal const global_template = js_global.getInstanceTemplate(); global_template.setInternalFieldCount(1); - // Configure the missing property callback on the global - // object. + // Configure the missing property callback on the global object. if (global_callback != null) { const configuration = v8.NamedPropertyHandlerConfiguration{ .getter = struct { @@ -162,15 +161,17 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal } errdefer if (enter) handle_scope.?.deinit(); + const js_global = v8_context.getGlobal(); { // If we want to overwrite the built-in console, we have to // delete the built-in one. - const js_obj = v8_context.getGlobal(); + const console_key = v8.String.initUtf8(isolate, "console"); - if (js_obj.deleteValue(v8_context, console_key) == false) { + if (js_global.deleteValue(v8_context, console_key) == false) { return error.ConsoleDeleteError; } } + const context_id = env.context_id; env.context_id = context_id + 1; @@ -195,17 +196,11 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool, global_cal v8_context.setEmbedderData(1, data); } - // @ZIGDOM // Custom exception - // NOTE: there is no way in v8 to subclass the Error built-in type - // TODO: this is an horrible hack - // inline for (JsApi) |JsApi| { - // const Struct = s.defaultValue().?; - // if (@hasDecl(Struct, "ErrorSet")) { - // const script = comptime JsApi.Meta.name ++ ".prototype.__proto__ = Error.prototype"; - // _ = try context.exec(script, "errorSubclass"); - // } - // } + // TODO: this is an horrible hack, I can't figure out how to do this cleanly. + { + _ = try context.exec("DOMException.prototype.__proto__ = Error.prototype", "errorSubclass"); + } try context.setupGlobal(); return context; diff --git a/src/browser/tests/document/query_selector.html b/src/browser/tests/document/query_selector.html index bd4b0d57c..b83c3928f 100644 --- a/src/browser/tests/document/query_selector.html +++ b/src/browser/tests/document/query_selector.html @@ -23,7 +23,7 @@
Heading 6
Main content
diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html index 324453cbd..5e1721b50 100644 --- a/src/browser/tests/element/matches.html +++ b/src/browser/tests/element/matches.html @@ -66,7 +66,7 @@ { const container = $('#test-container'); - testing.expectError("Syntax Error", () => container.matches('')); + testing.expectError("SyntaxError: Syntax Error", () => container.matches('')); testing.withError((err) => { testing.expectEqual(12, err.code); testing.expectEqual("SyntaxError", err.name); diff --git a/src/browser/tests/element/query_selector.html b/src/browser/tests/element/query_selector.html index 9750bd1a5..9564ca6dd 100644 --- a/src/browser/tests/element/query_selector.html +++ b/src/browser/tests/element/query_selector.html @@ -12,7 +12,7 @@ const p1 = $('#p1'); testing.expectEqual(null, p1.querySelector('#p1')); - testing.expectError("Syntax Error", () => p1.querySelector('')); + testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector('')); testing.withError((err) => { testing.expectEqual(12, err.code); testing.expectEqual("SyntaxError", err.name); diff --git a/src/browser/tests/element/query_selector_all.html b/src/browser/tests/element/query_selector_all.html index f203abe43..eeedc8769 100644 --- a/src/browser/tests/element/query_selector_all.html +++ b/src/browser/tests/element/query_selector_all.html @@ -24,7 +24,7 @@ diff --git a/src/browser/tests/legacy/dom/exceptions.html b/src/browser/tests/legacy/dom/exceptions.html index c6bb91f1c..654f68ca6 100644 --- a/src/browser/tests/legacy/dom/exceptions.html +++ b/src/browser/tests/legacy/dom/exceptions.html @@ -10,10 +10,9 @@ let link = $('#link'); testing.withError((err) => { - const msg = "Failed to execute 'appendChild' on 'Node': The new child element contains the parent."; testing.expectEqual(3, err.code); - testing.expectEqual(msg, err.message); - testing.expectEqual('HierarchyRequestError: ' + msg, err.toString()); + testing.expectEqual('Hierarchy Error', err.message); + testing.expectEqual('HierarchyRequestError: Hierarchy Error', err.toString()); testing.expectEqual(true, err instanceof DOMException); testing.expectEqual(true, err instanceof Error); }, () => link.appendChild(content)); diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index f25587524..f392e6760 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -23,16 +23,16 @@ const Page = @import("../Page.zig"); const DOMException = @This(); _code: Code = .none, -_custom_message: ?[]const u8 = null, _custom_name: ?[]const u8 = null, +_custom_message: ?[]const u8 = null, pub fn init(message: ?[]const u8, name: ?[]const u8) DOMException { // If name is provided, try to map it to a legacy code const code = if (name) |n| Code.fromName(n) else .none; return .{ ._code = code, - ._custom_message = message, ._custom_name = name, + ._custom_message = message, }; } @@ -104,8 +104,8 @@ pub fn getMessage(self: *const DOMException) []const u8 { } return switch (self._code) { .none => "", - .invalid_character_error => "Error: Invalid Character", - .index_size_error => "IndexSizeError: Index or size is negative or greater than the allowed amount", + .invalid_character_error => "Invalid Character", + .index_size_error => "Index or size is negative or greater than the allowed amount", .syntax_error => "Syntax Error", .not_supported => "Not Supported", .not_found => "Not Found", @@ -114,14 +114,17 @@ pub fn getMessage(self: *const DOMException) []const u8 { }; } -pub fn toString(self: *const DOMException) []const u8 { - if (self._custom_message) |msg| { - return msg; - } - return switch (self._code) { - .none => "Error", - else => self.getMessage(), +pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { + const msg = blk: { + if (self._custom_message) |msg| { + break :blk msg; + } + switch (self._code) { + .none => return "Error", + else => break :blk self.getMessage(), + } }; + return std.fmt.bufPrint(&page.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; } pub fn className(_: *const DOMException) []const u8 { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 1495cb094..41357bccf 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -190,12 +190,27 @@ pub fn parentElement(self: *const Node) ?*Element { return parent.is(Element); } +// Validates that a node can be inserted as a child of parent. +fn validateNodeInsertion(parent: *Node, node: *Node) !void { + // Check if parent is a valid type to have children + if (parent._type != .document and parent._type != .element and parent._type != .document_fragment) { + return error.HierarchyError; + } + + // Check if node contains parent (would create a cycle) + if (node.contains(parent)) { + return error.HierarchyError; + } +} + pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node { if (child.is(DocumentFragment)) |_| { try page.appendAllChildren(child, self); return child; } + try validateNodeInsertion(self, child); + page.domChanged(); // If the child is currently connected, and if its new parent is connected, @@ -435,6 +450,8 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page return new_node; } + try validateNodeInsertion(self, new_node); + const child_already_connected = new_node.isConnected(); // Check if we're adopting the node to a different document const child_root = new_node.getRootNode(null); @@ -464,12 +481,8 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page if (old_child._parent == null or old_child._parent.? != self) { return error.HierarchyError; } - if (self._type != .document and self._type != .element) { - return error.HierarchyError; - } - if (new_child.contains(self)) { - return error.HierarchyError; - } + + try validateNodeInsertion(self, new_child); _ = try self.insertBefore(new_child, old_child, page); page.removeNode(self, old_child, .{ .will_be_reconnected = false }); @@ -840,7 +853,7 @@ pub const JsApi = struct { pub const previousSibling = bridge.accessor(Node.previousSibling, null, .{}); pub const parentNode = bridge.accessor(Node.parentNode, null, .{}); pub const parentElement = bridge.accessor(Node.parentElement, null, .{}); - pub const appendChild = bridge.function(Node.appendChild, .{}); + pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true }); pub const childNodes = bridge.accessor(Node.childNodes, null, .{}); pub const isConnected = bridge.accessor(Node.isConnected, null, .{}); pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{}); From 94ca2c41e4f807f11cf39d52c50ec7647cb79a7f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 17 Dec 2025 07:42:29 +0800 Subject: [PATCH 234/257] Element.slot, Element.assignedSlot and slotchange event --- src/browser/Page.zig | 175 ++++++++++++++++++++++- src/browser/js/Context.zig | 9 ++ src/browser/tests/element/html/slot.html | 134 +++++++++++++++++ src/browser/webapi/Element.zig | 15 ++ src/browser/webapi/element/html/Slot.zig | 15 ++ 5 files changed, 346 insertions(+), 2 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 400f1ba00..b4db2d37a 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -53,7 +53,6 @@ const DocumentFragment = @import("webapi/DocumentFragment.zig"); const ShadowRoot = @import("webapi/ShadowRoot.zig"); const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); -const HtmlScript = @import("webapi/Element.zig").Html.Script; const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -95,6 +94,7 @@ _element_styles: Element.StyleLookup = .{}, _element_datasets: Element.DatasetLookup = .{}, _element_class_lists: Element.ClassListLookup = .{}, _element_shadow_roots: Element.ShadowRootLookup = .{}, +_element_assigned_slots: Element.AssignedSlotLookup = .{}, _script_manager: ScriptManager, @@ -108,6 +108,10 @@ _intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, _intersection_check_scheduled: bool = false, _intersection_delivery_scheduled: bool = false, +// Slots that need slotchange events to be fired +_slots_pending_slotchange: std.AutoHashMapUnmanaged(*Element.Html.Slot, void) = .{}, +_slotchange_delivery_scheduled: bool = false, + // Lookup for customized built-in elements. Maps element pointer to definition. _customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{}, _customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{}, @@ -242,6 +246,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._element_datasets = .{}; self._element_class_lists = .{}; self._element_shadow_roots = .{}; + self._element_assigned_slots = .{}; self._notified_network_idle = .init; self._notified_network_almost_idle = .init; @@ -251,6 +256,8 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._intersection_observers = .{}; self._intersection_check_scheduled = false; self._intersection_delivery_scheduled = false; + self._slots_pending_slotchange = .{}; + self._slotchange_delivery_scheduled = false; self._customized_builtin_definitions = .{}; self._customized_builtin_connected_callback_invoked = .{}; self._customized_builtin_disconnected_callback_invoked = .{}; @@ -770,7 +777,7 @@ pub fn tick(self: *Page) void { self.js.runMicrotasks(); } -pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void { +pub fn scriptAddedCallback(self: *Page, script: *Element.Html.Script) !void { self._script_manager.addFromElement(script, "parsing") catch |err| { log.err(.page, "page.scriptAddedCallback", .{ .err = err, @@ -889,6 +896,14 @@ pub fn scheduleIntersectionDelivery(self: *Page) !void { try self.js.queueIntersectionDelivery(); } +pub fn scheduleSlotchangeDelivery(self: *Page) !void { + if (self._slotchange_delivery_scheduled) { + return; + } + self._slotchange_delivery_scheduled = true; + try self.js.queueSlotchangeDelivery(); +} + pub fn performScheduledIntersectionChecks(self: *Page) void { if (!self._intersection_check_scheduled) { return; @@ -945,6 +960,42 @@ pub fn deliverMutations(self: *Page) void { } } +pub fn deliverSlotchangeEvents(self: *Page) void { + if (!self._slotchange_delivery_scheduled) { + return; + } + self._slotchange_delivery_scheduled = false; + + // we need to collect the pending slots, and then clear it and THEN exeute + // the slot change. We do this in case the slotchange event itself schedules + // more slot changes (which should only be executed on the next microtask) + const pending = self._slots_pending_slotchange.count(); + + var i: usize = 0; + var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| { + log.err(.page, "deliverSlotchange.append", .{ .err = err }); + return; + }; + + var it = self._slots_pending_slotchange.keyIterator(); + while (it.next()) |slot| { + slots[i] = slot.*; + i += 1; + } + self._slots_pending_slotchange.clearRetainingCapacity(); + + for (slots) |slot| { + const event = Event.init("slotchange", .{ .bubbles = true }, self) catch |err| { + log.err(.page, "deliverSlotchange.init", .{ .err = err }); + continue; + }; + const target = slot.asNode().asEventTarget(); + _ = target.dispatchEvent(event, self) catch |err| { + log.err(.page, "deliverSlotchange.dispatch", .{ .err = err }); + }; + } +} + fn notifyNetworkIdle(self: *Page) void { std.debug.assert(self._notified_network_idle == .done); self._session.browser.notification.dispatch(.page_network_idle, &.{ @@ -1564,6 +1615,28 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts child._parent = null; child._child_link = .{}; + // Handle slot assignment removal before mutation observers + if (child.is(Element)) |el| { + // Check if the parent was a shadow host + if (parent.is(Element)) |parent_el| { + if (self._element_shadow_roots.get(parent_el)) |shadow_root| { + // Signal slot changes for any affected slots + const slot_name = el.getAttributeSafe("slot") orelse ""; + var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(shadow_root.asNode(), .{}); + while (tw.next()) |slot_el| { + if (slot_el.is(Element.Html.Slot)) |slot| { + if (std.mem.eql(u8, slot.getName(), slot_name)) { + self.signalSlotChange(slot); + break; + } + } + } + } + } + // Remove from assigned slot lookup + _ = self._element_assigned_slots.remove(el); + } + if (self.hasMutationObservers()) { const removed = [_]*Node{child}; self.childListChange(parent, &.{}, &removed, previous_sibling, next_sibling); @@ -1733,6 +1806,12 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod return; } + // Update slot assignments for the inserted child if parent is a shadow host + // This needs to happen even if the element isn't connected to the document + if (child.is(Element)) |el| { + self.updateElementAssignedSlot(el); + } + if (opts.child_already_connected and !opts.adopting_to_new_document) { // The child is already connected in the same document, we don't have to reconnect it return; @@ -1779,6 +1858,16 @@ pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value: log.err(.page, "attributeChange.notifyObserver", .{ .err = err }); }; } + + // Handle slot assignment changes + if (std.mem.eql(u8, name, "slot")) { + self.updateSlotAssignments(element); + } else if (std.mem.eql(u8, name, "name")) { + // Check if this is a slot element + if (element.is(Element.Html.Slot)) |slot| { + self.signalSlotChange(slot); + } + } } pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_value: []const u8) void { @@ -1793,6 +1882,88 @@ pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_val log.err(.page, "attributeRemove.notifyObserver", .{ .err = err }); }; } + + // Handle slot assignment changes + if (std.mem.eql(u8, name, "slot")) { + self.updateSlotAssignments(element); + } else if (std.mem.eql(u8, name, "name")) { + // Check if this is a slot element + if (element.is(Element.Html.Slot)) |slot| { + self.signalSlotChange(slot); + } + } +} + +fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void { + self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| { + log.err(.page, "signalSlotChange.put", .{ .err = err }); + return; + }; + self.scheduleSlotchangeDelivery() catch |err| { + log.err(.page, "signalSlotChange.schedule", .{ .err = err }); + }; +} + +fn updateSlotAssignments(self: *Page, element: *Element) void { + // Find all slots in the shadow root that might be affected + const parent = element.asNode()._parent orelse return; + + // Check if parent is a shadow host + const parent_el = parent.is(Element) orelse return; + _ = self._element_shadow_roots.get(parent_el) orelse return; + + // Signal change for the old slot (if any) + if (self._element_assigned_slots.get(element)) |old_slot| { + self.signalSlotChange(old_slot); + } + + // Update the assignedSlot lookup to the new slot + self.updateElementAssignedSlot(element); + + // Signal change for the new slot (if any) + if (self._element_assigned_slots.get(element)) |new_slot| { + self.signalSlotChange(new_slot); + } +} + +fn updateElementAssignedSlot(self: *Page, element: *Element) void { + // Remove old assignment + _ = self._element_assigned_slots.remove(element); + + // Find the new assigned slot + const parent = element.asNode()._parent orelse return; + const parent_el = parent.is(Element) orelse return; + const shadow_root = self._element_shadow_roots.get(parent_el) orelse return; + + const slot_name = element.getAttributeSafe("slot") orelse ""; + + // Recursively search through the shadow root for a matching slot + if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| { + self._element_assigned_slots.put(self.arena, element, slot) catch |err| { + log.err(.page, "updateElementAssignedSlot.put", .{ .err = err }); + }; + } +} + +fn findMatchingSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot { + // Check if this node is a matching slot + if (node.is(Element)) |el| { + if (el.is(Element.Html.Slot)) |slot| { + if (std.mem.eql(u8, slot.getName(), slot_name)) { + return slot; + } + } + } + + // Search children + var it = node.childrenIterator(); + while (it.next()) |child| { + if (findMatchingSlot(child, slot_name)) |slot| { + return slot; + } + } + + return null; } pub fn hasMutationObservers(self: *const Page) bool { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 1273a4b78..d1c15fb3a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -2007,6 +2007,15 @@ pub fn queueIntersectionDelivery(self: *Context) !void { }.run, self.page); } +pub fn queueSlotchangeDelivery(self: *Context) !void { + self.isolate.enqueueMicrotask(struct { + fn run(data: ?*anyopaque) callconv(.c) void { + const page: *Page = @ptrCast(@alignCast(data.?)); + page.deliverSlotchangeEvents(); + } + }.run, self.page); +} + pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { self.isolate.enqueueMicrotaskFunc(cb.func.castToFunction()); } diff --git a/src/browser/tests/element/html/slot.html b/src/browser/tests/element/html/slot.html index af2b08086..b3ee2a58b 100644 --- a/src/browser/tests/element/html/slot.html +++ b/src/browser/tests/element/html/slot.html @@ -382,3 +382,137 @@ testing.expectTrue(flattened[2] === span); } + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 0e68d7fd4..c62437c16 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -45,6 +45,7 @@ pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot); +pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot); pub const Namespace = enum(u8) { html, @@ -400,6 +401,14 @@ pub fn setId(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe("id", value, page); } +pub fn getSlot(self: *const Element) []const u8 { + return self.getAttributeSafe("slot") orelse ""; +} + +pub fn setSlot(self: *Element, value: []const u8, page: *Page) !void { + return self.setAttributeSafe("slot", value, page); +} + pub fn getDir(self: *const Element) []const u8 { return self.getAttributeSafe("dir") orelse ""; } @@ -481,6 +490,10 @@ pub fn getShadowRoot(self: *Element, page: *Page) ?*ShadowRoot { return shadow_root; } +pub fn getAssignedSlot(self: *Element, page: *Page) ?*Html.Slot { + return page._element_assigned_slots.get(self); +} + pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowRoot { if (page._element_shadow_roots.get(self)) |_| { return error.AlreadyHasShadowRoot; @@ -1242,6 +1255,7 @@ pub const JsApi = struct { pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{}); + pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{}); pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, null, .{}); @@ -1259,6 +1273,7 @@ pub const JsApi = struct { pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{}); pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true }); pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); + pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{}); pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true }); pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true }); pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true }); diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig index 9e0c41b9a..e3e59c6c2 100644 --- a/src/browser/webapi/element/html/Slot.zig +++ b/src/browser/webapi/element/html/Slot.zig @@ -62,6 +62,7 @@ fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionTy const allocator = page.call_arena; const host = shadow_root.getHost(); + const initial_count = coll.items.len; var it = host.asNode().childrenIterator(); while (it.next()) |child| { if (!isAssignedToSlot(child, slot_name)) { @@ -87,6 +88,20 @@ fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionTy try coll.append(allocator, child); } } + + // If flatten is true and no assigned nodes were found, return fallback content + if (opts.flatten and coll.items.len == initial_count) { + var child_it = self.asNode().childrenIterator(); + while (child_it.next()) |child| { + if (comptime elements) { + if (child.is(Element)) |el| { + try coll.append(allocator, el); + } + } else { + try coll.append(allocator, child); + } + } + } } pub fn assign(self: *Slot, nodes: []const *Node) void { From fe2d309d3342d1aea3b9a82aa0dd8947e79fed8e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:01:58 -0800 Subject: [PATCH 235/257] begin UIEvent --- src/browser/webapi/event/UIEvent.zig | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/browser/webapi/event/UIEvent.zig diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig new file mode 100644 index 000000000..13f225335 --- /dev/null +++ b/src/browser/webapi/event/UIEvent.zig @@ -0,0 +1,71 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const Event = @import("../Event.zig"); +const Window = @import("../Window.zig"); +const Page = @import("../../Page.zig"); +const js = @import("../../js/js.zig"); + +const UIEvent = @This(); + +_proto: *Event, +_detail: u32, +_view: *Window, + +pub const EventOptions = struct { + detail: u32 = 0, + view: ?*Window = null, +}; + +pub fn init(typ: []const u8, _options: ?EventOptions, page: *Page) !*UIEvent { + const options = _options orelse EventOptions{}; + + return page._factory.event(typ, UIEvent{ + ._proto = undefined, + ._detail = options.detail, + ._view = options.view, + }); +} + +pub fn asEvent(self: *UIEvent) *Event { + return self._proto; +} + +pub fn getDetail(self: *UIEvent) u32 { + return self._detail; +} + +// sourceCapabilities not implemented + +pub fn getView(self: *UIEvent) *Window { + return self._view; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(UIEvent); + + pub const Meta = struct { + pub const name = "UIEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(UIEvent.init, .{}); + pub const detail = bridge.accessor(UIEvent.getDetail, null, .{}); + pub const view = bridge.accessor(UIEvent.getView, null, .{}); +}; From d63a045534929b50f158f90262bf85b40486a8ab Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 10 Dec 2025 06:48:22 -0800 Subject: [PATCH 236/257] proper UIEvent --- src/browser/js/bridge.zig | 1 + src/browser/tests/event/ui.html | 49 ++++++++++++++++++++++++++++ src/browser/webapi/Event.zig | 1 + src/browser/webapi/event/UIEvent.zig | 36 +++++++++++++++----- 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 src/browser/tests/event/ui.html diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index a37097e17..01df4663a 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -576,6 +576,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/event/PopStateEvent.zig"), + @import("../webapi/event/UIEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/tests/event/ui.html b/src/browser/tests/event/ui.html new file mode 100644 index 000000000..27a0f8ae1 --- /dev/null +++ b/src/browser/tests/event/ui.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index e99d71ece..614fc29be 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -60,6 +60,7 @@ pub const Type = union(enum) { navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), + ui_event: *@import("event/UIEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 13f225335..362f21eec 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -27,19 +27,30 @@ _proto: *Event, _detail: u32, _view: *Window, -pub const EventOptions = struct { +pub const UIEventOptions = struct { detail: u32 = 0, view: ?*Window = null, }; -pub fn init(typ: []const u8, _options: ?EventOptions, page: *Page) !*UIEvent { - const options = _options orelse EventOptions{}; +const Options = Event.inheritOptions( + UIEvent, + UIEventOptions, +); - return page._factory.event(typ, UIEvent{ - ._proto = undefined, - ._detail = options.detail, - ._view = options.view, - }); +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { + const opts = _opts orelse Options{}; + + const event = try page._factory.event( + typ, + UIEvent{ + ._proto = undefined, + ._detail = opts.detail, + ._view = opts.view orelse page.window, + }, + ); + + Event.populatePrototypes(event, opts); + return event; } pub fn asEvent(self: *UIEvent) *Event { @@ -56,6 +67,8 @@ pub fn getView(self: *UIEvent) *Window { return self._view; } +// deprecated `initUIEvent()` not implemented + pub const JsApi = struct { pub const bridge = js.Bridge(UIEvent); @@ -69,3 +82,8 @@ pub const JsApi = struct { pub const detail = bridge.accessor(UIEvent.getDetail, null, .{}); pub const view = bridge.accessor(UIEvent.getView, null, .{}); }; + +const testing = @import("../../../testing.zig"); +test "WebApi: UIEvent" { + try testing.htmlRunner("event/ui.html", .{}); +} From 6f43d9979da6d3d701023b892cc1fe81cf1c20cf Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 17 Dec 2025 14:11:49 -0800 Subject: [PATCH 237/257] add MouseEvent --- src/browser/Factory.zig | 22 ++- src/browser/js/bridge.zig | 1 + src/browser/tests/event/mouse.html | 37 +++++ src/browser/tests/event/ui.html | 5 + src/browser/webapi/Event.zig | 5 +- src/browser/webapi/event/MouseEvent.zig | 188 ++++++++++++++++++++++++ src/browser/webapi/event/UIEvent.zig | 22 ++- 7 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 src/browser/tests/event/mouse.html create mode 100644 src/browser/webapi/event/MouseEvent.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 6a0de8037..eb0613b16 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -30,6 +30,7 @@ const SlabAllocator = @import("../slab.zig").SlabAllocator; const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); +const UIEvent = @import("webapi/event/UIEvent.zig"); const Element = @import("webapi/Element.zig"); const Document = @import("webapi/Document.zig"); const EventTarget = @import("webapi/EventTarget.zig"); @@ -170,11 +171,11 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); - // Special case: Event has a _type_string field, so we need manual setup const chain = try PrototypeChain( &.{ Event, @TypeOf(child) }, ).allocate(allocator); + // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); event_ptr.* = .{ ._type = unionInit(Event.Type, chain.get(1)), @@ -185,6 +186,25 @@ pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { return chain.get(1); } +pub fn uiEvent(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + + const chain = try PrototypeChain( + &.{ Event, UIEvent, @TypeOf(child) }, + ).allocate(allocator); + + // Special case: Event has a _type_string field, so we need manual setup + const event_ptr = chain.get(0); + event_ptr.* = .{ + ._type = unionInit(Event.Type, chain.get(1)), + ._type_string = try String.init(self._page.arena, typ, .{}), + }; + chain.setMiddle(1, UIEvent.Type); + chain.setLeaf(2, child); + + return chain.get(2); +} + pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 01df4663a..67bae541c 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -577,6 +577,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/event/UIEvent.zig"), + @import("../webapi/event/MouseEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/tests/event/mouse.html b/src/browser/tests/event/mouse.html new file mode 100644 index 000000000..4f8dd17fa --- /dev/null +++ b/src/browser/tests/event/mouse.html @@ -0,0 +1,37 @@ + + + + + + + diff --git a/src/browser/tests/event/ui.html b/src/browser/tests/event/ui.html index 27a0f8ae1..50d880ec3 100644 --- a/src/browser/tests/event/ui.html +++ b/src/browser/tests/event/ui.html @@ -47,3 +47,8 @@ testing.expectEqual(0, evt5.detail); testing.expectEqual(100, evt6.detail); + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 614fc29be..23a0fa0e5 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -18,7 +18,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const reflect = @import("../reflect.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); @@ -63,7 +62,7 @@ pub const Type = union(enum) { ui_event: *@import("event/UIEvent.zig"), }; -const Options = struct { +pub const Options = struct { bubbles: bool = false, cancelable: bool = false, composed: bool = false, @@ -211,7 +210,7 @@ pub fn inheritOptions(comptime T: type, comptime additions: anytype) type { inline for (t_fields) |field| { if (std.mem.eql(u8, field.name, "_proto")) { - const ProtoType = reflect.Struct(field.type); + const ProtoType = @typeInfo(field.type).pointer.child; if (@hasDecl(ProtoType, "Options")) { const parent_options = @typeInfo(ProtoType.Options); all_fields = all_fields ++ parent_options.@"struct".fields; diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig new file mode 100644 index 000000000..305d4c86d --- /dev/null +++ b/src/browser/webapi/event/MouseEvent.zig @@ -0,0 +1,188 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Event = @import("../Event.zig"); +const UIEvent = @import("UIEvent.zig"); +const EventTarget = @import("../EventTarget.zig"); +const Window = @import("../Window.zig"); +const Page = @import("../../Page.zig"); +const js = @import("../../js/js.zig"); + +const MouseEvent = @This(); + +const MouseButton = enum(u8) { + main = 0, + auxillary = 1, + secondary = 2, + fourth = 3, + fifth = 4, +}; + +_proto: *UIEvent, + +_alt_key: bool, +_button: MouseButton, +// TODO: _buttons +_client_x: f64, +_client_y: f64, +_ctrl_key: bool, +_meta_key: bool, +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget +_related_target: ?*EventTarget = null, +_screen_x: f64, +_screen_y: f64, +_shift_key: bool, + +pub const MouseEventOptions = struct { + screenX: f64 = 0.0, + screenY: f64 = 0.0, + clientX: f64 = 0.0, + clientY: f64 = 0.0, + ctrlKey: bool = false, + shiftKey: bool = false, + altKey: bool = false, + metaKey: bool = false, + button: i32 = 0, + // TODO: buttons + relatedTarget: ?*EventTarget = null, +}; + +pub const Options = Event.inheritOptions( + MouseEvent, + MouseEventOptions, +); + +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { + const opts = _opts orelse Options{}; + + const event = try page._factory.uiEvent( + typ, + MouseEvent{ + ._proto = undefined, + ._screen_x = opts.screenX, + ._screen_y = opts.screenY, + ._client_x = opts.clientX, + ._client_y = opts.clientY, + ._ctrl_key = opts.ctrlKey, + ._shift_key = opts.shiftKey, + ._alt_key = opts.altKey, + ._meta_key = opts.metaKey, + ._button = std.meta.intToEnum(MouseButton, opts.button) catch return error.TypeError, + ._related_target = opts.relatedTarget, + }, + ); + + Event.populatePrototypes(event, opts); + return event; +} + +pub fn asEvent(self: *MouseEvent) *Event { + return self._proto.asEvent(); +} + +pub fn getAltKey(self: *const MouseEvent) bool { + return self._alt_key; +} + +pub fn getButton(self: *const MouseEvent) u8 { + return @intFromEnum(self._button); +} + +pub fn getClientX(self: *const MouseEvent) f64 { + return self._client_x; +} + +pub fn getClientY(self: *const MouseEvent) f64 { + return self._client_y; +} + +pub fn getCtrlKey(self: *const MouseEvent) bool { + return self._ctrl_key; +} + +pub fn getMetaKey(self: *const MouseEvent) bool { + return self._meta_key; +} + +pub fn getOffsetX(_: *const MouseEvent) f64 { + return 0.0; +} + +pub fn getOffsetY(_: *const MouseEvent) f64 { + return 0.0; +} + +pub fn getPageX(self: *const MouseEvent) f64 { + // this should be clientX + window.scrollX + return self._client_x; +} + +pub fn getPageY(self: *const MouseEvent) f64 { + // this should be clientY + window.scrollY + return self._client_y; +} + +pub fn getRelatedTarget(self: *const MouseEvent) ?*EventTarget { + return self._related_target; +} + +pub fn getScreenX(self: *const MouseEvent) f64 { + return self._screen_x; +} + +pub fn getScreenY(self: *const MouseEvent) f64 { + return self._screen_y; +} + +pub fn getShiftKey(self: *const MouseEvent) bool { + return self._shift_key; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MouseEvent); + + pub const Meta = struct { + pub const name = "MouseEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(MouseEvent.init, .{}); + pub const altKey = bridge.accessor(getAltKey, null, .{}); + pub const button = bridge.accessor(getButton, null, .{}); + pub const clientX = bridge.accessor(getClientX, null, .{}); + pub const clientY = bridge.accessor(getClientY, null, .{}); + pub const ctrlKey = bridge.accessor(getCtrlKey, null, .{}); + pub const metaKey = bridge.accessor(getMetaKey, null, .{}); + pub const offsetX = bridge.accessor(getOffsetX, null, .{}); + pub const offsetY = bridge.accessor(getOffsetY, null, .{}); + pub const pageX = bridge.accessor(getPageX, null, .{}); + pub const pageY = bridge.accessor(getPageY, null, .{}); + pub const relatedTarget = bridge.accessor(getRelatedTarget, null, .{}); + pub const screenX = bridge.accessor(getScreenX, null, .{}); + pub const screenY = bridge.accessor(getScreenY, null, .{}); + pub const shiftKey = bridge.accessor(getShiftKey, null, .{}); + pub const x = bridge.accessor(getClientX, null, .{}); + pub const y = bridge.accessor(getClientY, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: MouseEvent" { + try testing.htmlRunner("event/mouse.html", .{}); +} diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 362f21eec..c251cf04d 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -23,16 +23,22 @@ const js = @import("../../js/js.zig"); const UIEvent = @This(); +_type: Type, _proto: *Event, -_detail: u32, -_view: *Window, +_detail: u32 = 0, +_view: ?*Window = null, + +pub const Type = union(enum) { + generic, + mouse_event: *@import("MouseEvent.zig"), +}; pub const UIEventOptions = struct { detail: u32 = 0, view: ?*Window = null, }; -const Options = Event.inheritOptions( +pub const Options = Event.inheritOptions( UIEvent, UIEventOptions, ); @@ -43,6 +49,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { const event = try page._factory.event( typ, UIEvent{ + ._type = .generic, ._proto = undefined, ._detail = opts.detail, ._view = opts.view orelse page.window, @@ -53,6 +60,11 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { return event; } +pub fn populateFromOptions(self: *UIEvent, opts: anytype) void { + self._detail = opts.detail; + self._view = opts.view; +} + pub fn asEvent(self: *UIEvent) *Event { return self._proto; } @@ -63,8 +75,8 @@ pub fn getDetail(self: *UIEvent) u32 { // sourceCapabilities not implemented -pub fn getView(self: *UIEvent) *Window { - return self._view; +pub fn getView(self: *UIEvent, page: *Page) *Window { + return self._view orelse page.window; } // deprecated `initUIEvent()` not implemented From 9dbfac02b2b8766d7b5b0360a13606fe234e9581 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 17 Dec 2025 14:45:36 -0800 Subject: [PATCH 238/257] add KeyboardEvent --- src/browser/js/bridge.zig | 1 + src/browser/tests/event/keyboard.html | 89 ++++++++++ src/browser/webapi/event/KeyboardEvent.zig | 196 +++++++++++++++++++++ src/browser/webapi/event/UIEvent.zig | 1 + 4 files changed, 287 insertions(+) create mode 100644 src/browser/tests/event/keyboard.html create mode 100644 src/browser/webapi/event/KeyboardEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 67bae541c..e3c7813f1 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -578,6 +578,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/event/UIEvent.zig"), @import("../webapi/event/MouseEvent.zig"), + @import("../webapi/event/KeyboardEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/tests/event/keyboard.html b/src/browser/tests/event/keyboard.html new file mode 100644 index 000000000..3d14cfca5 --- /dev/null +++ b/src/browser/tests/event/keyboard.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig new file mode 100644 index 000000000..288a5ccbb --- /dev/null +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -0,0 +1,196 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Event = @import("../Event.zig"); +const UIEvent = @import("UIEvent.zig"); +const EventTarget = @import("../EventTarget.zig"); +const Window = @import("../Window.zig"); +const Page = @import("../../Page.zig"); +const js = @import("../../js/js.zig"); + +const KeyboardEvent = @This(); + +_proto: *UIEvent, +_key: Key, +_ctrl_key: bool, +_shift_key: bool, +_alt_key: bool, +_meta_key: bool, +_location: Location, +_repeat: bool, +_is_composing: bool, + +// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values +pub const Key = union(enum) { + // Special Key Values + Dead, + Undefined, + Alt, + AltGraph, + CapsLock, + Control, + Fn, + FnLock, + Hyper, + Meta, + NumLock, + ScrollLock, + Shift, + Super, + Symbol, + SymbolLock, + standard: []const u8, + + pub fn fromString(allocator: std.mem.Allocator, str: []const u8) !Key { + const key_type_info = @typeInfo(Key); + inline for (key_type_info.@"union".fields) |field| { + if (comptime std.mem.eql(u8, field.name, "standard")) continue; + + if (std.mem.eql(u8, field.name, str)) { + return @unionInit(Key, field.name, {}); + } + } + + const duped = try allocator.dupe(u8, str); + return .{ .standard = duped }; + } +}; + +pub const Location = enum(i32) { + DOM_KEY_LOCATION_STANDARD = 0, + DOM_KEY_LOCATION_LEFT = 1, + DOM_KEY_LOCATION_RIGHT = 2, + DOM_KEY_LOCATION_NUMPAD = 3, +}; + +pub const KeyboardEventOptions = struct { + key: []const u8 = "", + // TODO: code but it is not baseline. + location: i32 = 0, + repeat: bool = false, + isComposing: bool = false, + ctrlKey: bool = false, + shiftKey: bool = false, + altKey: bool = false, + metaKey: bool = false, +}; + +pub const Options = Event.inheritOptions( + KeyboardEvent, + KeyboardEventOptions, +); + +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*KeyboardEvent { + const opts = _opts orelse Options{}; + + const event = try page._factory.uiEvent( + typ, + KeyboardEvent{ + ._proto = undefined, + ._key = try Key.fromString(page.arena, opts.key), + ._location = std.meta.intToEnum(Location, opts.location) catch return error.TypeError, + ._repeat = opts.repeat, + ._is_composing = opts.isComposing, + ._ctrl_key = opts.ctrlKey, + ._shift_key = opts.shiftKey, + ._alt_key = opts.altKey, + ._meta_key = opts.metaKey, + }, + ); + + Event.populatePrototypes(event, opts); + return event; +} + +pub fn asEvent(self: *KeyboardEvent) *Event { + return self._proto.asEvent(); +} + +pub fn getAltKey(self: *const KeyboardEvent) bool { + return self._alt_key; +} + +pub fn getCtrlKey(self: *const KeyboardEvent) bool { + return self._ctrl_key; +} + +pub fn getIsComposing(self: *const KeyboardEvent) bool { + return self._is_composing; +} + +pub fn getKey(self: *const KeyboardEvent) []const u8 { + return switch (self._key) { + .standard => |key| key, + else => |x| @tagName(x), + }; +} + +pub fn getLocation(self: *const KeyboardEvent) i32 { + return @intFromEnum(self._location); +} + +pub fn getMetaKey(self: *const KeyboardEvent) bool { + return self._meta_key; +} + +pub fn getRepeat(self: *const KeyboardEvent) bool { + return self._repeat; +} + +pub fn getShiftKey(self: *const KeyboardEvent) bool { + return self._shift_key; +} + +pub fn getModifierState(self: *const KeyboardEvent, str: []const u8, page: *Page) !bool { + const key = try Key.fromString(page.arena, str); + + switch (key) { + .Alt, .AltGraph => return self._alt_key, + .Shift => return self._shift_key, + .Control => return self._ctrl_key, + .Meta => return self._meta_key, + else => return false, + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(KeyboardEvent); + + pub const Meta = struct { + pub const name = "KeyboardEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(KeyboardEvent.init, .{}); + pub const altKey = bridge.accessor(KeyboardEvent.getAltKey, null, .{}); + pub const ctrlKey = bridge.accessor(KeyboardEvent.getCtrlKey, null, .{}); + pub const isComposing = bridge.accessor(KeyboardEvent.getIsComposing, null, .{}); + pub const key = bridge.accessor(KeyboardEvent.getKey, null, .{}); + pub const location = bridge.accessor(KeyboardEvent.getLocation, null, .{}); + pub const metaKey = bridge.accessor(KeyboardEvent.getMetaKey, null, .{}); + pub const repeat = bridge.accessor(KeyboardEvent.getRepeat, null, .{}); + pub const shiftKey = bridge.accessor(KeyboardEvent.getShiftKey, null, .{}); + pub const getModifierState = bridge.function(KeyboardEvent.getModifierState, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: KeyboardEvent" { + try testing.htmlRunner("event/keyboard.html", .{}); +} diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index c251cf04d..75a58cd61 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -31,6 +31,7 @@ _view: ?*Window = null, pub const Type = union(enum) { generic, mouse_event: *@import("MouseEvent.zig"), + keyboard_event: *@import("KeyboardEvent.zig"), }; pub const UIEventOptions = struct { From 22303d2ae84f476cdec4139211e3b6abf2108d64 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 9 Dec 2025 08:10:04 +0100 Subject: [PATCH 239/257] Merge pull request #1236 from lightpanda-io/v8-build-with-zig-gclient-ci V8 build with zig gclient ci --- .github/actions/install/action.yml | 51 +++++++------- .github/workflows/build.yml | 8 +-- .github/workflows/e2e-test.yml | 2 +- .github/workflows/zig-test.yml | 4 +- .gitignore | 6 +- Dockerfile | 47 ++++++++----- Makefile | 15 +--- README.md | 16 ----- build.zig | 108 ++++++++++------------------- build.zig.zon | 5 +- 10 files changed, 106 insertions(+), 156 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 3b29b7b27..013272024 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -2,10 +2,6 @@ name: "Browsercore install" description: "Install deps for the project browsercore" inputs: - zig: - description: 'Zig version to install' - required: false - default: '0.15.2' arch: description: 'CPU arch used to select the v8 lib' required: false @@ -26,10 +22,6 @@ inputs: description: 'cache dir to use' required: false default: '~/.cache' - mode: - description: 'debug or release' - required: false - default: 'debug' runs: using: "composite" @@ -42,9 +34,8 @@ runs: sudo apt-get update sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang - - uses: mlugg/setup-zig@v2.0.5 - with: - version: ${{ inputs.zig }} + # Zig version used from the `minimum_zig_version` field in build.zig.zon + - uses: mlugg/setup-zig@v2 - name: Cache v8 id: cache-v8 @@ -62,26 +53,34 @@ runs: wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a - - name: install v8 release - if: ${{ inputs.mode == 'release' }} + - name: install v8 shell: bash run: | - mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/ - ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a + mkdir -p v8 + ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8.a + + - name: Cache libiconv + id: cache-libiconv + uses: actions/cache@v4 + env: + cache-name: cache-libiconv + with: + path: ${{ inputs.cache-dir }}/libiconv + key: vendor/libiconv/libiconv-1.17 - - name: install v8 debug - if: ${{ inputs.mode == 'debug' }} + - name: download libiconv + if: ${{ steps.cache-libiconv.outputs.cache-hit != 'true' }} shell: bash - run: | - mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/ - ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a + run: make download-libiconv + + - name: build libiconv + shell: bash + run: make build-libiconv - - name: hmtl5ever release - if: ${{ inputs.mode == 'release' }} + - name: build mimalloc shell: bash - run: zig build -Doptimize=ReleaseFast html5ever + run: make install-mimalloc - - name: hmtl5ever debug - if: ${{ inputs.mode == 'debug' }} + - name: build netsurf shell: bash - run: zig build html5ever + run: make install-netsurf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ab034e84..9b7f17c8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: mode: 'release' - name: zig build - run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -78,7 +78,7 @@ jobs: mode: 'release' - name: zig build - run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -119,7 +119,7 @@ jobs: mode: 'release' - name: zig build - run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -163,7 +163,7 @@ jobs: mode: 'release' - name: zig build - run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 1b8b910b7..b88da6520 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -60,7 +60,7 @@ jobs: mode: 'release' - name: zig build release - run: zig build -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index f90a6e870..db54a8936 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -56,7 +56,7 @@ jobs: - uses: ./.github/actions/install - name: zig build debug - run: zig build + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a - name: upload artifact uses: actions/upload-artifact@v4 @@ -104,7 +104,7 @@ jobs: - uses: ./.github/actions/install - name: zig build test - run: zig build test -- --json > bench.json + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a test -- --json > bench.json - name: write commit run: | diff --git a/.gitignore b/.gitignore index 59d6886ca..c0b52a0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +zig-cache /.zig-cache/ -/zig-out/ +/.lp-cache/ +zig-out +/vendor/netsurf/out +/vendor/libiconv/ lightpanda.id /v8/ /build/ diff --git a/Dockerfile b/Dockerfile index a405a057c..2ce342b23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,9 @@ -FROM debian:stable +FROM debian:stable-slim ARG MINISIG=0.12 -ARG ZIG=0.15.2 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.1.35 +ARG ZIG_V8=v0.1.34 ARG TARGETPLATFORM RUN apt-get update -yq && \ @@ -17,30 +16,32 @@ RUN apt-get update -yq && \ # install minisig RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${MINISIG}/minisign-${MINISIG}-linux.tar.gz && \ - tar xvzf minisign-${MINISIG}-linux.tar.gz + tar xvzf minisign-${MINISIG}-linux.tar.gz -C / + +# clone lightpanda +RUN git clone https://github.com/lightpanda-io/browser.git +WORKDIR /browser # install zig -RUN case $TARGETPLATFORM in \ - "linux/arm64") ARCH="aarch64" ;; \ - *) ARCH="x86_64" ;; \ +RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \ + case $TARGETPLATFORM in \ + "linux/arm64") ARCH="aarch64" ;; \ + *) ARCH="x86_64" ;; \ esac && \ curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \ curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \ - minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \ + /minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \ tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \ mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \ ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig -# clone lightpanda -RUN git clone https://github.com/lightpanda-io/browser.git - -WORKDIR /browser - # install deps RUN git submodule init && \ git submodule update --recursive -RUN zig build -Doptimize=ReleaseFast html5ever +RUN make install-libiconv && \ + make install-netsurf && \ + make install-mimalloc # download and install v8 RUN case $TARGETPLATFORM in \ @@ -48,11 +49,16 @@ RUN case $TARGETPLATFORM in \ *) ARCH="x86_64" ;; \ esac && \ curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \ - mkdir -p v8/out/linux/release/obj/zig/ && \ - mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a + mkdir -p v8/ && \ + mv libc_v8.a v8/libc_v8.a # build release -RUN make build +RUN zig build -Doptimize=ReleaseSafe -Dprebuilt_v8_path=v8/libc_v8.a -Dgit_commit=$$(git rev-parse --short HEAD) + +FROM debian:stable-slim + +RUN apt-get update -yq && \ + apt-get install -yq tini FROM debian:stable-slim @@ -60,7 +66,12 @@ FROM debian:stable-slim COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda +COPY --from=1 /usr/bin/tini /usr/bin/tini EXPOSE 9222/tcp -CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"] +# Lightpanda install only some signal handlers, and PID 1 doesn't have a default SIGTERM signal handler. +# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang. +# (See https://github.com/krallin/tini#why-tini). +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log_level", "info"] diff --git a/Makefile b/Makefile index 3f79e1fa2..8a25cb1ba 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-dev run run-release shell test bench download-zig wpt data get-v8 build-v8 build-v8-dev +.PHONY: build build-dev run run-release shell test bench download-zig wpt data .PHONY: end2end zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2) @@ -112,19 +112,6 @@ end2end: @test -d ../demo cd ../demo && go run runner/main.go -## v8 -get-v8: - @printf "\e[36mGetting v8 source...\e[0m\n" - @$(ZIG) build get-v8 - -build-v8-dev: - @printf "\e[36mBuilding v8 (dev)...\e[0m\n" - @$(ZIG) build build-v8 - -build-v8: - @printf "\e[36mBuilding v8...\e[0m\n" - @$(ZIG) build -Doptimize=ReleaseSafe build-v8 - # Install and build required dependencies commands # ------------ .PHONY: install install-dev diff --git a/README.md b/README.md index 5e25926ab..32340860f 100644 --- a/README.md +++ b/README.md @@ -225,22 +225,6 @@ zig build html5ever For a release build, use `zig build -Doptimize=ReleaseFast html5ever`. -**v8** - -First, get the tools necessary for building V8, as well as the V8 source code: - -``` -make get-v8 -``` - -Next, build v8. This build task is very long and cpu consuming, as you will build v8 from sources. - -``` -make build-v8 -``` - -For dev env, use `make build-v8-dev`. - ## Test ### Unit Tests diff --git a/build.zig b/build.zig index 0070e5769..308ec6860 100644 --- a/build.zig +++ b/build.zig @@ -21,33 +21,18 @@ const builtin = @import("builtin"); const Build = std.Build; -/// Do not rename this constant. It is scanned by some scripts to determine -/// which zig version to install. -const recommended_zig_version = "0.15.2"; - pub fn build(b: *Build) !void { - switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) { - .eq => {}, - .lt => { - @compileError("The minimum version of Zig required to compile is '" ++ recommended_zig_version ++ "', found '" ++ builtin.zig_version_string ++ "'."); - }, - .gt => { - std.debug.print( - "WARNING: Recommended Zig version '{s}', but found '{s}', build may fail...\n\n", - .{ recommended_zig_version, builtin.zig_version_string }, - ); - }, - } - const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const manifest = Manifest.init(b); + + const git_commit = b.option([]const u8, "git_commit", "Current git commit"); + const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a"); + var opts = b.addOptions(); - opts.addOption( - []const u8, - "git_commit", - b.option([]const u8, "git_commit", "Current git commit") orelse "dev", - ); + opts.addOption([]const u8, "version", manifest.version); + opts.addOption([]const u8, "git_commit", git_commit orelse "dev"); // Build step to install html5ever dependency. const html5ever_argv = blk: { @@ -88,7 +73,7 @@ pub fn build(b: *Build) !void { .sanitize_thread = enable_tsan, }); - try addDependencies(b, mod, opts); + try addDependencies(b, mod, opts, prebuilt_v8_path); break :blk mod; }; @@ -191,33 +176,17 @@ pub fn build(b: *Build) !void { const run_step = b.step("wpt", "Run WPT tests"); run_step.dependOn(&run_cmd.step); } - - { - // get v8 - // ------- - const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize }); - const get_v8 = b.addRunArtifact(v8.artifact("get-v8")); - const get_step = b.step("get-v8", "Get v8"); - get_step.dependOn(&get_v8.step); - } - - { - // build v8 - // ------- - const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize }); - const build_v8 = b.addRunArtifact(v8.artifact("build-v8")); - const build_step = b.step("build-v8", "Build v8"); - build_step.dependOn(&build_v8.step); - } } -fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !void { +fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options, prebuilt_v8_path: ?[]const u8) !void { mod.addImport("build_config", opts.createModule()); const target = mod.resolved_target.?; const dep_opts = .{ .target = target, .optimize = mod.optimize.?, + .prebuilt_v8_path = prebuilt_v8_path, + .cache_root = b.pathFromRoot(".lp-cache"), }; mod.addIncludePath(b.path("vendor/lightpanda")); @@ -230,36 +199,6 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo const v8_mod = b.dependency("v8", dep_opts).module("v8"); v8_mod.addOptions("default_exports", v8_opts); mod.addImport("v8", v8_mod); - - const release_dir = if (mod.optimize.? == .Debug) "debug" else "release"; - const os = switch (target.result.os.tag) { - .linux => "linux", - .macos => "macos", - else => return error.UnsupportedPlatform, - }; - var lib_path = try std.fmt.allocPrint( - mod.owner.allocator, - "v8/out/{s}/{s}/obj/zig/libc_v8.a", - .{ os, release_dir }, - ); - std.fs.cwd().access(lib_path, .{}) catch { - // legacy path - lib_path = try std.fmt.allocPrint( - mod.owner.allocator, - "v8/out/{s}/obj/zig/libc_v8.a", - .{release_dir}, - ); - }; - mod.addObjectFile(mod.owner.path(lib_path)); - - switch (target.result.os.tag) { - .macos => { - // v8 has a dependency, abseil-cpp, which, on Mac, uses CoreFoundation - mod.addSystemFrameworkPath(.{ .cwd_relative = "/System/Library/Frameworks" }); - mod.linkFramework("CoreFoundation", .{}); - }, - else => {}, - } } { @@ -747,3 +686,28 @@ fn buildCurl(b: *Build, m: *Build.Module) !void { }, }); } + +const Manifest = struct { + version: []const u8, + minimum_zig_version: []const u8, + + fn init(b: *std.Build) Manifest { + const input = @embedFile("build.zig.zon"); + + var diagnostics: std.zon.parse.Diagnostics = .{}; + defer diagnostics.deinit(b.allocator); + + return std.zon.parse.fromSlice(Manifest, b.allocator, input, &diagnostics, .{ + .free_on_error = true, + .ignore_unknown_fields = true, + }) catch |err| { + switch (err) { + error.OutOfMemory => @panic("OOM"), + error.ParseZon => { + std.debug.print("Parse diagnostics:\n{f}\n", .{diagnostics}); + std.process.exit(1); + }, + } + }; + } +}; diff --git a/build.zig.zon b/build.zig.zon index cb0136209..7bef6f541 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,10 +3,11 @@ .paths = .{""}, .version = "0.0.0", .fingerprint = 0xda130f3af836cea0, + .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0d19781ccec829640e4f07591cbc166fa7dbe139.tar.gz", - .hash = "v8-0.0.0-xddH6wTgAwALFCYoZbUIqtsRyP6mr69N7aKT_cySHKN2", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/e047d2a4d5af5783763f0f6a652fab8982a08603.tar.gz", + .hash = "v8-0.0.0-xddH65gMBACRBQMM7EwmVgfi94FJyyX-0jpe5KhXYhfv", }, //.v8 = .{ .path = "../zig-v8-fork" } .@"boringssl-zig" = .{ From aa5e71112ef1851b2c25c89848030fcece4d7110 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 18 Dec 2025 11:59:30 +0800 Subject: [PATCH 240/257] v8 symbol -> []const support --- src/browser/js/Context.zig | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index d1c15fb3a..2d1a4cbd3 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1025,23 +1025,25 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved { const valueToStringOpts = struct { allocator: ?Allocator = null, }; -pub fn valueToString(self: *const Context, value: v8.Value, opts: valueToStringOpts) ![]u8 { +pub fn valueToString(self: *const Context, js_val: v8.Value, opts: valueToStringOpts) ![]u8 { const allocator = opts.allocator orelse self.call_arena; - if (value.isSymbol()) { - // symbol's can't be converted to a string - return allocator.dupe(u8, "$Symbol"); + if (js_val.isSymbol()) { + const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym_desc = js_sym.getDescription(self.isolate); + return self.valueToString(js_sym_desc, .{}); } - const str = try value.toString(self.v8_context); + const str = try js_val.toString(self.v8_context); return self.jsStringToZig(str, .{ .allocator = allocator }); } -pub fn valueToStringZ(self: *const Context, value: v8.Value, opts: valueToStringOpts) ![:0]u8 { +pub fn valueToStringZ(self: *const Context, js_val: v8.Value, opts: valueToStringOpts) ![:0]u8 { const allocator = opts.allocator orelse self.call_arena; - if (value.isSymbol()) { - // symbol's can't be converted to a string - return allocator.dupeZ(u8, "$Symbol"); + if (js_val.isSymbol()) { + const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym_desc = js_sym.getDescription(self.isolate); + return self.valueToStringZ(js_sym_desc, .{}); } - const str = try value.toString(self.v8_context); + const str = try js_val.toString(self.v8_context); return self.jsStringToZigZ(str, .{ .allocator = allocator }); } @@ -1090,15 +1092,13 @@ fn _debugValue(self: *const Context, js_val: v8.Value, seen: *std.AutoHashMapUnm if (js_val.isFalse()) { return writer.writeAll("false"); } - // TODO: KARL wait for v8 build to work again, this works with - // the latest version of zig-v8-fork, I just can't build it right now - // APPLY THIS change to valueToString and valueToStringz - // if (js_val.isSymbol()) { - // const js_sym = v8.Symbol{.handle = js_val.handle}; - // const js_sym_desc = js_sym.getDescription(self.isolate); - // const js_sym_str = try self.valueToString(js_sym_desc, .{}); - // return writer.print("{s} (symbol)", .{js_sym_str}); - // } + + if (js_val.isSymbol()) { + const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym_desc = js_sym.getDescription(self.isolate); + const js_sym_str = try self.valueToString(js_sym_desc, .{}); + return writer.print("{s} (symbol)", .{js_sym_str}); + } const js_type = try self.jsStringToZig(try js_val.typeOf(self.isolate), .{}); const js_val_str = try self.valueToString(js_val, .{}); if (js_val_str.len > 2000) { From 3e03f7559f33f827e7a86737fbcfa7fe92dfcada Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 18 Dec 2025 20:48:14 +0800 Subject: [PATCH 241/257] Document log_filter_scope argument Add fetch logging --- src/browser/Browser.zig | 6 +++++- src/browser/Page.zig | 12 +++++++----- src/browser/Session.zig | 9 +++++++-- src/browser/webapi/element/DOMStringMap.zig | 8 ++++---- src/browser/webapi/net/Fetch.zig | 16 ++++++++++++++++ src/main.zig | 5 +++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 1d3bcbfeb..cf3ca2efc 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -27,6 +27,8 @@ const App = @import("../App.zig"); const HttpClient = @import("../http/Client.zig"); const Notification = @import("../Notification.zig"); +const IS_DEBUG = @import("builtin").mode == .Debug; + const Session = @import("Session.zig"); // Browser is an instance of the browser. @@ -104,7 +106,9 @@ pub fn runMicrotasks(self: *const Browser) void { pub fn runMessageLoop(self: *const Browser) void { while (self.env.pumpMessageLoop()) { - log.debug(.browser, "pumpMessageLoop", .{}); + if (comptime IS_DEBUG) { + log.debug(.browser, "pumpMessageLoop", .{}); + } } self.env.runIdleTasks(); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b4db2d37a..b47f5f4b3 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -472,11 +472,13 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !void { self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; - log.debug(.page, "navigate header", .{ - .url = self.url, - .status = header.status, - .content_type = header.contentType(), - }); + if (comptime IS_DEBUG) { + log.debug(.page, "navigate header", .{ + .url = self.url, + .status = header.status, + .content_type = header.contentType(), + }); + } } fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 3c599c393..5ba549c48 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -30,6 +30,7 @@ const Browser = @import("Browser.zig"); const Allocator = std.mem.Allocator; const NavigateOpts = Page.NavigateOpts; +const IS_DEBUG = @import("builtin").mode == .Debug; // Session is like a browser's tab. // It owns the js env and the loader for all the pages of the session. @@ -110,7 +111,9 @@ pub fn createPage(self: *Session) !*Page { // Creates a new NavigationEventTarget for this page. try self.navigation.onNewPage(page); - log.debug(.browser, "create page", .{}); + if (comptime IS_DEBUG) { + log.debug(.browser, "create page", .{}); + } // start JS env // Inform CDP the main page has been created such that additional context for other Worlds can be created as well self.browser.notification.dispatch(.page_created, page); @@ -129,7 +132,9 @@ pub fn removePage(self: *Session) void { self.navigation.onRemovePage(); - log.debug(.browser, "remove page", .{}); + if (comptime IS_DEBUG) { + log.debug(.browser, "remove page", .{}); + } } pub fn currentPage(self: *Session) ?*Page { diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index 518f996a8..4d1d23076 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -28,17 +28,17 @@ const DOMStringMap = @This(); _element: *Element, -fn _getProperty(self: *DOMStringMap, name: []const u8, page: *Page) !?[]const u8 { +fn getProperty(self: *DOMStringMap, name: []const u8, page: *Page) !?[]const u8 { const attr_name = try camelToKebab(page.call_arena, name); return try self._element.getAttribute(attr_name, page); } -fn _setProperty(self: *DOMStringMap, name: []const u8, value: []const u8, page: *Page) !void { +fn setProperty(self: *DOMStringMap, name: []const u8, value: []const u8, page: *Page) !void { const attr_name = try camelToKebab(page.call_arena, name); return self._element.setAttributeSafe(attr_name, value, page); } -fn _deleteProperty(self: *DOMStringMap, name: []const u8, page: *Page) !void { +fn deleteProperty(self: *DOMStringMap, name: []const u8, page: *Page) !void { const attr_name = try camelToKebab(page.call_arena, name); try self._element.removeAttribute(attr_name, page); } @@ -101,5 +101,5 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const @"[]" = bridge.namedIndexed(_getProperty, _setProperty, _deleteProperty, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.namedIndexed(getProperty, setProperty, deleteProperty, .{ .null_as_undefined = true }); }; diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 58fc9e8f9..87f794f5c 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -99,6 +99,14 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void { const res = self._response; const header = transfer.response_header.?; + if (comptime IS_DEBUG) { + log.debug(.http, "request header", .{ + .source = "xhr", + .url = self._url, + .status = header.status, + }); + } + res._status = header.status; res._url = try self._page.arena.dupeZ(u8, std.mem.span(header.url)); res._is_redirected = header.redirect_count > 0; @@ -135,6 +143,14 @@ fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { fn httpDoneCallback(ctx: *anyopaque) !void { const self: *Fetch = @ptrCast(@alignCast(ctx)); self._response._body = self._buf.items; + + log.info(.http, "request complete", .{ + .source = "fetch", + .url = self._url, + .status = self._response._status, + .len = self._buf.items.len, + }); + return self._resolver.resolve("fetch done", self._response); } diff --git a/src/main.zig b/src/main.zig index b43ea92cf..06f65a014 100644 --- a/src/main.zig +++ b/src/main.zig @@ -305,6 +305,11 @@ const Command = struct { \\--log_format The log format: pretty or logfmt. \\ Defaults to ++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++ + \\ + \\ + \\--log_filter_scopes + \\ Filter out too verbose logs per scope: + \\ http, unknown_prop, event, ... \\ \\ --user_agent_suffix \\ Suffix to append to the Lightpanda/X.Y User-Agent From ba4900b61f6d6b47db43967c1c634ea443a35222 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 18 Dec 2025 21:14:41 +0800 Subject: [PATCH 242/257] import template parsing test from 'legacy' --- src/browser/tests/element/html/template.html | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/html/template.html b/src/browser/tests/element/html/template.html index 2a92534f7..0c1d46d2f 100644 --- a/src/browser/tests/element/html/template.html +++ b/src/browser/tests/element/html/template.html @@ -212,4 +212,19 @@

Hello Template

// NOT the DocumentFragment, so it should be empty testing.expectEqual('', template.textContent); } - --> + + + + From c3f8f9de544347e35287faef744bcb6f8d6cb0ad Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 18 Dec 2025 21:17:13 +0800 Subject: [PATCH 243/257] merge https://github.com/lightpanda-io/browser/pull/1275 --- src/TestHTTPServer.zig | 5 +++++ src/main_wpt.zig | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/TestHTTPServer.zig b/src/TestHTTPServer.zig index fdf4e1247..9750a396d 100644 --- a/src/TestHTTPServer.zig +++ b/src/TestHTTPServer.zig @@ -132,6 +132,11 @@ fn getContentType(file_path: []const u8) []const u8 { return "text/xml"; } + if (std.mem.endsWith(u8, file_path, ".mjs")) { + // mjs are ECMAScript modules + return "application/json"; + } + std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); return "text/html"; } diff --git a/src/main_wpt.zig b/src/main_wpt.zig index 5e334a224..69cccb682 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -581,6 +581,11 @@ const TestHTTPServer = struct { return "text/xml"; } + if (std.mem.endsWith(u8, file_path, ".mjs")) { + // mjs are ECMAScript modules + return "application/json"; + } + std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); return "text/html"; } From 098eeea8f7ab3441eaf4656794e753fcdb489aac Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 07:18:47 +0800 Subject: [PATCH 244/257] remove some mimalloc, netsurf and iconv references --- .github/actions/install/action.yml | 26 -------------------------- Dockerfile | 4 ---- README.md | 6 ++---- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 013272024..787a45f53 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -58,29 +58,3 @@ runs: run: | mkdir -p v8 ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8.a - - - name: Cache libiconv - id: cache-libiconv - uses: actions/cache@v4 - env: - cache-name: cache-libiconv - with: - path: ${{ inputs.cache-dir }}/libiconv - key: vendor/libiconv/libiconv-1.17 - - - name: download libiconv - if: ${{ steps.cache-libiconv.outputs.cache-hit != 'true' }} - shell: bash - run: make download-libiconv - - - name: build libiconv - shell: bash - run: make build-libiconv - - - name: build mimalloc - shell: bash - run: make install-mimalloc - - - name: build netsurf - shell: bash - run: make install-netsurf diff --git a/Dockerfile b/Dockerfile index 2ce342b23..916b9720a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,10 +39,6 @@ RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \ RUN git submodule init && \ git submodule update --recursive -RUN make install-libiconv && \ - make install-netsurf && \ - make install-mimalloc - # download and install v8 RUN case $TARGETPLATFORM in \ "linux/arm64") ARCH="aarch64" ;; \ diff --git a/README.md b/README.md index 32340860f..a54d6be55 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,8 @@ Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to install it with the right version in order to build the project. Lightpanda also depends on -[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8), -[Libcurl](https://curl.se/libcurl/), -[Netsurf libs](https://www.netsurf-browser.org/) and -[Mimalloc](https://microsoft.github.io/mimalloc). +[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8) and +[Libcurl](https://curl.se/libcurl/). To be able to build the v8 engine for zig-js-runtime, you have to install some libs: From c15ef590c2f4c3b572b63e1dfeba072db7e239cf Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 08:16:36 +0800 Subject: [PATCH 245/257] build html5ever in CI --- .github/actions/install/action.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 787a45f53..89af62f75 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -58,3 +58,13 @@ runs: run: | mkdir -p v8 ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8.a + + - name: hmtl5ever release + if: ${{ inputs.mode == 'release' }} + shell: bash + run: zig build -Doptimize=ReleaseFast html5ever + + - name: hmtl5ever debug + if: ${{ inputs.mode == 'debug' }} + shell: bash + run: zig build html5ever From 520e197e0ec654da7ed3fe0cfcf449f700fe0cec Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 08:25:22 +0800 Subject: [PATCH 246/257] build html5ever in CI --- .github/actions/install/action.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 89af62f75..067d27e4b 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -59,12 +59,8 @@ runs: mkdir -p v8 ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8.a - - name: hmtl5ever release - if: ${{ inputs.mode == 'release' }} + - name: hmtl5ever shell: bash - run: zig build -Doptimize=ReleaseFast html5ever - - - name: hmtl5ever debug - if: ${{ inputs.mode == 'debug' }} - shell: bash - run: zig build html5ever + run: | + zig build html5ever + zig build -Doptimize=ReleaseFast html5ever From 566fa72bcda1a62bcd5eb111cfc0c4626b0f6183 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 10:05:42 +0800 Subject: [PATCH 247/257] various small backports from main --- README.md | 2 +- src/browser/Page.zig | 70 +++++++++++++++++++++++++++++++++++ src/browser/Scheduler.zig | 6 +-- src/browser/ScriptManager.zig | 4 +- src/main.zig | 2 +- 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a54d6be55..79194354c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Lightpanda is the open-source browser made for headless usage: - Javascript execution - Support of Web APIs (partial, WIP) -- Compatible with Playwright[^1], Puppeteer, chromedp through CDP +- Compatible with Playwright[^1], Puppeteer, chromedp through [CDP](https://chromedevtools.github.io/devtools-protocol/) Fast web automation for AI agents, LLM training, scraping and testing: diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b47f5f4b3..c7b453d87 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -769,6 +769,76 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { } } +fn printWaitAnalysis(self: *Page) void { + std.debug.print("load_state: {s}\n", .{@tagName(self._load_state)}); + std.debug.print("parse_state: {s}\n", .{@tagName(std.meta.activeTag(self._parse_state))}); + { + std.debug.print("\nactive requests: {d}\n", .{self._session.browser.http_client.active}); + var n_ = self._session.browser.http_client.handles.in_use.first; + while (n_) |n| { + const handle: *Http.Client.Handle = @fieldParentPtr("node", n); + const transfer = Http.Transfer.fromEasy(handle.conn.easy) catch |err| { + std.debug.print(" - failed to load transfer: {any}\n", .{err}); + break; + }; + std.debug.print(" - {f}\n", .{transfer}); + n_ = n.next; + } + } + + { + std.debug.print("\nqueued requests: {d}\n", .{self._session.browser.http_client.queue.len()}); + var n_ = self._session.browser.http_client.queue.first; + while (n_) |n| { + const transfer: *Http.Transfer = @fieldParentPtr("_node", n); + std.debug.print(" - {f}\n", .{transfer}); + n_ = n.next; + } + } + + + { + std.debug.print("\ndeferreds: {d}\n", .{self._script_manager.defer_scripts.len()}); + var n_ = self._script_manager.defer_scripts.first; + while (n_) |n| { + const script: *ScriptManager.Script = @fieldParentPtr("node", n); + std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete }); + n_ = n.next; + } + } + + { + std.debug.print("\nasyncs: {d}\n", .{self._script_manager.async_scripts.len()}); + } + + { + std.debug.print("\nasyncs ready: {d}\n", .{self._script_manager.ready_scripts.len()}); + var n_ = self._script_manager.ready_scripts.first; + while (n_) |n| { + const script: *ScriptManager.Script = @fieldParentPtr("node", n); + std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete }); + n_ = n.next; + } + } + + const now = milliTimestamp(.monotonic); + { + std.debug.print("\nhigh_priority schedule: {d}\n", .{self.scheduler.high_priority.count()}); + var it = self.scheduler.high_priority.iterator(); + while (it.next()) |task| { + std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); + } + } + + { + std.debug.print("\nlow_priority schedule: {d}\n", .{self.scheduler.low_priority.count()}); + var it = self.scheduler.low_priority.iterator(); + while (it.next()) |task| { + std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); + } + } +} + pub fn tick(self: *Page) void { if (comptime IS_DEBUG) { log.debug(.page, "tick", .{}); diff --git a/src/browser/Scheduler.zig b/src/browser/Scheduler.zig index 78a7ca1e4..01ada86c9 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/Scheduler.zig @@ -20,7 +20,7 @@ const std = @import("std"); const builtin = @import("builtin"); const log = @import("../log.zig"); -const timestamp = @import("../datetime.zig").milliTimestamp; +const milliTimestamp = @import("../datetime.zig").milliTimestamp; const IS_DEBUG = builtin.mode == .Debug; @@ -71,7 +71,7 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts .callback = cb, .sequence = seq, .name = opts.name, - .run_at = timestamp(.monotonic) + run_in_ms, + .run_at = milliTimestamp(.monotonic) + run_in_ms, }); } @@ -85,7 +85,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { return null; } - const now = timestamp(.monotonic); + const now = milliTimestamp(.monotonic); while (queue.peek()) |*task_| { if (task_.run_at > now) { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 5f916fedc..cfd5d568f 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -360,7 +360,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const // This seems wrong since we're not dealing with an async import (unlike // getAsyncModule below), but all we're trying to do here is pre-load the - // script for execution at some point in the future (when waitForModule is + // script for execution at some point in the future (when waitForImport is // called). self.async_scripts.append(&script.node); } @@ -564,7 +564,7 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void { } } -const Script = struct { +pub const Script = struct { complete: bool, kind: Kind, status: u16 = 0, diff --git a/src/main.zig b/src/main.zig index 06f65a014..f01019f53 100644 --- a/src/main.zig +++ b/src/main.zig @@ -311,7 +311,7 @@ const Command = struct { \\ Filter out too verbose logs per scope: \\ http, unknown_prop, event, ... \\ - \\ --user_agent_suffix + \\--user_agent_suffix \\ Suffix to append to the Lightpanda/X.Y User-Agent \\ ; From 7a69e3fc9bf3113be5795a3384943da417c6507e Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Oct 2025 15:00:00 +0100 Subject: [PATCH 248/257] cdp: add browser permissions noop --- src/cdp/domains/browser.zig | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cdp/domains/browser.zig b/src/cdp/domains/browser.zig index 236b3b9c2..f86feefe6 100644 --- a/src/cdp/domains/browser.zig +++ b/src/cdp/domains/browser.zig @@ -38,16 +38,22 @@ const DEV_TOOLS_WINDOW_ID = 1923710101; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getVersion, - setDownloadBehavior, - getWindowForTarget, + setPermission, setWindowBounds, + resetPermissions, + grantPermissions, + getWindowForTarget, + setDownloadBehavior, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getVersion => return getVersion(cmd), - .setDownloadBehavior => return setDownloadBehavior(cmd), - .getWindowForTarget => return getWindowForTarget(cmd), + .setPermission => return setPermission(cmd), .setWindowBounds => return setWindowBounds(cmd), + .resetPermissions => return resetPermissions(cmd), + .grantPermissions => return grantPermissions(cmd), + .getWindowForTarget => return getWindowForTarget(cmd), + .setDownloadBehavior => return setDownloadBehavior(cmd), } } @@ -89,6 +95,21 @@ fn setWindowBounds(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +// TODO: noop method +fn grantPermissions(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} + +// TODO: noop method +fn setPermission(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} + +// TODO: noop method +fn resetPermissions(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} + const testing = @import("../testing.zig"); test "cdp.browser: getVersion" { var ctx = testing.context(); From fe96bc7895a4d511e0a2a247d05374a2ae6fbd6c Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 19 Nov 2025 10:57:48 +0100 Subject: [PATCH 249/257] cdp: use default value for grantUniveralAccess In createIsolatedWorld, we set a default value to false for optional grantUniveralAccess parameter. --- src/cdp/domains/page.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index c27d3de78..9c5e48e41 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("../../browser/Page.zig"); const Notification = @import("../../Notification.zig"); +const log = @import("../../log.zig"); const timestampF = @import("../../datetime.zig").timestamp; const Allocator = std.mem.Allocator; @@ -139,7 +140,7 @@ fn createIsolatedWorld(cmd: anytype) !void { grantUniveralAccess: bool, })) orelse return error.InvalidParams; if (!params.grantUniveralAccess) { - std.debug.print("grantUniveralAccess == false is not yet implemented", .{}); + log.warn(.cdp, "not implemented", .{ .feature = "grantUniveralAccess == false is not yet implemented" }); // When grantUniveralAccess == false and the client attempts to resolve // or otherwise access a DOM or other JS Object from another context that should fail. } From a087386af3e581a81311102448142dce14cfb89f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 5 Dec 2025 13:49:02 +0100 Subject: [PATCH 250/257] cdp: implement DOM.requestNode --- src/cdp/domains/dom.zig | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index ea0c81ef5..feac20b76 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -22,6 +22,8 @@ const Node = @import("../Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); const Selector = @import("../../browser/webapi/selector/Selector.zig"); +const dump = @import("../../browser/dump.zig"); + const Allocator = std.mem.Allocator; pub fn processMessage(cmd: anytype) !void { @@ -40,6 +42,8 @@ pub fn processMessage(cmd: anytype) !void { getBoxModel, requestChildNodes, getFrameOwner, + getOuterHTML, + requestNode, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -57,6 +61,8 @@ pub fn processMessage(cmd: anytype) !void { .getBoxModel => return getBoxModel(cmd), .requestChildNodes => return requestChildNodes(cmd), .getFrameOwner => return getFrameOwner(cmd), + .getOuterHTML => return getOuterHTML(cmd), + .requestNode => return requestNode(cmd), } } @@ -482,6 +488,39 @@ fn getFrameOwner(cmd: anytype) !void { return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); } +fn getOuterHTML(cmd: anytype) !void { + const params = (try cmd.params(struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + objectId: ?[]const u8 = null, + includeShadowDOM: bool = false, + })) orelse return error.InvalidParams; + + if (params.includeShadowDOM) { + log.warn(.cdp, "not implemented", .{ .feature = "DOM.getOuterHTML: Not implemented includeShadowDOM parameter" }); + } + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); + + var aw = std.Io.Writer.Allocating.init(cmd.arena); + try dump.deep(node.dom, .{}, &aw.writer, page); + + return cmd.sendResult(.{ .outerHTML = aw.written() }, .{}); +} + +fn requestNode(cmd: anytype) !void { + const params = (try cmd.params(struct { + objectId: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const node = try getNode(cmd.arena, bc, null, null, params.objectId); + + return cmd.sendResult(.{ .nodeId = node.id }, .{}); +} + const testing = @import("../testing.zig"); test "cdp.dom: getSearchResults unknown search id" { var ctx = testing.context(); From bb1ea39c5454b0e7178af2890306d4da3faeb1ad Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 10:31:07 +0800 Subject: [PATCH 251/257] backport a variety of smaller CDP changes --- src/browser/Page.zig | 7 +++++ src/cdp/cdp.zig | 8 ++++++ src/cdp/domains/page.zig | 39 +++++++++++++++++++++++++ src/cdp/domains/target.zig | 59 +++++++++++++++++++++++++++++--------- 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c7b453d87..799052c3b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -283,6 +283,13 @@ fn registerBackgroundTasks(self: *Page) !void { }.runMessageLoop, 250, .{ .name = "page.messageLoop" }); } +pub fn getTitle(self: *Page) !?[]const u8 { + if (self.window._document.is(Document.HTMLDocument)) |html_doc| { + return try html_doc.getTitle(self); + } + return null; +} + pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self._session; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index a2ce7159e..302467885 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -469,6 +469,14 @@ pub fn BrowserContext(comptime CDP_T: type) type { return if (url.len == 0) null else url; } + pub fn getTitle(self: *const Self) ?[]const u8 { + const page = self.session.currentPage() orelse return null; + return page.getTitle() catch |err| { + log.err(.cdp, "page title", .{ .err = err }); + return null; + }; + } + pub fn networkEnable(self: *Self) !void { try self.cdp.browser.notification.register(.http_request_fail, self, onHttpRequestFail); try self.cdp.browser.notification.register(.http_request_start, self, onHttpRequestStart); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 9c5e48e41..7f0928ebb 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -33,6 +33,7 @@ pub fn processMessage(cmd: anytype) !void { createIsolatedWorld, navigate, stopLoading, + close, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -43,6 +44,7 @@ pub fn processMessage(cmd: anytype) !void { .createIsolatedWorld => return createIsolatedWorld(cmd), .navigate => return navigate(cmd), .stopLoading => return cmd.sendResult(null, .{}), + .close => return close(cmd), } } @@ -133,6 +135,43 @@ fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void { }, .{}); } +fn close(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + const target_id = bc.target_id orelse return error.TargetNotLoaded; + + // can't be null if we have a target_id + std.debug.assert(bc.session.page != null); + + try cmd.sendResult(.{}, .{}); + + // Following code is similar to target.closeTarget + // + // could be null, created but never attached + if (bc.session_id) |session_id| { + // Inspector.detached event + try cmd.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session_id }); + + // detachedFromTarget event + try cmd.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session_id, + .reason = "Render process gone.", + }, .{}); + + bc.session_id = null; + } + + bc.session.removePage(); + for (bc.isolated_worlds.items) |*world| { + world.deinit(); + } + bc.isolated_worlds.clearRetainingCapacity(); + bc.target_id = null; +} + fn createIsolatedWorld(cmd: anytype) !void { const params = (try cmd.params(struct { frameId: []const u8, diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index d6f672e29..7dd636e6e 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -24,6 +24,7 @@ const LOADER_ID = "LOADERID42AA389647D702B4D805F49A"; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { + getTargets, attachToTarget, closeTarget, createBrowserContext, @@ -38,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void { }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { + .getTargets => return getTargets(cmd), .attachToTarget => return attachToTarget(cmd), .closeTarget => return closeTarget(cmd), .createBrowserContext => return createBrowserContext(cmd), @@ -52,6 +54,31 @@ pub fn processMessage(cmd: anytype) !void { } } +fn getTargets(cmd: anytype) !void { + // Some clients like Stagehand expects to have an existing context. + const bc = cmd.browser_context orelse cmd.createBrowserContext() catch |err| switch (err) { + error.AlreadyExists => unreachable, + else => return err, + }; + + const target_id = bc.target_id orelse { + return cmd.sendResult(.{ + .targetInfos = [_]TargetInfo{}, + }, .{ .include_session_id = false }); + }; + + return cmd.sendResult(.{ + .targetInfos = [_]TargetInfo{.{ + .targetId = target_id, + .type = "page", + .title = bc.getTitle() orelse "about:blank", + .url = bc.getURL() orelse "about:blank", + .attached = true, + .canAccessOpener = false, + }}, + }, .{ .include_session_id = false }); +} + fn getBrowserContexts(cmd: anytype) !void { var browser_context_ids: []const []const u8 = undefined; if (cmd.browser_context) |bc| { @@ -168,7 +195,7 @@ fn createTarget(cmd: anytype) !void { .targetInfo = TargetInfo{ .attached = false, .targetId = target_id, - .title = params.url, + .title = "about:blank", .browserContextId = bc.id, .url = "about:blank", }, @@ -179,11 +206,13 @@ fn createTarget(cmd: anytype) !void { try doAttachtoTarget(cmd, target_id); } - try page.navigate( - params.url, - .{ .reason = .address_bar }, - .{ .push = null }, - ); + if (!std.mem.eql(u8, "about:blank", params.url)) { + try page.navigate( + params.url, + .{ .reason = .address_bar }, + .{ .push = null }, + ); + } try cmd.sendResult(.{ .targetId = target_id, @@ -206,7 +235,9 @@ fn attachToTarget(cmd: anytype) !void { return error.SessionAlreadyLoaded; } - try doAttachtoTarget(cmd, target_id); + if (bc.session_id == null) { + try doAttachtoTarget(cmd, target_id); + } return cmd.sendResult( .{ .sessionId = bc.session_id }, @@ -272,8 +303,8 @@ fn getTargetInfo(cmd: anytype) !void { .targetInfo = TargetInfo{ .targetId = target_id, .type = "page", - .title = "", - .url = "", + .title = bc.getTitle() orelse "about:blank", + .url = bc.getURL() orelse "about:blank", .attached = true, .canAccessOpener = false, }, @@ -284,8 +315,8 @@ fn getTargetInfo(cmd: anytype) !void { .targetInfo = TargetInfo{ .targetId = "TID-STARTUP-B", .type = "browser", - .title = "", - .url = "", + .title = "about:blank", + .url = "about:blank", .attached = true, .canAccessOpener = false, }, @@ -631,8 +662,8 @@ test "cdp.target: getTargetInfo" { try ctx.expectSentResult(.{ .targetInfo = .{ .type = "browser", - .title = "", - .url = "", + .title = "about:blank", + .url = "about:blank", .attached = true, .canAccessOpener = false, }, @@ -665,7 +696,7 @@ test "cdp.target: getTargetInfo" { .targetId = "TID-A", .type = "page", .title = "", - .url = "", + .url = "about:blank", .attached = true, .canAccessOpener = false, }, From 2ac90262b77e337d8f201c18e5412041d0eac54b Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 9 Dec 2025 14:32:17 +0100 Subject: [PATCH 252/257] ci: add nightly integration test --- .github/workflows/e2e-integration-test.yml | 68 ++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/e2e-integration-test.yml diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml new file mode 100644 index 000000000..5bb24d1fe --- /dev/null +++ b/.github/workflows/e2e-integration-test.yml @@ -0,0 +1,68 @@ +name: e2e-integration-test + +env: + LIGHTPANDA_DISABLE_TELEMETRY: true + +on: + schedule: + - cron: "4 4 * * *" + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + zig-build-release: + name: zig build release + + runs-on: ubuntu-latest + timeout-minutes: 15 + + # Don't run the CI with draft PR. + if: github.event.pull_request.draft == false + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # fetch submodules recusively, to get zig-js-runtime submodules also. + submodules: recursive + + - uses: ./.github/actions/install + + - name: zig build release + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + + - name: upload artifact + uses: actions/upload-artifact@v4 + with: + name: lightpanda-build-release + path: | + zig-out/bin/lightpanda + retention-days: 1 + + demo-scripts: + name: demo-integration-scripts + needs: zig-build-release + + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + repository: 'lightpanda-io/demo' + fetch-depth: 0 + + - run: npm install + + - name: download artifact + uses: actions/download-artifact@v4 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - name: run end to end integration tests + run: | + ./lightpanda serve & echo $! > LPD.pid + go run integration/main.go + kill `cat LPD.pid` From 33ee2fb1a04d5bf7fb852971bf77d2acfb7c34d4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 9 Dec 2025 14:05:00 +0100 Subject: [PATCH 253/257] ci: use macos-14-intel for building macos x86 macos-13 is unsupported. We Have to switch for payed instance. see https://github.com/actions/runner-images/issues/13046 --- .github/workflows/build.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b7f17c8e..609a9b8bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -141,12 +141,7 @@ jobs: ARCH: x86_64 OS: macos - # macos-13 runs on x86 CPU. see - # https://github.com/actions/runner-images?tab=readme-ov-file - # If we want to build for macos-14 or superior, we need to switch to - # macos-14-large. - # No need for now, but maybe we will need it in the short term. - runs-on: macos-13 + runs-on: macos-14-large timeout-minutes: 15 steps: From 1278dc28cd76847f4c004f704f573c96424c62f4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 19 Nov 2025 12:07:42 +0100 Subject: [PATCH 254/257] cdp: add accessibility domain --- src/cdp/cdp.zig | 5 ++++ src/cdp/domains/accessibility.zig | 38 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/cdp/domains/accessibility.zig diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 302467885..2fbfe4dd7 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -231,6 +231,11 @@ pub fn CDPT(comptime TypeProvider: type) type { asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command), else => {}, }, + 13 => switch (@as(u104, @bitCast(domain[0..13].*))) { + asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command), + else => {}, + }, + else => {}, } diff --git a/src/cdp/domains/accessibility.zig b/src/cdp/domains/accessibility.zig new file mode 100644 index 000000000..a2132f43d --- /dev/null +++ b/src/cdp/domains/accessibility.zig @@ -0,0 +1,38 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + disable, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .enable => return enable(cmd), + .disable => return disable(cmd), + } +} +fn enable(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} + +fn disable(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} From f475aa09e8553b2a2f5c8921c36158008e218387 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 16:06:25 +0800 Subject: [PATCH 255/257] backport https://github.com/lightpanda-io/browser/pull/1265 --- src/Notification.zig | 28 ++++++++----- src/browser/Mime.zig | 28 +++++++++++-- src/browser/Page.zig | 53 ++++++++++++++++++++---- src/browser/js/Context.zig | 6 +-- src/cdp/cdp.zig | 3 +- src/cdp/domains/network.zig | 34 ++++++++++++--- src/cdp/domains/page.zig | 82 ++++++++++++++++++++----------------- src/http/Client.zig | 26 +++++++++++- 8 files changed, 191 insertions(+), 69 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index dea1d5499..1f364d03f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -108,14 +108,17 @@ const EventType = std.meta.FieldEnum(Events); pub const PageRemove = struct {}; pub const PageNavigate = struct { + req_id: usize, timestamp: u64, url: [:0]const u8, opts: Page.NavigateOpts, }; pub const PageNavigated = struct { + req_id: usize, timestamp: u64, url: [:0]const u8, + opts: Page.NavigatedOpts, }; pub const PageNetworkIdle = struct { @@ -314,6 +317,7 @@ test "Notification" { // noop notifier.dispatch(.page_navigate, &.{ + .req_id = 1, .timestamp = 4, .url = undefined, .opts = .{}, @@ -323,6 +327,7 @@ test "Notification" { try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); notifier.dispatch(.page_navigate, &.{ + .req_id = 1, .timestamp = 4, .url = undefined, .opts = .{}, @@ -331,6 +336,7 @@ test "Notification" { notifier.unregisterAll(&tc); notifier.dispatch(.page_navigate, &.{ + .req_id = 1, .timestamp = 10, .url = undefined, .opts = .{}, @@ -340,21 +346,23 @@ test "Notification" { try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); try notifier.register(.page_navigated, &tc, TestClient.pageNavigated); notifier.dispatch(.page_navigate, &.{ + .req_id = 1, .timestamp = 10, .url = undefined, .opts = .{}, }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 6, .url = undefined }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} }); try testing.expectEqual(14, tc.page_navigate); try testing.expectEqual(6, tc.page_navigated); notifier.unregisterAll(&tc); notifier.dispatch(.page_navigate, &.{ + .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{}, }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 100, .url = undefined }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); try testing.expectEqual(14, tc.page_navigate); try testing.expectEqual(6, tc.page_navigated); @@ -362,27 +370,27 @@ test "Notification" { // unregister try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); try notifier.register(.page_navigated, &tc, TestClient.pageNavigated); - notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined }); + notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(1006, tc.page_navigated); notifier.unregister(.page_navigate, &tc); - notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined }); + notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); notifier.unregister(.page_navigated, &tc); - notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined }); + notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); // already unregistered, try anyways notifier.unregister(.page_navigated, &tc); - notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} }); - notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined }); + notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); + notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); } diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 27fe35a85..144f6b23d 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -24,6 +24,7 @@ params: []const u8 = "", // IANA defines max. charset value length as 40. // We keep 41 for null-termination since HTML parser expects in this format. charset: [41]u8 = default_charset, +charset_len: usize = 5, /// String "UTF-8" continued by null characters. pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; @@ -53,9 +54,25 @@ pub const ContentType = union(ContentTypeEnum) { other: struct { type: []const u8, sub_type: []const u8 }, }; +pub fn contentTypeString(mime: *const Mime) []const u8 { + return switch (mime.content_type) { + .text_xml => "text/xml", + .text_html => "text/html", + .text_javascript => "application/javascript", + .text_plain => "text/plain", + .text_css => "text/css", + .application_json => "application/json", + else => "", + }; +} + /// Returns the null-terminated charset value. -pub fn charsetString(mime: *const Mime) [:0]const u8 { - return @ptrCast(&mime.charset); +pub fn charsetStringZ(mime: *const Mime) [:0]const u8 { + return mime.charset[0..mime.charset_len :0]; +} + +pub fn charsetString(mime: *const Mime) []const u8 { + return mime.charset[0..mime.charset_len]; } /// Removes quotes of value if quotes are given. @@ -99,6 +116,7 @@ pub fn parse(input: []u8) !Mime { const params = trimLeft(normalized[type_len..]); var charset: [41]u8 = undefined; + var charset_len: usize = undefined; var it = std.mem.splitScalar(u8, params, ';'); while (it.next()) |attr| { @@ -124,6 +142,7 @@ pub fn parse(input: []u8) !Mime { @memcpy(charset[0..attribute_value.len], attribute_value); // Null-terminate right after attribute value. charset[attribute_value.len] = 0; + charset_len = attribute_value.len; }, } } @@ -131,6 +150,7 @@ pub fn parse(input: []u8) !Mime { return .{ .params = params, .charset = charset, + .charset_len = charset_len, .content_type = content_type, }; } @@ -510,9 +530,9 @@ fn expect(expected: Expectation, input: []const u8) !void { if (expected.charset) |ec| { // We remove the null characters for testing purposes here. - try testing.expectEqual(ec, actual.charsetString()[0..ec.len]); + try testing.expectEqual(ec, actual.charsetString()); } else { const m: Mime = .unknown; - try testing.expectEqual(m.charsetString(), actual.charsetString()); + try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ()); } } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 799052c3b..ecff9bec9 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -161,6 +161,9 @@ version: usize, scheduler: Scheduler, +_req_id: ?usize = null, +_navigated_options: ?NavigatedOpts = null, + pub fn init(arena: Allocator, call_arena: Allocator, session: *Session) !*Page { if (comptime IS_DEBUG) { log.debug(.page, "page.init", .{}); @@ -290,6 +293,17 @@ pub fn getTitle(self: *Page) !?[]const u8 { return null; } +pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { + const URLRaw = @import("URL.zig"); + return try URLRaw.getOrigin(allocator, self.url); +} + +pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { + const URLRaw = @import("URL.zig"); + const current_origin = (try URLRaw.getOrigin(self.call_arena, self.url)) orelse return false; + return std.mem.startsWith(u8, url, current_origin); +} + pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self._session; @@ -323,11 +337,13 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind try self.reset(false); } + const req_id = self._session.browser.http_client.nextReqId(); log.info(.page, "navigate", .{ .url = request_url, .method = opts.method, .reason = opts.reason, .body = opts.body != null, + .req_id = req_id, }); // if the url is about:blank, we load an empty HTML document in the @@ -342,16 +358,25 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind self.documentIsComplete(); self._session.browser.notification.dispatch(.page_navigate, &.{ + .req_id = req_id, .opts = opts, .url = request_url, .timestamp = timestamp(.monotonic), }); self._session.browser.notification.dispatch(.page_navigated, &.{ + .req_id = req_id, + .opts = .{ + .cdp_id = opts.cdp_id, + .reason = opts.reason, + .method = opts.method, + }, .url = request_url, .timestamp = timestamp(.monotonic), }); + // force next request id manually b/c we won't create a real req. + _ = self._session.browser.http_client.incrReqId(); return; } @@ -359,6 +384,13 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind self.url = try self.arena.dupeZ(u8, request_url); + self._req_id = req_id; + self._navigated_options = .{ + .cdp_id = opts.cdp_id, + .reason = opts.reason, + .method = opts.method, + }; + var headers = try http_client.newHeaders(); if (opts.header) |hdr| { try headers.add(hdr); @@ -368,6 +400,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind // We dispatch page_navigate event before sending the request. // It ensures the event page_navigated is not dispatched before this one. self._session.browser.notification.dispatch(.page_navigate, &.{ + .req_id = req_id, .opts = opts, .url = self.url, .timestamp = timestamp(.monotonic), @@ -439,7 +472,14 @@ pub fn documentIsComplete(self: *Page) void { log.err(.page, "document is complete", .{ .err = err }); }; + if (IS_DEBUG) { + std.debug.assert(self._req_id != null); + std.debug.assert(self._navigated_options != null); + } + self._session.browser.notification.dispatch(.page_navigated, &.{ + .req_id = self._req_id.?, + .opts = self._navigated_options.?, .url = self.url, .timestamp = timestamp(.monotonic), }); @@ -803,7 +843,6 @@ fn printWaitAnalysis(self: *Page) void { } } - { std.debug.print("\ndeferreds: {d}\n", .{self._script_manager.defer_scripts.len()}); var n_ = self._script_manager.defer_scripts.first; @@ -2254,12 +2293,6 @@ const IdleNotification = union(enum) { } }; -pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { - const URLRaw = @import("URL.zig"); - const current_origin = (try URLRaw.getOrigin(self.call_arena, self.url)) orelse return false; - return std.mem.startsWith(u8, url, current_origin); -} - pub const NavigateReason = enum { anchor, address_bar, @@ -2278,6 +2311,12 @@ pub const NavigateOpts = struct { force: bool = false, }; +pub const NavigatedOpts = struct { + cdp_id: ?i64 = null, + reason: NavigateReason = .address_bar, + method: Http.Method = .GET, +}; + const RequestCookieOpts = struct { is_http: bool = true, is_navigation: bool = false, diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 2d1a4cbd3..0fc88c0f9 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1028,7 +1028,7 @@ const valueToStringOpts = struct { pub fn valueToString(self: *const Context, js_val: v8.Value, opts: valueToStringOpts) ![]u8 { const allocator = opts.allocator orelse self.call_arena; if (js_val.isSymbol()) { - const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym = v8.Symbol{ .handle = js_val.handle }; const js_sym_desc = js_sym.getDescription(self.isolate); return self.valueToString(js_sym_desc, .{}); } @@ -1039,7 +1039,7 @@ pub fn valueToString(self: *const Context, js_val: v8.Value, opts: valueToString pub fn valueToStringZ(self: *const Context, js_val: v8.Value, opts: valueToStringOpts) ![:0]u8 { const allocator = opts.allocator orelse self.call_arena; if (js_val.isSymbol()) { - const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym = v8.Symbol{ .handle = js_val.handle }; const js_sym_desc = js_sym.getDescription(self.isolate); return self.valueToStringZ(js_sym_desc, .{}); } @@ -1094,7 +1094,7 @@ fn _debugValue(self: *const Context, js_val: v8.Value, seen: *std.AutoHashMapUnm } if (js_val.isSymbol()) { - const js_sym = v8.Symbol{.handle = js_val.handle}; + const js_sym = v8.Symbol{ .handle = js_val.handle }; const js_sym_desc = js_sym.getDescription(self.isolate); const js_sym_str = try self.valueToString(js_sym_desc, .{}); return writer.print("{s} (symbol)", .{js_sym_str}); diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 2fbfe4dd7..4bb9dc537 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -552,7 +552,8 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn onPageNavigated(ctx: *anyopaque, msg: *const Notification.PageNavigated) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - return @import("domains/page.zig").pageNavigated(self, msg); + defer self.resetNotificationArena(); + return @import("domains/page.zig").pageNavigated(self.notification_arena, self, msg); } pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index ea4ebf609..30f772340 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -23,6 +23,7 @@ const CdpStorage = @import("storage.zig"); const URL = @import("../../browser/URL.zig"); const Transfer = @import("../../http/Client.zig").Transfer; const Notification = @import("../../Notification.zig"); +const Mime = @import("../../browser/Mime.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -240,14 +241,19 @@ pub fn httpRequestStart(arena: Allocator, bc: anytype, msg: *const Notification. } const transfer = msg.transfer; + const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}); + // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.requestWillBeSent", .{ - .requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}), + .requestId = loader_id, .frameId = target_id, - .loaderId = bc.loader_id, - .documentUrl = page.url, + .loaderId = loader_id, + .type = msg.transfer.req.resource_type.string(), + .documentURL = page.url, .request = TransferAsRequestWriter.init(transfer), .initiator = .{ .type = "other" }, + .redirectHasExtraInfo = false, // TODO change after adding Network.requestWillBeSentExtraInfo + .hasUserGesture = false, }, .{ .session_id = session_id }); } @@ -257,12 +263,16 @@ pub fn httpResponseHeaderDone(arena: Allocator, bc: anytype, msg: *const Notific const session_id = bc.session_id orelse return; const target_id = bc.target_id orelse unreachable; + const transfer = msg.transfer; + const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}); + // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.responseReceived", .{ - .requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{msg.transfer.id}), - .loaderId = bc.loader_id, + .requestId = loader_id, .frameId = target_id, + .loaderId = loader_id, .response = TransferAsResponseWriter.init(arena, msg.transfer), + .hasExtraInfo = false, // TODO change after adding Network.responseReceivedExtraInfo }, .{ .session_id = session_id }); } @@ -381,6 +391,20 @@ const TransferAsResponseWriter = struct { try jws.write(@as(std.http.Status, @enumFromInt(status)).phrase() orelse "Unknown"); } + { + const mime: Mime = blk: { + if (transfer.response_header.?.contentType()) |ct| { + break :blk try Mime.parse(ct); + } + break :blk .unknown; + }; + + try jws.objectField("mimeType"); + try jws.write(mime.contentTypeString()); + try jws.objectField("charset"); + try jws.write(mime.charsetString()); + } + { // chromedp doesn't like having duplicate header names. It's pretty // common to get these from a server (e.g. for Cache-Control), but diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 7f0928ebb..4fdbcbc64 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -18,9 +18,9 @@ const std = @import("std"); const Page = @import("../../browser/Page.zig"); +const timestampF = @import("../../datetime.zig").timestamp; const Notification = @import("../../Notification.zig"); const log = @import("../../log.zig"); -const timestampF = @import("../../datetime.zig").timestamp; const Allocator = std.mem.Allocator; @@ -51,7 +51,7 @@ pub fn processMessage(cmd: anytype) !void { const Frame = struct { id: []const u8, loaderId: []const u8, - url: [:0]const u8, + url: []const u8, domainAndRegistry: []const u8 = "", securityOrigin: []const u8, mimeType: []const u8 = "text/html", @@ -101,11 +101,10 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void { if (page._load_state == .complete) { const now = timestampF(.monotonic); - const http_client = page._session.browser.http_client; - try sendPageLifecycle(bc, "DOMContentLoaded", now); try sendPageLifecycle(bc, "load", now); + const http_client = page._session.browser.http_client; const http_active = http_client.active; const total_network_activity = http_active + http_client.intercepted; if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { @@ -176,7 +175,7 @@ fn createIsolatedWorld(cmd: anytype) !void { const params = (try cmd.params(struct { frameId: []const u8, worldName: []const u8, - grantUniveralAccess: bool, + grantUniveralAccess: bool = false, })) orelse return error.InvalidParams; if (!params.grantUniveralAccess) { log.warn(.cdp, "not implemented", .{ .feature = "grantUniveralAccess == false is not yet implemented" }); @@ -218,7 +217,6 @@ fn navigate(cmd: anytype) !void { } var page = bc.session.currentPage() orelse return error.PageNotLoaded; - bc.loader_id = bc.cdp.loader_id_gen.next(); try page.navigate(params.url, .{ .reason = .address_bar, @@ -231,8 +229,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa // things, but no session. const session_id = bc.session_id orelse return; - bc.loader_id = bc.cdp.loader_id_gen.next(); - const loader_id = bc.loader_id; + const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{event.req_id}); const target_id = bc.target_id orelse unreachable; bc.reset(); @@ -240,13 +237,13 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa var cdp = bc.cdp; const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", - .script, .history => "scriptInitiated", + .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", else => unreachable, }, - .address_bar, .navigation => null, + .address_bar => null, }; if (reason_) |reason| { try cdp.sendEvent("Page.frameScheduledNavigation", .{ @@ -276,6 +273,30 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa try cdp.sendEvent("Page.frameStartedLoading", .{ .frameId = target_id, }, .{ .session_id = session_id }); +} + +pub fn pageRemove(bc: anytype) !void { + // The main page is going to be removed, we need to remove contexts from other worlds first. + for (bc.isolated_worlds.items) |*isolated_world| { + try isolated_world.removeContext(); + } +} + +pub fn pageCreated(bc: anytype, page: *Page) !void { + for (bc.isolated_worlds.items) |*isolated_world| { + try isolated_world.createContextAndLoadPolyfills(page); + } +} + +pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.PageNavigated) !void { + // detachTarget could be called, in which case, we still have a page doing + // things, but no session. + const session_id = bc.session_id orelse return; + const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{event.req_id}); + const target_id = bc.target_id orelse unreachable; + const timestamp = event.timestamp; + + var cdp = bc.cdp; // Drivers are sensitive to the order of events. Some more than others. // The result for the Page.navigate seems like it _must_ come after @@ -302,6 +323,17 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa }, .{ .session_id = session_id }); } + const reason_: ?[]const u8 = switch (event.opts.reason) { + .anchor => "anchorClick", + .script, .history, .navigation => "scriptInitiated", + .form => switch (event.opts.method) { + .GET => "formSubmissionGet", + .POST => "formSubmissionPost", + else => unreachable, + }, + .address_bar => null, + }; + if (reason_ != null) { try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{ .frameId = target_id, @@ -319,8 +351,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa bc.inspector.contextCreated( page.js, "", - "", // @ZIGDOM - // try page.origin(arena), + try page.getOrigin(arena) orelse "", aux_data, true, ); @@ -336,37 +367,14 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa false, ); } -} - -pub fn pageRemove(bc: anytype) !void { - // The main page is going to be removed, we need to remove contexts from other worlds first. - for (bc.isolated_worlds.items) |*isolated_world| { - try isolated_world.removeContext(); - } -} - -pub fn pageCreated(bc: anytype, page: *Page) !void { - for (bc.isolated_worlds.items) |*isolated_world| { - try isolated_world.createContextAndLoadPolyfills(page); - } -} -pub fn pageNavigated(bc: anytype, event: *const Notification.PageNavigated) !void { - // detachTarget could be called, in which case, we still have a page doing - // things, but no session. - const session_id = bc.session_id orelse return; - const loader_id = bc.loader_id; - const target_id = bc.target_id orelse unreachable; - const timestamp = event.timestamp; - - var cdp = bc.cdp; // frameNavigated event try cdp.sendEvent("Page.frameNavigated", .{ .type = "Navigation", .frame = Frame{ .id = target_id, .url = event.url, - .loaderId = bc.loader_id, + .loaderId = loader_id, .securityOrigin = bc.security_origin, .secureContextType = bc.secure_context_type, }, diff --git a/src/http/Client.zig b/src/http/Client.zig index e9812cf4b..bb407e2bb 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -260,14 +260,23 @@ pub fn fulfillTransfer(self: *Client, transfer: *Transfer, status: u16, headers: return transfer.fulfill(status, headers, body); } +pub fn nextReqId(self: *Client) usize { + return self.next_request_id + 1; +} + +pub fn incrReqId(self: *Client) usize { + const id = self.next_request_id + 1; + self.next_request_id = id; + return id; +} + fn makeTransfer(self: *Client, req: Request) !*Transfer { errdefer req.headers.deinit(); const transfer = try self.transfer_pool.create(); errdefer self.transfer_pool.destroy(transfer); - const id = self.next_request_id + 1; - self.next_request_id = id; + const id = self.incrReqId(); transfer.* = .{ .arena = ArenaAllocator.init(self.allocator), .id = id, @@ -673,6 +682,19 @@ pub const Request = struct { xhr, script, fetch, + + // Allowed Values: Document, Stylesheet, Image, Media, Font, Script, + // TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, + // SignedExchange, Ping, CSPViolationReport, Preflight, FedCM, Other + // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType + pub fn string(self: ResourceType) []const u8 { + return switch (self) { + .document => "Document", + .xhr => "XHR", + .script => "Script", + .fetch => "Fetch", + }; + } }; }; From 3d6af216dc555a22eef5af6942a6f64c110059f2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 21:29:28 +0800 Subject: [PATCH 256/257] document.write, document.close, document.open Add support for both modes - parsing and post-parsing. In post-parsing mode, document.write implicitly calls document open, and document.open wipes the document. This mode is probably rarely, if ever, used. However, while parsing, document.write does not call document.open and does not remove all existing nodes. It just writes the html into the document where the parser is. That isn't something we can properly do..but we can hack it. We create a new DocumentFragment, parse the html into the document fragment, then transfer the children into the document where we currently are. Our hack probably doesn't work for some advance usage of document.write (e.g nested calls), but it should work for more common cases, e.g. injecting a script tag. --- src/browser/Page.zig | 2 +- src/browser/ScriptManager.zig | 5 + src/browser/tests/document/write.html | 161 ++++++++++++++++++++++++++ src/browser/webapi/Document.zig | 141 +++++++++++++++++++++- 4 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 src/browser/tests/document/write.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ecff9bec9..d6708668e 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -74,7 +74,7 @@ _session: *Session, _event_manager: EventManager, -_parse_mode: enum { document, fragment }, +_parse_mode: enum { document, fragment, document_write }, // See Attribute.List for what this is. TL;DR: proper DOM Attribute Nodes are // fat yet rarely needed. We only create them on-demand, but still need proper diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index cfd5d568f..4cdbdc8b4 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -727,6 +727,11 @@ pub const Script = struct { page.document._current_script = script_element; defer page.document._current_script = previous_script; + // Clear the document.write insertion point for this script + const previous_write_insertion_point = page.document._write_insertion_point; + page.document._write_insertion_point = null; + defer page.document._write_insertion_point = previous_write_insertion_point; + // inline scripts aren't cached. remote ones are. const cacheable = self.source == .remote; diff --git a/src/browser/tests/document/write.html b/src/browser/tests/document/write.html new file mode 100644 index 000000000..d9659e81b --- /dev/null +++ b/src/browser/tests/document/write.html @@ -0,0 +1,161 @@ + + + document.write Tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Before
+ + + + + + + + + + + + +
This will be removed by document.open()
+ + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 5f3fab951..1c1969a40 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -26,6 +26,7 @@ const URL = @import("../URL.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const Location = @import("Location.zig"); +const Parser = @import("../parser/Parser.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); const NodeFilter = @import("NodeFilter.zig"); @@ -34,8 +35,8 @@ const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); -pub const HTMLDocument = @import("HTMLDocument.zig"); pub const XMLDocument = @import("XMLDocument.zig"); +pub const HTMLDocument = @import("HTMLDocument.zig"); const Document = @This(); @@ -47,6 +48,8 @@ _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, _active_element: ?*Element = null, _style_sheets: ?*StyleSheetList = null, +_write_insertion_point: ?*Node = null, +_script_created_parser: ?Parser.Streaming = null, pub const Type = union(enum) { generic, @@ -233,8 +236,8 @@ pub fn getImplementation(_: *const Document) DOMImplementation { return .{}; } -pub fn createDocumentFragment(_: *const Document, page: *Page) !*@import("DocumentFragment.zig") { - return @import("DocumentFragment.zig").init(page); +pub fn createDocumentFragment(_: *const Document, page: *Page) !*Node.DocumentFragment { + return Node.DocumentFragment.init(page); } pub fn createComment(_: *const Document, data: []const u8, page: *Page) !*Node { @@ -401,6 +404,135 @@ pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const return result.items; } +pub fn write(self: *Document, text: []const []const u8, page: *Page) !void { + if (self._type == .xml) { + return error.InvalidStateError; + } + + const html = blk: { + var joined: std.ArrayList(u8) = .empty; + for (text) |str| { + try joined.appendSlice(page.call_arena, str); + } + break :blk joined.items; + }; + + if (self._current_script == null or page._load_state != .parsing) { + // Post-parsing (destructive behavior) + if (self._script_created_parser == null) { + _ = try self.open(page); + } + if (html.len > 0) { + self._script_created_parser.?.read(html); + } + return; + } + + // Inline script during parsing + const script = self._current_script.?; + const parent = script.asNode().parentNode() orelse return; + + // Our implemnetation is hacky. We'll write to a DocumentFragment, then + // append its children. + const fragment = try Node.DocumentFragment.init(page); + const fragment_node = fragment.asNode(); + + const previous_parse_mode = page._parse_mode; + page._parse_mode = .document_write; + defer page._parse_mode = previous_parse_mode; + + var parser = Parser.init(page.call_arena, fragment_node, page); + parser.parseFragment(html); + + // Extract children from wrapper HTML element (html5ever wraps fragments) + // https://github.com/servo/html5ever/issues/583 + const children = fragment_node._children orelse return; + const first = children.first(); + + // Collect all children to insert (to avoid iterator invalidation) + var children_to_insert: std.ArrayList(*Node) = .empty; + + var it = if (first.is(Element.Html.Html) == null) fragment_node.childrenIterator() else first.childrenIterator(); + while (it.next()) |child| { + try children_to_insert.append(page.call_arena, child); + } + + if (children_to_insert.items.len == 0) { + return; + } + + // Determine insertion point: + // - If _write_insertion_point is set, continue from there (subsequent write) + // - Otherwise, start after the script (first write) + var insert_after: ?*Node = self._write_insertion_point orelse script.asNode(); + + for (children_to_insert.items) |child| { + // Clear parent pointer (child is currently parented to fragment/HTML wrapper) + child._parent = null; + try page.insertNodeRelative(parent, child, .{ .after = insert_after.? }, .{}); + insert_after = child; + } + + page.domChanged(); + self._write_insertion_point = children_to_insert.getLast(); +} + +pub fn open(self: *Document, page: *Page) !*Document { + if (self._type == .xml) { + return error.InvalidStateError; + } + + if (page._load_state == .parsing) { + return self; + } + + if (self._script_created_parser != null) { + return self; + } + + // If we aren't parsing, then open clears the document. + const doc_node = self.asNode(); + + { + // Remove all children from document + var it = doc_node.childrenIterator(); + while (it.next()) |child| { + page.removeNode(doc_node, child, .{ .will_be_reconnected = false }); + } + } + + // reset the document + self._elements_by_id.clearAndFree(page.arena); + self._active_element = null; + self._style_sheets = null; + self._ready_state = .loading; + + self._script_created_parser = Parser.Streaming.init(page.arena, doc_node, page); + try self._script_created_parser.?.start(); + page._parse_mode = .document; + + return self; +} + +pub fn close(self: *Document, page: *Page) !void { + if (self._type == .xml) { + return error.InvalidStateError; + } + + if (self._script_created_parser == null) { + return; + } + + // done() calls html5ever_streaming_parser_finish which frees the parser + // We must NOT call deinit() after done() as that would be a double-free + self._script_created_parser.?.done(); + // Just null out the handle since done() already freed it + self._script_created_parser.?.handle = null; + self._script_created_parser = null; + + page.documentIsComplete(); +} + const ReadyState = enum { loading, interactive, @@ -463,6 +595,9 @@ pub const JsApi = struct { pub const prepend = bridge.function(Document.prepend, .{}); pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{}); pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{}); + pub const write = bridge.function(Document.write, .{ .dom_exception = true }); + pub const open = bridge.function(Document.open, .{ .dom_exception = true }); + pub const close = bridge.function(Document.close, .{ .dom_exception = true }); pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { From 29259c23d7fc8a0c769b9d8e7b6729ee17e488ef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 19 Dec 2025 21:36:09 +0800 Subject: [PATCH 257/257] update zig-v8-fork version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 916b9720a..ab61cf88b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.1.34 +ARG ZIG_V8=v0.1.37 ARG TARGETPLATFORM RUN apt-get update -yq && \