2444 Commits

Author SHA1 Message Date
Pierre Tachoire
e25c33eaa6 Merge pull request #1673 from arrufat/mcp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add Model Context Protocol (MCP) server support
2026-03-03 15:18:34 +01:00
Adrià Arrufat
7bddc0a89c mcp: remove search and over tools 2026-03-03 22:50:06 +09:00
Karl Seguin
403f42bf38 Merge pull request #1702 from arrufat/cdp-namespaces
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add LP domain for CDP and getMarkdown method
2026-03-03 18:08:45 +08:00
Adrià Arrufat
b2e301418f cdp.lp: use page.document instead of window._document 2026-03-03 17:11:16 +09:00
Adrià Arrufat
334a2e44a1 lp: simplify dom_node resolution in getMarkdown 2026-03-03 17:08:43 +09:00
Adrià Arrufat
c9121a03d2 cdp: move LP.getMarkdown test to lp domain 2026-03-03 16:39:31 +09:00
Adrià Arrufat
cc93180d57 cdp: add LP domain and getMarkdown method
This PR introduces a custom CDP domain 'LP' (Lightpanda) to expose browser-specific tools. The first method, 'LP.getMarkdown', allows retrieving a Markdown representation of the DOM or a specific node by its 'nodeId'. This is optimized for AI agents and LLM-based scraping tasks.
2026-03-03 16:35:48 +09:00
Pierre Tachoire
4062a425cb Merge pull request #1700 from lightpanda-io/minor_cleanup
Remove unused file and unused .gitignore paths
2026-03-03 08:09:44 +01:00
Karl Seguin
cce533ebb6 Merge pull request #1701 from arrufat/markdown-test-namespace
markdown: namespace tests
2026-03-03 14:21:24 +08:00
Adrià Arrufat
48df38cbfe mcp: improve evaluate error reporting and refactor tool result types 2026-03-03 15:17:59 +09:00
Adrià Arrufat
f982f073df mcp: optimize memory re-use and add thread safety to Server.sendResponse 2026-03-03 14:50:13 +09:00
Adrià Arrufat
34999f12ca mcp: migrate tests to expectJson 2026-03-03 14:40:20 +09:00
Adrià Arrufat
c8d5665653 mcp: use testing allocator in tests 2026-03-03 14:32:29 +09:00
Adrià Arrufat
ddebaf87d0 markdown: namespace tests 2026-03-03 14:22:45 +09:00
Adrià Arrufat
6b80cd6109 mcp: namespace tests 2026-03-03 14:19:36 +09:00
Karl Seguin
7635d8d2a5 Remove unused file and unused .gitignore paths 2026-03-03 12:08:53 +08:00
Adrià Arrufat
634e3e35a0 mcp: re-enable tests 2026-03-02 23:12:16 +09:00
Adrià Arrufat
da3dc58199 Merge branch 'main' into mcp 2026-03-02 23:01:55 +09:00
Adrià Arrufat
4f99df694b mcp: simplify minify and remove eval quota 2026-03-02 22:46:20 +09:00
Adrià Arrufat
982b8e2d72 mcp: remove redundant mcp from test references 2026-03-02 22:24:17 +09:00
Adrià Arrufat
6e7c8d7ae2 mcp: consolidate tests and streamline parameter parsing 2026-03-02 22:18:02 +09:00
Adrià Arrufat
3c858f522b mcp: simplify minify function 2026-03-02 22:04:55 +09:00
Adrià Arrufat
f2a30f8cdd mcp: don't forget to flush 2026-03-02 21:46:49 +09:00
Adrià Arrufat
43785bfab4 mcp: simplify handleList implementations 2026-03-02 21:30:47 +09:00
Adrià Arrufat
78edf6d324 mcp: simplify I/O architecture and remove test harness 2026-03-02 21:25:07 +09:00
Adrià Arrufat
73565c4493 mcp: optimize dispatching and simplify test harness
- Use StaticStringMap and enums for method, tool, and resource lookups.
- Implement comptime JSON minification for tool schemas.
- Refactor router and harness to use more efficient buffered polling.
- Consolidate integration tests and add synchronous unit tests.
2026-03-02 20:53:14 +09:00
Karl Seguin
8c37cac957 Merge pull request #1694 from lightpanda-io/client_abort_frame
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Allow frame-specific HTTP abort
2026-03-02 18:11:33 +08:00
Karl Seguin
eceab76b6f Merge pull request #1693 from lightpanda-io/nikneym/arena-pool-test
`ArenaPool`: make init configurable + add tests
2026-03-02 18:11:13 +08:00
Karl Seguin
1f81b6ddc4 Allow frame-specific HTTP abort
Needed for frame navigation. Implemented using some ugly comptime to avoid
duplication and avoid an runtime frame check when doing a full abort.
2026-03-02 18:00:55 +08:00
Halil Durak
52c3aadd24 ArenaPool: add tests 2026-03-02 12:56:10 +03:00
Halil Durak
ad87573d09 ArenaPool: make init configurable 2026-03-02 12:55:55 +03:00
Karl Seguin
20fbfc8544 Merge pull request #1689 from lightpanda-io/protect_xhr_abort_during_callback
Protect against transfer.abort() being called during callback
2026-03-02 17:42:07 +08:00
Karl Seguin
7695c8403f Merge pull request #1692 from lightpanda-io/rename_page_id_to_frame_id
Rename page.id to page._frame_id
2026-03-02 17:40:43 +08:00
Karl Seguin
421983d06e Merge pull request #1690 from lightpanda-io/event_dispatch_cleanup
Attempt to improve non-DOM EventTarget dispatching
2026-03-02 17:40:29 +08:00
Karl Seguin
328c681a8f Add transfer-specific "performing" flag
In the previous commits, two separte crash resolution conspired to introduce
1 tick delay in request handling.

When we're in a libcurl perform, we can't re-enter libcurl. So we added a
check before processing new requests to make sure we weren't "performing" and,
if we were, we'd queue the request (hence the 1 tick delay).

But for another issue, we set the same "performing" check when manually
triggering callbacks. This extended the situations where the above check fired
thus causing the 1-tick delay to happen under more (and even common) situation.

This commit improves this - instead of relying on the global "performing" check
when processing 1 transfer explicitly, we now have a per-transfer performing
check. This prevents the transfer from being deinitialized during a callback
but does not block requests from being started immediately.
2026-03-02 17:29:47 +08:00
Pierre Tachoire
48d94d0f68 Merge pull request #1688 from lightpanda-io/cdp_shutdown
Remove redundant CDP v8 shutdown
2026-03-02 09:35:40 +01:00
Karl Seguin
10ad5d763e Rename page.id to page._frame_id
This field was recently added and is used to generate correct frameIds in CDP
messages. They remain the same during a navigation event, so calling them
page.id might cause surprises since navigation events create new pages, but
retain the original id. Hence, frame_id is more accurate and hopefully less
surprising.

(This is a small cleanup prior to doing some iframe navigation work).
2026-03-02 16:21:29 +08:00
Pierre Tachoire
2a78c946e4 Merge pull request #1691 from lightpanda-io/wpt-timeout
adjust WPT timeout on CI
2026-03-02 09:14:50 +01:00
Adrià Arrufat
a7872aa054 mcp: improve robustness of server and test harness
- Refactor router and test harness for non-blocking I/O using buffered polling.
- Implement reliable test failure reporting from sub-threads to the main test runner.
- Encapsulate pipe management using idiomatic std.fs.File methods.
- Fix invalid JSON generation in resource streaming due to duplicate fields.
- Improve shutdown sequence for clean test exits.
2026-03-02 17:03:04 +09:00
Pierre Tachoire
5c228ae0a1 adjust WPT timeout on CI 2026-03-02 08:58:03 +01:00
Karl Seguin
ce73f7ac5a Attempt to improve non-DOM EventTarget dispatching
There are two main ways to dispatch events, both via the EventManager: dispatch
and dispatchWithFunction. dispatchWithFunction came about from having to
dispatch to function callbacks in addition to event listeners. Specifically,
firing the window.onload callback.

Since that original design, much has changed. Most significantly, with
https://github.com/lightpanda-io/browser/pull/1524 callbacks defined via
attributes became properly (I hope) integrated with the event dispatching.
Furthermore, the number of non-tree event targets (e.g. AbortSignal) has grown
significantly. Finally, dispatching an event is DOM-based event is pretty
complex, involving multiple phases and capturing the path.

The current design is largely correct, but non-obvious. This commit attempts to
improve the ergonomics of event dispatching.

`dispatchWithFunction` has been renamed to `dispatchDirect`. This function is
meant to be used with non-DOM event targets. It is optimized for having an event
path with a single target andh no bubbling/capture phase. In addition to being
a little more streamlined, `dispatchDirect` will internally turn a
`js.Function.Global` or `js.Function.Temp` into a local. This makes the callsite
simpler, but also provides optimization opportunity - not having to create
a new scope for the common case of having no callback/listener. This lays the
groundwork for having a `hasDirect` guard clause at the callsite to avoid
unnecessary event creation (todo in a follow up commit).

`dispatch` remains unchanged. While `dispatch` is primarily meant to handle the
DOM-based EventTarget, it will forward non-DOM EventTargets to `dispatchDirect`.
This is necessary since JS code can call `signal.dispatchEvent(....)`.

Two notes:
1 - The flow of dispatchDirect is an optimization. The spec makes no distinction
    between DOM and non-DOM based EventTargets.
2 - While the window (as an EventTarget) should probably be thought of as a
    DOM-based EventTarget, we use `dispatchDirect with it. This is because it
    sits at the root and thus can safely go through the faster `dispatchDirect`.
2026-03-02 15:11:02 +08:00
Adrià Arrufat
64107f5957 mcp: refactor for testability and add comprehensive test suite
- Refactor mcp.Server and router to accept injected I/O streams.
- Implement McpHarness for high-fidelity MCP integration testing.
- Add unit tests for protocol, tools, and resources modules.
- Add integration tests covering initialization, tool/resource execution, and error handling.
- Improve error reporting for malformed JSON requests.
2026-03-02 15:52:05 +09:00
Adrià Arrufat
8a1795d56f mcp: fix memory leak in links tool 2026-03-02 13:09:58 +09:00
Karl Seguin
b104c3bfe8 Don't start request during callback
Fixes a separate but similar issue to
https://github.com/lightpanda-io/browser/pull/1689

Specifically, it prevents starting a request from within a libcurl handler, thus
avoiding an illegal recursive call.

(This commit also removes the failed function call debug logging for
DOMExceptions, as these aren't particularly abnormal / log-worthy)
2026-03-02 12:04:02 +08:00
Karl Seguin
82e3f126ff Protect against transfer.abort() being called during callback
This was already handled in most cases, but not for a body-less response. It's
safe to call transfer.abort() during a callback, so long as the performing flag
is set to true. This was set during the normal libcurl callbacks, but for a
body-less response, we manually invoke the header_done_callback and were not
setting the performing flag.
2026-03-02 11:44:42 +08:00
Adrià Arrufat
175488563e mcp: remove browser message loop from processRequests 2026-03-02 12:25:33 +09:00
Adrià Arrufat
da51cdd11d Merge branch 'main' into mcp 2026-03-02 11:55:36 +09:00
Adrià Arrufat
a8a47b138f mcp: change browser from pointer to value 2026-03-02 11:50:56 +09:00
Adrià Arrufat
b63d4cf675 mcp: improve RawJson stringification and schema formatting
- Update `RawJson.jsonStringify` to parse and re-write JSON content, ensuring valid output.
- Reformat tool input schemas in `tools.zig` using multi-line string literals for better readability.
2026-03-02 11:47:02 +09:00
Karl Seguin
03b999c592 Remove redundant CDP v8 shutdown
https://github.com/lightpanda-io/browser/pull/1614 improved our shutdown
behavior so that microtasks associated with a context wouldn't fire after the
context was disposed of. This involved having context-specific microtasks,
pumping the message loop, and prevent re-entry.

The shutdown code in CDP already had much of this behavior built-in, but it has
now become redundant. Most importantly the CDP shutdown logic did not prevent
re-entry.

Removing this code fixes a flaky WPT crash. I didn't seem to be tied to a
specific test, but rather a cross-context/page use-after-free that was saw
prior to 1614. I could reproduce it reliably by running `/wasm/core/`.

I'll be honest, it isn't clear to me why _removing_ the CDP cleanup helps.
Running the message loop and microtask _before_ our normal shutdown might be
unnecessary, but why would it crash? I don't know, but the CDP path is slightly
different in that it also involves Inspector shutdown. So there's still
something about this flow I don't quite understand. And, at least for this case
the current flow seems "correct".
2026-03-02 10:24:07 +08:00
Adrià Arrufat
a91afab038 mcp: improve event loop and response handling
- Use an allocating writer in `sendResponse` to handle large payloads.
- Update the main loop to tick the HTTP client and cap poll timeouts.
- Update protocol version and minify tool input schemas.
2026-03-02 11:12:00 +09:00
Adrià Arrufat
d4747b5386 mcp: own the browser
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-02 10:10:08 +09:00
Adrià Arrufat
41b81c8b05 mcp: use io poll for stdin and integrate message loop
Replaces blocking stdin reads with `std.io.poll` to allow macrotasks to
run. Removes the stdout mutex as I/O is now serialized.
2026-03-02 10:04:23 +09:00
Pierre Tachoire
552831364d Merge pull request #1687 from lightpanda-io/ci-integration-test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: reduce log_level for integration test
2026-03-01 16:09:56 +01:00
Adrià Arrufat
42b5e32473 mcp: modernize I/O processing and reuse message buffer 2026-03-01 22:35:28 +09:00
Adrià Arrufat
e9c36fd6f8 mcp: use declarative static definitions for tools and resources 2026-03-01 21:56:48 +09:00
Adrià Arrufat
952dfbef36 mcp: use acquire/release ordering for server running flag 2026-03-01 21:39:38 +09:00
Adrià Arrufat
254984b600 mcp: use dynamic allocation for error messages in tools 2026-03-01 21:36:21 +09:00
Adrià Arrufat
8cbc58d257 mcp: unify error reporting and use named error codes 2026-03-01 21:29:59 +09:00
Adrià Arrufat
e6cc3e8c34 mcp: refactor tools handling 2026-03-01 21:18:28 +09:00
Karl Seguin
516335e0ed Merge pull request #1686 from lightpanda-io/load_event_iframe_fix
Fix load event for page with no external scripts but with iframes
2026-03-01 20:15:36 +08:00
Adrià Arrufat
01798ed7f8 mcp: use sentinel-terminated strings for tool params 2026-03-01 20:58:00 +09:00
Adrià Arrufat
fcad67a854 mcp: pre-initialize tools and resources on server startup 2026-03-01 20:44:11 +09:00
Adrià Arrufat
e359ffead0 mcp: propagate errors in tool schema parsing 2026-03-01 20:39:40 +09:00
Adrià Arrufat
eb09041859 mcp: resolve absolute URLs for links tool 2026-03-01 20:35:13 +09:00
Adrià Arrufat
b3d52c966d mcp: handle errors during resource and tool streaming 2026-03-01 20:23:23 +09:00
Pierre Tachoire
3fb8a14348 ci: reduce log_level for integration test 2026-03-01 11:22:37 +01:00
Karl Seguin
84a949e7c7 Fix load event for page with no external scripts but with iframes
Previously the "load" event happened when all external scripts were done. In the
case that there was no external script, the "load" event would fire immediately
after parsing.

With iframes, it now waits for external script AND iframes to complete but the
no-external-script code was never updated to consider iframes and would thus
fire load events prematurely.
2026-03-01 18:19:40 +08:00
Pierre Tachoire
eaf1cb26b2 Merge pull request #1685 from ireydiak/chore/remove-gitsubmodules-from-readme
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove outdated git submodule documentation and Makefile target
2026-03-01 11:01:24 +01:00
Pierre Tachoire
f37962d3de Merge pull request #1683 from lightpanda-io/dump_mode_wpt
Add a "wpt" dump mode
2026-03-01 11:00:29 +01:00
Pierre Tachoire
511e957d4b Merge pull request #1682 from lightpanda-io/iframe_document_open_fix
Noop when document.open is called during iframe parsing
2026-03-01 10:57:42 +01:00
Pierre Tachoire
71df03b729 Merge pull request #1681 from lightpanda-io/xhr_url_escape_mime_parse_lax
Escape XHR URL, Lax MIME parameter parsing
2026-03-01 10:55:50 +01:00
Pierre Tachoire
839052f4b8 Merge pull request #1680 from lightpanda-io/cdp_json_url
Correctly JSON encode URL
2026-03-01 10:55:05 +01:00
ireydiak
7c18d857f0 chore: removed gitsubmodules from README and Makefile 2026-02-28 12:36:40 -05:00
Adrià Arrufat
947e672d18 mcp: stream resource and tool content to JSON output 2026-02-28 23:04:22 +09:00
Adrià Arrufat
96942960a9 mcp: reuse arena allocator for message processing 2026-02-28 22:38:16 +09:00
Adrià Arrufat
8b0118e2c8 mcp: update logging scope to use mcp instead of app 2026-02-28 22:30:02 +09:00
Adrià Arrufat
5f9a7a5381 mcp: ignore unknown json fields and improve error reporting 2026-02-28 22:18:37 +09:00
Adrià Arrufat
6897d72c3e mcp: simplify request processing to single-threaded 2026-02-28 21:26:51 +09:00
Adrià Arrufat
aae9a505e0 mcp: promot Server.zig to file struct 2026-02-28 21:02:49 +09:00
Karl Seguin
45196e022b Add a "wpt" dump mode
Adds a not-documented "wpt" mode to --dump which outputs a formatted
report.cases.

This is meant to make working on a single WPT test case easier, particularly
with some coding tool. Claude recommended this output for its own use.

Instead of telling claude to start the browser in serve mode, then run the
wptrunner, and merge the two outputs (and then stop the server), you can do:

zig build run -- fetch --dump wpt "http://localhost:8000/dom/nodes/CharacterData-appendChild.html"

(you still need the wpt server up)
2026-02-28 19:08:58 +08:00
Karl Seguin
b9e4c44d63 Noop when document.open is called during iframe parsing
I'm not sure what the correct behavior is, but this fixes a WPT crash:
/html/browsers/sandboxing/sandbox-inherited-from-required-csp.html

The issue is iframe-specific as, with an iframe, you document.write can be
called during parsing when there's no document._current_script (because it's
being executed from the parent).
2026-02-28 18:05:03 +08:00
Karl Seguin
0a9e5b66ee Merge pull request #1679 from lightpanda-io/escape_data_uri
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Escape DataURIs
2026-02-28 14:45:12 +08:00
Karl Seguin
8b99e82743 Merge pull request #1678 from lightpanda-io/improve_atob
Re-implement forgiving base64 decode without intermediate allocation
2026-02-28 14:45:01 +08:00
Karl Seguin
059fb85e22 Escape XHR URL, Lax MIME parameter parsing
Follow up to https://github.com/lightpanda-io/browser/pull/1646 applies the
same change to XHR URLs.

Following specs, ignores unknown/invalid parameters of the Content-Type when
parsing the MIME (rather than rejecting the entire header).
2026-02-28 14:42:43 +08:00
Karl Seguin
8997df861a Merge pull request #1677 from lightpanda-io/mime_charset_default
Initialize charset to safe default
2026-02-28 14:30:14 +08:00
Karl Seguin
e65667963f Correctly JSON encode URL
I think this code comes from some serialization tweak from when everything was
an std.Uri and by switch to [:0]const u8 everywhere not only was the tweak
unecessary, it was also wrong - possibly resulting in the generation of
invalid JSON.
2026-02-28 12:48:45 +08:00
Karl Seguin
3d51667fc8 Escape DataURIs
Support forgiving base64 decoder

Support non-encoded DataURIs
2026-02-28 12:24:26 +08:00
Karl Seguin
7fc6e97cd8 Re-implement forgiving base64 decode without intermediate allocation
Was looking at, what I thought was a related issue, and started to extract this
code to re-use it (in DataURIs). Realized it could be written without the
intermediate allocation. Then I realized the dataURI issue is something else,
but wanted to keep this improvement.
2026-02-28 11:22:31 +08:00
Karl Seguin
1473e58a41 Initialize charset to safe default
Fixes a WPT crash (not sure which, but in `/fetch/content-type/`)
2026-02-28 10:42:53 +08:00
Karl Seguin
2394b2f44f Merge pull request #1676 from lightpanda-io/add-scroll-by
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add window.scrollBy
2026-02-28 07:03:39 +08:00
Karl Seguin
516bd98198 Merge pull request #1675 from lightpanda-io/reset-attribute-listener
Set a null attribute listener must remove existing value
2026-02-28 07:01:11 +08:00
Karl Seguin
7d8688a130 Merge pull request #1674 from lightpanda-io/atob-unpadded-base64
accept must accept unpadded data in atob
2026-02-28 07:00:00 +08:00
Pierre Tachoire
631ec70058 add window.scrollBy 2026-02-27 16:19:27 +01:00
Pierre Tachoire
6fd51cfdc0 Set a null attribute listener must remove existing value 2026-02-27 14:47:43 +01:00
Pierre Tachoire
6857b74623 accept must accept unpadded data in atob
according with https://infra.spec.whatwg.org/#forgiving-base64-decode
2026-02-27 14:31:03 +01:00
Adrià Arrufat
5ec4305a9f mcp: add optional url parameter to tools 2026-02-27 22:17:15 +09:00
Pierre Tachoire
88baff96d0 Merge pull request #1671 from lightpanda-io/custom_element_name
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix our custom element name validation
2026-02-27 14:08:13 +01:00
Pierre Tachoire
e871f0002b Merge pull request #1633 from lightpanda-io/wptrunner
remove WPT specific code
2026-02-27 13:02:47 +01:00
Karl Seguin
7358d48e35 Fix our custom element name validation
Passes all WPT tests:
/custom-elements/registries/valid-custom-element-names.html

Also, apply validation to whenDefined, which we were not doing.
2026-02-27 18:46:07 +08:00
Pierre Tachoire
178fbf0fca wpt: reduce concurrency 2026-02-27 11:37:44 +01:00
Karl Seguin
a50597ff27 Merge pull request #1669 from lightpanda-io/more_interned_strings
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Expand the strings we intern
2026-02-27 17:30:21 +08:00
Karl Seguin
e4cb78abee Merge pull request #1670 from lightpanda-io/cdata_sso
Change CData._data from []const to String (SSO)
2026-02-27 17:30:03 +08:00
Karl Seguin
732884a3b2 Merge pull request #1668 from lightpanda-io/selector_list_arena
Add RC support to NodeList
2026-02-27 17:29:06 +08:00
Karl Seguin
80f2c42c69 Merge pull request #1660 from lightpanda-io/fix_css_parse_overflow
Fix possible overflow when parsing floats without an integer
2026-02-27 17:27:56 +08:00
Pierre Tachoire
49a5a39659 Merge pull request #1666 from lightpanda-io/curl_github_repo
Use github repo for curl source
2026-02-27 10:09:37 +01:00
Pierre Tachoire
a4a7040b98 wpt: configure hosts manually for self host runner 2026-02-27 10:09:24 +01:00
Pierre Tachoire
de5a7d5b99 wpt: use auo-restart browser feature of wpt runner 2026-02-27 10:09:23 +01:00
Pierre Tachoire
3f92e388be allow insecure TLS when running WPT tests 2026-02-27 10:09:23 +01:00
Pierre Tachoire
25c941b847 use wptrunner and wpt HTTP server to run wpt tests 2026-02-27 10:09:23 +01:00
Pierre Tachoire
24b6934d3b remove WPT specific code
Using both lightpanda-io/wpt and lightpanda-io/demo/wptrunner remove the
need for code specific to run WPT from browser.
2026-02-27 10:09:07 +01:00
Karl Seguin
d286ab406c Merge pull request #1664 from lightpanda-io/storage-quota-limit
implement storage size limit per origin
2026-02-27 16:45:51 +08:00
Pierre Tachoire
ef6a7a6904 storage: maintain Lookup size correctly 2026-02-27 08:57:29 +01:00
Pierre Tachoire
c61eda0d24 crypto: use dom exception to return QuotaExceededError 2026-02-27 08:57:28 +01:00
Pierre Tachoire
ad226b6fb1 implement storage size limit per origin 2026-02-27 08:57:28 +01:00
Karl Seguin
24491f0dfe fix String copy/reference 2026-02-27 14:34:20 +08:00
Karl Seguin
870fd1654d Change CData._data from []const to String (SSO)
After looking at a handful of websites, the # of Text and Commend nodes
that are small (<= 12 bytes) is _really_ high. Ranging from 85% to 98%. I
thought that was high, but a lot of it is indentation or a sentence that's
broken down into multiple nodes, eg:

<div><b>sale!</b> <span class=price>$1.99</span> buy now<div>

So what looks like 1 sentence to us, is actually 3 text nodes.

On a typical website, we should see thousands of fewer allocations in the
page arena for the text in text nodes.
2026-02-27 12:53:54 +08:00
Karl Seguin
38bc912e4e Expand the strings we intern
Based on analysis of a handful of websites (amazon product, github, DDG, reddit)
2026-02-27 11:17:06 +08:00
Karl Seguin
315c9a2d92 Add RC support to NodeList
Most importantly, this allows the Selector.List to be self-contained with
an arena from the ArenaPool. Selector.List can be both relatively large
and relatively common, so moving it off the page.arena is a nice win.

Also applied this to ChildNodes, which is much smaller but could also be
called often.

I was initially going to hook into the v8::Object's internal fields to store
the referencing v8::Object. So the v8::Object representing the Iterator
would store the v8::Object representing the NodeList inside of its internal
field - which the GC would trace/detect/respect. And that is probably the
fastest and most v8-ish solution, but I couldn't come up with an elegant
solution. The best I had was having a "afterCreate" callback which passed
the v8 object (this is similar to the old postAttach callback we had, but
used for a different purpose). However, since "acquireRef" was recently
added to events, re-using that was much simpler and worked well.
2026-02-27 10:29:46 +08:00
Karl Seguin
a14ad6f700 Merge pull request #1659 from lightpanda-io/nodelist_enumerable
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Make NodeList enumerable
2026-02-27 08:26:59 +08:00
Karl Seguin
76dcdfb98c Use github repo for curl source
Since we already rely on github for builds, this removes a point of failure.
Also curl.se consistently fails from a VPS machine for me - not sure if
they're blocking IP ranges, but it works fine on github.
2026-02-27 07:02:06 +08:00
Karl Seguin
99c09ba8a1 Merge pull request #1657 from lightpanda-io/FileReader
Add FileReader
2026-02-27 06:58:49 +08:00
Karl Seguin
0f18b76813 Merge pull request #1665 from lightpanda-io/cookies-limit
add limit for cookie and jar size
2026-02-27 06:54:39 +08:00
Pierre Tachoire
8504e4cd22 add limit for cookie and jar size 2026-02-26 18:33:18 +01:00
Karl Seguin
ebe793e0e7 Merge pull request #1663 from lightpanda-io/more_header_callback_debugging
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add more properties to ScriptManager.Header recall
2026-02-26 19:15:59 +08:00
Pierre Tachoire
965c6cf4d9 Merge pull request #1662 from lightpanda-io/non_keyboard_keydown_event
Don't assume that a 'keydown' event is a KeyboardEvent
2026-02-26 11:55:54 +01:00
Pierre Tachoire
2b1ab3184e Merge pull request #1640 from lightpanda-io/nikneym/load-events-after-doc-complete
Dispatch `load` events that're attached after `documentIsComplete`
2026-02-26 11:01:46 +01:00
Karl Seguin
e7d21c2dbe Add more properties to ScriptManager.Header recall
This bug continues elusive. The latest crash logs show us that, somehow, 1
script is being resolved from 2 transfer objects. This doesn't seem possible,
so I'm adding more properties to log the state of both transfers to try and
figure out how this is happening.
2026-02-26 17:52:33 +08:00
Nikolay Govorov
11906d9d71 Merge pull request #1650 from lightpanda-io/wp/mrdimidium/update-deps
Updates dependencies and their build
2026-02-26 09:19:34 +00:00
Nikolay Govorov
ac5a64d77a Fix typo in build.zig
Co-authored-by: Halil Durak <halildrk@gmail.com>
2026-02-26 08:41:01 +00:00
Halil Durak
c86c851c60 move *addedCallbacks to respective types 2026-02-26 10:27:35 +03:00
Halil Durak
721cf98486 update Image and Style tests 2026-02-26 10:27:35 +03:00
Halil Durak
84bbb6efd4 replacement w/ imageAddedCallback 2026-02-26 10:27:35 +03:00
Halil Durak
f897cda6cd dispatch Style element's load event from nodeIsReady 2026-02-26 10:27:34 +03:00
Halil Durak
2da8b25b09 add LinkLoadError to CloneError 2026-02-26 10:27:34 +03:00
Halil Durak
3f94fd90dd dispatch a load event when href set for Link element
Also add `lazy-href-set` test.
2026-02-26 10:27:34 +03:00
Halil Durak
bc6be22cb4 update test 2026-02-26 10:27:34 +03:00
Halil Durak
e23604e08d introduce dispatchLoad and move load dispatching to Session._wait 2026-02-26 10:27:33 +03:00
Halil Durak
be858ac9ce add load event related tests to link.html 2026-02-26 10:27:33 +03:00
Halil Durak
137ab4a557 dispatch load events that're attached after documentIsComplete 2026-02-26 10:27:33 +03:00
Karl Seguin
bad0fc386d Don't assume that a 'keydown' event is a KeyboardEvent 2026-02-26 15:26:34 +08:00
Karl Seguin
641c7b2c89 Merge pull request #1661 from lightpanda-io/escape_navigate_url
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Callers to page.navigate ensure URL is properly encoded.
2026-02-26 15:19:11 +08:00
Karl Seguin
21be3db51f Callers to page.navigate ensure URL is properly encoded.
Follow up to https://github.com/lightpanda-io/browser/pull/1646

The encodeURL (renamed to ensureEncoded and exposed in this commit) already
handled already-encoded URLs, so this was largely a matter of exposing the
functionality.

The reason this isn't baked directly into Page.navigate is that, in some places
e.g. internal navigation, the URL is already know to be encoded. So it's up
to every caller to make sure they are passing a valid URL to navigate.
2026-02-26 12:22:06 +08:00
Karl Seguin
e978857820 Fix possible overflow when parsing floats without an integer
Fixes a WPT test, but I'm not exactly sure which one.
2026-02-26 11:52:29 +08:00
Karl Seguin
3bf596c54c Merge pull request #1651 from lightpanda-io/more_pump_message_loop
Run the MessageLoop [a lot] more.
2026-02-26 11:35:11 +08:00
Karl Seguin
aedb823b4d update v8 dep 2026-02-26 10:55:02 +08:00
Karl Seguin
7a417435cc Update src/browser/Session.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-26 10:53:16 +08:00
Karl Seguin
497d6e80f7 Merge pull request #1658 from lightpanda-io/wp/mrdimidium/typesafe-libcurl
Move curl C API to type-safe wrapper
2026-02-26 10:28:37 +08:00
Karl Seguin
ae6ab34e72 Make NodeList enumerable
This probably needs to be done for more types. Foundation is now in bridge, so
it should be easy to add.
2026-02-26 08:57:42 +08:00
Nikolay Govorov
4c26161728 Move curl C API to type-safe wrapper 2026-02-25 23:29:54 +00:00
Karl Seguin
1731dca5dd Merge pull request #1648 from lightpanda-io/remove_unused_page_wait
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Remove unused Page.wait and Page._wait
2026-02-26 07:19:40 +08:00
Karl Seguin
ee2caff46e Merge pull request #1577 from lightpanda-io/selection-modify
Add `modify` to Selection
2026-02-26 07:03:37 +08:00
Karl Seguin
db8fb8b05d Merge pull request #1646 from lightpanda-io/url_encoding
Add url encoding option to URL.resolve
2026-02-26 07:02:49 +08:00
Karl Seguin
bec7e141dc Remove unused Page.wait and Page._wait
These exist in Session since iframes. The code isn't currently being used, I
must have pulled it back in during a rebase.
2026-02-26 06:58:56 +08:00
Karl Seguin
ab85b4b129 Merge pull request #1653 from lightpanda-io/dummy-performance-timing
add dummy PerformanceTiming
2026-02-26 06:50:19 +08:00
Karl Seguin
b030049b40 Merge pull request #1652 from lightpanda-io/dump_with_frames
Add a --with_frames argument to fetch
2026-02-26 06:49:53 +08:00
Karl Seguin
1338a3d89d Merge pull request #1647 from lightpanda-io/fix_event_leak_on_disaptch
fix event leak on dispatchEvent
2026-02-26 06:49:29 +08:00
Pierre Tachoire
181178296f set empty_with_no_proto for performance timing and navigation 2026-02-25 21:14:09 +01:00
Pierre Tachoire
df7888d6fb use bridge.property for performance timing and navigation 2026-02-25 21:10:27 +01:00
Muki Kiboigo
dd15f5e052 fix selection modify on nextTextNodeAfter 2026-02-25 07:21:30 -08:00
Muki Kiboigo
f348d85b11 add tests for walking past element on selection modify 2026-02-25 07:21:16 -08:00
Adrià Arrufat
8c8a05b8c1 mcp: consolidate tests and cleanup imports 2026-02-26 00:02:49 +09:00
Adrià Arrufat
34d2fc1503 mcp: support notifications and improve error handling
Make Request id optional for JSON-RPC notifications and handle the
initialized event. Improve thread safety, logging, and error paths.
2026-02-25 23:14:06 +09:00
Adrià Arrufat
9b3fa809bf mcp: add search, markdown, links, and over tools 2026-02-25 20:27:49 +09:00
Karl Seguin
59535c112e Add FileReader 2026-02-25 19:03:24 +08:00
Karl Seguin
04e5a6425a Merge pull request #1655 from lightpanda-io/crash_report_args
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Include assertion args in crash report
2026-02-25 18:36:24 +08:00
Karl Seguin
424dddf67b Merge pull request #1656 from lightpanda-io/dump_attribute
Dump Attribute to empty string
2026-02-25 18:31:19 +08:00
Pierre Tachoire
f0d6ae2a00 Merge pull request #1654 from lightpanda-io/dynamic_import_undefined_resource
Handle dynamicModuleCallback being called with undefined/null resourc…
2026-02-25 11:22:53 +01:00
Karl Seguin
25298a32fa Dump Attribute to empty string
This is normally not called in "normal" dump-usage, but with
XMLSerializer.serializeToString an Attr node _can_ be provided. The spec says,
and FF agrees, this should return an empty string.
2026-02-25 18:16:13 +08:00
Karl Seguin
ba28bf01b7 Include assertion args in crash report
Add more parameters to mysterious/persistent/infrequent
ScriptManager.Header recall failed assertion.
2026-02-25 18:05:33 +08:00
Pierre Tachoire
d15c29b1a3 add dummy Performance.Navigation 2026-02-25 10:52:17 +01:00
Karl Seguin
b083910a51 Handle dynamicModuleCallback being called with undefined/null resource_name
Fixes WPT test:
/semantics/scripting-1/the-script-element/module/dynamic-import/string-compilation-of-promise-result.html
2026-02-25 17:05:40 +08:00
Pierre Tachoire
235aad32a6 add dummy PerformanceTiming 2026-02-25 08:35:53 +01:00
Karl Seguin
a818560344 Add a --with_frames argument to fetch
When set (defaults to not set/false), --dump will include iframe contents.

I was hoping I could add a mode to strip_mode to this, but since dump is used
extensively (e.g. innerHTML), this is something that has to be off by default
(for correctness).
2026-02-25 15:29:27 +08:00
Nikolay Govorov
8f179becf7 Merge pull request #1639 from lightpanda-io/wp/mrdimidium/cleanup-io-layer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Separates network and the browser specific t
2026-02-25 06:12:56 +00:00
Nikolay Govorov
e1695a0874 Strict visibility for Net functions 2026-02-25 05:58:08 +00:00
Karl Seguin
af7498d283 Run the MessageLoop [a lot] more.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/152

We previously ran the message loop every 250ms. This commit changes it to run on
every tick (much more frequently). It also runs microtasks after draining the
message loop (since it can generate microtasks).

Also, we use to run microtasks after each script execution. Now we drain the
message Loop + microtasks.

We still only drain the microtasks when executing v8 callbacks.

As part of this change, we also adjust our wait time based on whether or not
there are pending background tasks in v8 in order to try to execute them (in
general) and in a timely manner.

The goal is to ensure that tasks v8 enqueued on the foreground thread are
executed promptly.

This change is particularly useful for calls to webassembly as compilation
happens in the background and eventually requires the message loop to be drained
to continue.

Previously, if a script did `await WebAssembly.instantiate(....)`, there was
a good chance we'd never finish the code - we'd wait too long to run the
message loop AND, after running it, we wouldn't necessarily resolve the promise.
2026-02-25 13:55:35 +08:00
Nikolay Govorov
3e2a4d8053 Move curl_multi to Net layer 2026-02-25 05:31:28 +00:00
Nikolay Govorov
29982e2caf Move all curl_easy ops to Connection 2026-02-25 05:31:24 +00:00
Nikolay Govorov
5fea1df42b Move Net staff to clean network module 2026-02-25 05:31:19 +00:00
Nikolay Govorov
a041162b32 Merge pull request #1649 from ireydiak/fix/cdp-json-version-trailing-slash
fix: handle trailing slash on /json/version CDP endpoint
2026-02-25 05:20:33 +00:00
Nikolay Govorov
32cd3981d8 Update libcurl version, use build based on config file 2026-02-25 05:05:00 +00:00
Nikolay Govorov
ca5af87196 Build C libs in isolated modules 2026-02-25 05:04:58 +00:00
Nikolay Govorov
a8164f612f Use zig package manager instead of submodules 2026-02-25 05:04:57 +00:00
ireydiak
d3bb0b6ff0 fix: handle trailing slash on /json/version CDP endpoint
Some CDP clients (e.g. playwright-go) request /json/version/ with a
trailing
slash. Added handling for this variant to match the exact same behavior
as /json/version
2026-02-24 22:23:11 -05:00
Karl Seguin
0ef10c1e13 fix event leak on dispatchEvent 2026-02-25 09:36:13 +08:00
Karl Seguin
4017911373 Merge pull request #1643 from lightpanda-io/nikneym/invalid-timer-test
Add a test for invalid timer/timer-like remove
2026-02-25 09:00:53 +08:00
Karl Seguin
048034d4b1 Merge pull request #1645 from lightpanda-io/fix-empty-struct
fix: add _pad to IdleDeadline to avoid identity_map pointer aliasing
2026-02-25 08:58:58 +08:00
Karl Seguin
fcb3f08bcb Add url encoding option to URL.resolve
Given:

a.href = "over 9000!"

Then:

a.href === BASE_URL + '/over%209000!';

This commits adds an escape: bool option to URL.resolve which will escape the
path, query and fragment when true.

Also changes the Anchor, Image, Link and IFrame getSrc to escape. Escaping is
also used when navigating a frame.
2026-02-25 08:17:05 +08:00
Karl Seguin
d2a05bb622 Merge pull request #1642 from lightpanda-io/html-noscript-no-escape
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
don't escape noscript content on html dump
2026-02-25 07:31:26 +08:00
Karl Seguin
f7254ee169 Merge pull request #1641 from lightpanda-io/contexts_no_realloc
Store Env.Contexts in an [fixed-length] array.
2026-02-25 07:30:42 +08:00
Pierre Tachoire
a0e5c9d570 add padding field for some other webapi 2026-02-24 21:36:38 +01:00
Pierre Tachoire
8291e4ba73 fix: add _pad to IdleDeadline to avoid identity_map pointer aliasing 2026-02-24 21:31:41 +01:00
Pierre Tachoire
b324be3b0b Merge pull request #1638 from lightpanda-io/performance-mark-name
accept more performance mark name and return dummy 0
2026-02-24 19:15:04 +01:00
Halil Durak
6ba0ba7126 add a test for invalid timer removal
We cover such cases; yet its better to have a test.
2026-02-24 18:25:26 +03:00
Pierre Tachoire
1d8e0629af don't escape noscript content on html dump 2026-02-24 15:22:43 +01:00
Pierre Tachoire
42df54869f performance: use a comptime StaticStringMap for string comparison 2026-02-24 15:18:25 +01:00
Karl Seguin
7b758b85ec fix test 2026-02-24 19:41:15 +08:00
Karl Seguin
82987ec401 Store Env.Contexts in an [fixed-length] array.
We currently store all of the env's contexts in an ArrayList. When performing
micro/macro tasks, we iterate through this list and perform the micro/macro
tasks. This can result in the ArrayList being invalidated (e.g. a microtask can
result in a context being created, a promise resolving and creating an iframe).
Invalidating the arrylist while we iterate through it is a use-after-free.

This commit stores contexts in a fixed array (64) so that it doesn't move.
Iteration is slower, unfortunately, as the new `env.context_count` has to be
checked.

Fixes WPT crash on
/html/cross-origin-embedder-policy/cross-origin-isolated-permission-iframe.https.window.html url=http://web-platform.test:8000/html/cross-origin-embedder-policy/cross-origin-isolated-permission-iframe.https.window.html

This commit also prevents microtasks execution from causing microtask execution.
On the above test, I saw runMicrotasks which I don't think we're supposed to do.
2026-02-24 19:26:43 +08:00
Pierre Tachoire
71707b5aa7 accept more performance mark name and return dummy 0 2026-02-24 09:12:18 +01:00
Karl Seguin
ca2df83928 Merge pull request #1637 from lightpanda-io/page_url_log
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Include url in page logs
2026-02-24 15:55:26 +08:00
Karl Seguin
085771c2f0 Merge pull request #1636 from lightpanda-io/null_GetOwnPropertyNames
Handle v8:Object::GetOwnPropertyNames returning null
2026-02-24 15:55:10 +08:00
Karl Seguin
607a638858 Merge pull request #1635 from lightpanda-io/frame_shared_memory
All frames must share the same Arena/Factory
2026-02-24 15:54:29 +08:00
Karl Seguin
5f6d06d05d Merge pull request #1634 from lightpanda-io/event_rc
Add [basic] reference counting to events
2026-02-24 15:53:52 +08:00
Karl Seguin
19ecb87b07 Include url in page logs
Wasn't really needed before frames and multithreading,but it's pretty essential
now to figure out what's going on. Probably needs to be applied more
broadly.
2026-02-24 15:11:20 +08:00
Karl Seguin
2a332c0883 Handle v8:Object::GetOwnPropertyNames returning null
This seems to only happen in error cases, most notably someone changes the
object to return an invalid ownKeys, as we see in WPT
/fetch/api/headers/headers-record.any.html
2026-02-24 13:31:34 +08:00
Karl Seguin
bb773c6c13 All frames must share the same Arena/Factory
This is a bit sad, but at least for now, all frames must share the same
page.arena and page.factory (they still get their own v8::Context and
call_arena).

Consider this case (from WPT /css/cssom-view/scrollingElement.html):

```
    let nonQuirksFrame = document.createElement("iframe");
    nonQuirksFrame.onload = this.step_func_done(function() {
      var nonQuirksDoc = nonQuirksFrame.contentDocument;
      nonQuirksDoc.removeChild(nonQuirksDoc.documentElement);
    });
    nonQuirksFrame.src = URL.createObjectURL(new Blob([`<!doctype html>`], { type:
"text/html" }));
    document.body.append(nonQuirksFrame);
```

We have the root page (p0) and the frame page (p1). When the frame (p1) is
created, it's [currently] given its own arena/factory and parses the page. Those
nodes are created with p1's factory.

The onload callback executes on p0, so when we call removeChild that's executing
with p0's arena/factory and tries to release memory using that factory - which
is NOT the factory that created them.

A better approach might be that _memory_ operations aren't tied to the current
calling context, but rather the owning document's page. But:
1 - That would mean we have 2 execution contexts: the v8 context where the code
    is running, and the memory context that owns the code
2 - Nodes can be disconnected, what page should we use?
3 - Some operations can behave across frames, p0 could adoptNode on p1, so we'd
    have to carefully use p1's factory when cleaning up and re-create the node
    in p0's factory.

So much hassle.

Using a shared factory/arena solves these problems at the cost of bloat - when
a frame goes away or navigates, we can't free its old memory. At some point, we
should fix that. But I don't have a quick and easy solution, and sharing the
arena/factory is _really_ quick and easy.
2026-02-24 12:57:05 +08:00
Karl Seguin
238de489c1 Add [basic] reference counting to events
Previously, we used a boolean, `_v8_handoff` to detect whether or not an event
was handed off to v8. When it _was_ handed off, then we relied on the Global
finalizer (or context shutdown) to cleanup the instance. When it wasn't handed
off, we could immediately free the instance.

The issue is that, under pressure, v8 might finalize the event _before_ we've
checked the handoff flag. This was the old code:

```zig
    const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
    defer if (!event._v8_handoff) event.deinit(false);
    try self._event_manager.dispatch(
        self.document.asEventTarget(),
        event,
    );
```

But what happens if, during the call to dispatch, v8 finalizes the event? The
defer statement will access event after its been freed.

Rather than a boolean, we now track a basic reference count. deinit decreases
the reference count, and only frees the object when it reaches 0. Any handoff
to v8 automatically increases the reference count by 1. The above code becomes
a simpler:

```zig
    const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
    defer event.deinit(false);
    try self._event_manager.dispatch(
        self.document.asEventTarget(),
        event,
    );
```

The deinit is un-conditional. The dispatch function itself increases the RC by 1,
and then the v8 handoff  increases it to 2. On v8 finalization the RC is
decreased to 1. The defer deinit decreases it to 0, at which point it is freed.

Fixes WPT /css/css-transitions/properties-value-003.html
2026-02-24 12:31:20 +08:00
Karl Seguin
6b4db330d8 Merge pull request #1629 from lightpanda-io/script_event_dispatch
Use EventManager.dispatch for Script events
2026-02-24 09:46:38 +08:00
Karl Seguin
ea5d7c0dee Use EventManager.dispatch for Script events
Should be updated and merged after:
https://github.com/lightpanda-io/browser/pull/1623 else we'll have a double-free.

The ScriptManager used to directly call the "onload" and "onerror" attributes.
The implementation predates EventManager.dispatch support attribute-based
callbacks. But now the EventManager is attribute-aware and correctly times
the attribute dispatch AND details such as cancellation. So this commit moves
the old attribute-only ScriptManager-specific callback to the EventManager.

With one little wrinkle: 'load' listeners added during a script's execution
should NOT receive a 'load' event when the script finishes. This makes no
sense to me. The EventManager now maintains an ignore_list for "load" events
which is reset after each script execution. A comptime flag is passed to
dispatch to indicate whether the ignore list should be checked. This is only
ever set when the ScriptManager dispatches the 'load' event, so there's no
overhead to dispatch for most events.
2026-02-24 08:50:04 +08:00
Karl Seguin
0f189f1af3 Merge pull request #1628 from lightpanda-io/optimize_resource_load_event
Optimize Resource "load" event
2026-02-24 08:43:00 +08:00
Karl Seguin
0f1b8dd51a Optimize Resource "load" event
An amazon product page has 345 resources and not a single DOM "load" listener.
This, I believe, is pretty common (reddit also has no "load" listener). So this
is a simple optimization to skip dispatching the resource "load" event when
there's no listener for it.

The check could be more granular, i.e. checking the specific parents of the
element. But I believe the global no "load" listener is common enough that this
simpler approach is the best.

The worst case is that we dispatch unnecessary "load" events, which is exactly
what the code was doing before.
2026-02-24 07:30:15 +08:00
Karl Seguin
d7e6946a78 Merge pull request #1632 from lightpanda-io/Response_arrayBuffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add Response.arrayBuffer()
2026-02-24 07:27:20 +08:00
Karl Seguin
255b7b1a54 Merge pull request #1631 from lightpanda-io/improve_tests_outside_of_runner
Improve tests when running outside of our test runner
2026-02-24 07:27:08 +08:00
Karl Seguin
79e1c751a1 Merge pull request #1630 from lightpanda-io/getpeername
Log actual client address
2026-02-24 07:26:52 +08:00
Karl Seguin
fc745b9614 Merge pull request #1627 from lightpanda-io/dom_load_no_window
Don't bubble "load" event to Window
2026-02-24 07:24:58 +08:00
Karl Seguin
95b1baebd2 Merge pull request #1626 from lightpanda-io/optimize_property_and_noop_functions
Optimizes properties and noop functions
2026-02-24 07:24:45 +08:00
Karl Seguin
56fe1ceb97 Merge pull request #1623 from lightpanda-io/finalizer_tweaks
Tweak Finalizer callbacks
2026-02-24 07:24:29 +08:00
Karl Seguin
863a51e556 Merge pull request #1614 from lightpanda-io/fix_v8_context_queue_lifetime
Improve Context shutdown
2026-02-24 07:24:18 +08:00
Karl Seguin
69b3064b45 Add Response.arrayBuffer() 2026-02-23 19:53:57 +08:00
Karl Seguin
fb3eab1aa8 Improve tests when running outside of our test runner
We often verify the correctness of tests by loading them in an external browser,
but some tests just don't run the same/correctly. For example, we used to hard-
code the http://127.0.0.1:9582/ origin, but that would cause tests to fail if
running from a different origin.

This commit _begins_ the work of improving this. It introduces a
testing.ORIGIN, testing.BASE_URL and testing.HOST which will work correctly in
both our runner and an external browser.

It also introduces `testing.IS_TEST_RUNNER` boolean flag so that tests which
have no chance of working in an external browser (e.g. screen.width) can be
skipped.

The goal is to reduce/remove tests which fail in external browsers so that such
failures aren't quickly written off as "just how it is".
2026-02-23 18:20:15 +08:00
Karl Seguin
32c7399f26 Log actual client address
We're currently logging the server address. It should clearly be the client
address.
2026-02-23 17:42:25 +08:00
Karl Seguin
955351b5bd Don't bubble "load" event to Window
As per spec:

For legacy reasons, load events for resources inside the document (e.g., images)
do not include the Window in the propagation path in HTML implementations.
2026-02-23 14:04:26 +08:00
Karl Seguin
75f6c67b6e Optimizes properties and noop functions
Define properties directly on the PrototypeTemplate, removing the need for 1
FunctionTemplate per instance-property. Also, properties were previously all
readonly. There is now a readonly flag...thus, properties which we don't care
about, but have a default value, can be defined this way.

Added a 'noop' flag to functions which skips the zig callback and does nothing.

I was initially hoping these would noticeably reduce the size of our snapshot
due to requiring fewer FunctionTemplates. While it shaves a few ~3KB, it's far
less than I was hoping. The issue is that noop function templates still need
to be unique so that `type_a.noopFunc !== type_b.noopFunc` remains true.

Still, as we add more dummy implementation, these little tweaks might add up.
2026-02-23 12:41:02 +08:00
Karl Seguin
700a3e6ed9 Merge pull request #1622 from egrs/wpt-fixes-batch-3
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix WPT: document.implementation identity, file input value
2026-02-23 07:01:58 +08:00
Karl Seguin
00702448c7 Merge branch 'main' into wpt-fixes-batch-3 2026-02-23 07:01:35 +08:00
Karl Seguin
5074827d51 Merge pull request #1625 from lightpanda-io/option-set-text
add Option.text setter
2026-02-23 06:56:34 +08:00
Karl Seguin
ceb0711e42 Merge pull request #1620 from lightpanda-io/offscreen_canvas
Add dummy implementation of OffscreenCanvas
2026-02-23 06:55:18 +08:00
Karl Seguin
ddb5824b58 Merge pull request #1624 from lightpanda-io/fontface
add dummy implementation of FontFaceSet
2026-02-23 06:55:03 +08:00
egrs
39f9209374 fix file input value getter/setter per spec
- getValue() returns "" for file inputs regardless of value attribute
- setValue("") is a no-op for file inputs (was throwing)
- setValue(non-empty) still throws InvalidStateError
- add _setValue bridge wrapper for [LegacyNullToEmptyString]: null → ""

Flips html/semantics/forms/the-input-element/valueMode.html (38/40 → 40/40).
2026-02-22 19:13:57 +01:00
Adrià Arrufat
5fea4cf760 mcp: add protocol and router unit tests 2026-02-22 23:15:45 +09:00
Pierre Tachoire
0e5ec86ca9 add Option.text setter 2026-02-22 15:03:12 +01:00
Pierre Tachoire
8b95211055 add dummy implementation of font face set 2026-02-22 14:59:41 +01:00
Adrià Arrufat
a27339b954 mcp: add Model Context Protocol server support
Adds a new `mcp` run mode to start an MCP server over stdio.
Implements tools for navigation and JS evaluation, along with
resources for HTML and Markdown page content.
2026-02-22 22:32:14 +09:00
Karl Seguin
028b728760 Tweak Finalizer callbacks
1 - Finalizer callbacks are now give a *Page parameter. Various types no longer
    need to maintain a reference to *Page just to finalize

2 - EventManager now handles v8_handoff == false cleanup. This is largely
    because of the above change, which would require every:

```
defer if (!event._v8_handoff) event.deinit(false);
```

to be turned into:

```
defer if (!event._v8_handoff) event.deinit(false, page);
```

But the caller might not have a page. Besides this, it makes most uses of Event
simpler. But, in some cases, it could leave a window where the event doesn't
reach the EventManager to be properly managed (though, we have no such cases
as of now).
2026-02-22 20:51:21 +08:00
Karl Seguin
18e63df01e Merge pull request #1621 from egrs/wpt-fixes-batch-2
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix WPT failures: nodeName, PI validation, willValidate, maxLength
2026-02-22 07:45:17 +08:00
egrs
5f459c0901 cache document.implementation for object identity
getImplementation() now returns a cached *DOMImplementation pointer
per Document, matching the getStyleSheets() pattern. This ensures
document.implementation === document.implementation holds true.

Flips dom/nodes/Document-implementation.html (1/2 → 2/2).
2026-02-21 14:45:59 +01:00
egrs
a90bcde38c fix WPT failures: nodeName prefix case, PI validation, willValidate, maxLength
- uppercase entire qualified name in tagName (including prefix)
- validate PI data for "?>" and use proper XML Name production with Unicode
- implement willValidate on HTMLInputElement
- throw IndexSizeError DOMException for negative maxLength assignment

flips: Node-nodeName, Document-createProcessingInstruction, button,
maxlength, input-willvalidate (+6 subtests)
2026-02-21 13:11:06 +01:00
Karl Seguin
603e7d922e Improve Context shutdown
Under some conditions, a microtask would be executed for a context that was
already deinit'd, resulting in various use-after-free.

The culprit appears to be WASM compilation being placed in the microtask queue
(by a user-script) and then resolved at some point in the future. We guard the
microtask queue by a context.shutting_down boolean, but v8 doesn't know anything
about this flag. The fact is that, microtasks are tied to an isolate, not a
context.

This commit introduces a number of changes:

1 - It follows 309f254c2c and stores the zig Context inside of an embedder field. This
    ensures v8 doesn't consider this when GC'ing, which _could_ extend the
    lifetime of the v8::Context beyond what we expect

2 - Most significantly, it introduces per-context microtasks queues. Each
    context gets its own queue. This makes cleanup much simpler and reduces the
    chance of microtasks outliving the context

3 - pumpMessageLoop is called on context.deinit, this helps to ensure that any
    tasks v8 has for our context are processed (e.g. wasm compilation) before
    shtudown

4 - The order of context shutdown is important, we notify the isolate of the
    context destruction first, then pump the message loop and finally destroy
    the context's message loop.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/151
2026-02-21 13:02:43 +08:00
Karl Seguin
861126f810 Add dummy implementation of OffscreenCanvas 2026-02-21 12:58:35 +08:00
Karl Seguin
eb9b706ebc Merge branch 'select-event'
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
2026-02-21 07:21:37 +08:00
Karl Seguin
de9cbae0b2 Merge pull request #1565 from lightpanda-io/frames
Initial support for frames
2026-02-21 07:17:51 +08:00
Karl Seguin
25e890986f Merge pull request #1619 from egrs/wpt-small-fixes
fix DocumentType.remove, MutationRecord.attributeNamespace, createElementNS casing
2026-02-21 07:17:29 +08:00
Karl Seguin
f66627dd04 Merge pull request #1618 from arrufat/markdown-simplifications
markdown: simplify rendering logic and state management
2026-02-21 07:10:56 +08:00
Karl Seguin
924eb33b3f Update src/browser/js/Env.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-21 07:02:21 +08:00
Karl Seguin
1b288c541a Merge pull request #1616 from lightpanda-io/URL_createObjectURL
Add URL.createObjectURL and URL.revokeObjectURL
2026-02-21 07:02:01 +08:00
Karl Seguin
2612b8c86f Merge pull request #1617 from lightpanda-io/cookie_fixes
Add more cookie tests
2026-02-21 07:01:47 +08:00
Karl Seguin
3e2796d456 Merge pull request #1611 from lightpanda-io/utf_range_offsets
Get both start and end bytes in a single pass
2026-02-21 07:01:30 +08:00
Pierre Tachoire
7092913863 Merge pull request #1615 from lightpanda-io/css_escape_null
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
prorper escaping of null character
2026-02-20 15:38:54 +01:00
Pierre Tachoire
67625fc347 Merge pull request #1600 from lightpanda-io/formdata_disabled_fieldset
FormData recognizes (and skips over) disabled fieldsets
2026-02-20 15:35:03 +01:00
Pierre Tachoire
eb55030b06 Merge pull request #1584 from egrs/fix-textarea-selection-insert
fix textarea text insertion to respect selection range
2026-02-20 15:24:39 +01:00
egrs
6e1b2d50f2 fix DocumentType.remove, MutationRecord.attributeNamespace, createElementNS casing
- add ChildNode.remove() to DocumentType (flips DocumentType-remove.html)
- return null for MutationRecord.attributeNamespace on non-namespaced
  attribute mutations (flips MutationObserver-takeRecords.html)
- stop lowercasing in createElementNS per spec — only createElement
  should ASCII-lowercase for HTML namespace (flips
  Element/Document-getElementsByTagNameNS.html)
- fix getElementsByTagName to use case-insensitive matching for HTML
  namespace elements
2026-02-20 14:46:58 +01:00
Adrià Arrufat
c6f72c44b8 markdown: simplify rendering logic and state management 2026-02-20 22:04:36 +09:00
Karl Seguin
d38ded0f26 Merge pull request #1613 from egrs/lookup-namespace-uri
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
implement Node.lookupNamespaceURI() and isDefaultNamespace()
2026-02-20 20:48:05 +08:00
egrs
ec20b7bd3a implement Node.lookupNamespaceURI() and Node.isDefaultNamespace()
Implements the DOM spec algorithms for namespace lookup on all node
types. Stores custom namespace URIs in a page lookup for elements
created via createElementNS with unknown namespaces. Fixes
setAttributeNS to preserve qualified names for xmlns namespace
declarations.

Flips dom/nodes/Node-lookupNamespaceURI.html: 0/75 → 75/75.
2026-02-20 13:25:09 +01:00
Karl Seguin
0766cf464a Merge pull request #1612 from egrs/fix-childnode-sibling-ordering
fix ChildNode after() and replaceWith() sibling ordering
2026-02-20 20:24:29 +08:00
egrs
867f00e091 fix ChildNode after() and replaceWith() sibling ordering
after() captured node.nextSibling() once, which went stale when that
sibling was one of the nodes being inserted. Use viableNextSibling() to
find the first following sibling not in the nodes list per the DOM spec.

replaceWith() in CData had the same stale-reference problem and also
removed self before inserting, unlike Element.replaceWith() which keeps
self as the insertion anchor. Adopt the same anchor pattern: insert
before self, then remove self at the end.

Flips ChildNode-after.html from 33/45 to 45/45 and
ChildNode-replaceWith.html from 27/33 to 33/33.
2026-02-20 13:12:34 +01:00
Karl Seguin
c823b8d7ae Add more cookie tests
Fix trimming and incorrect early loop termination when  expiring cookies.
2026-02-20 20:03:18 +08:00
Karl Seguin
393d4d336c Add URL.createObjectURL and URL.revokeObjectURL 2026-02-20 19:34:57 +08:00
Karl Seguin
2cb3f2d03d prorper escaping of null character 2026-02-20 18:44:54 +08:00
Karl Seguin
279f2dd633 Merge pull request #1599 from lightpanda-io/input_sanitize_ownership
Improve and fix sanitized value ownership.
2026-02-20 18:40:51 +08:00
Karl Seguin
dec051a6e0 Merge pull request #1603 from egrs/wpt-spec-guards
spec compliance: missing validation guards
2026-02-20 15:33:06 +08:00
Karl Seguin
790fdd320c Merge pull request #1610 from lightpanda-io/add_js_nullablestring
Add js.NullableString
2026-02-20 15:30:15 +08:00
Karl Seguin
feb4a364a7 Merge pull request #1608 from egrs/null-domstring-constants
add DOMException legacy error code constants
2026-02-20 15:30:01 +08:00
egrs
1140149e1e add dom_exception flag to Element.replaceChildren 2026-02-20 08:22:29 +01:00
egrs
2ee9599b6e add DOMException legacy error code constants
Add all 25 legacy constants (INDEX_SIZE_ERR through DATA_CLONE_ERR)
to DOMException on both constructor and prototype, enabling WPT
assert_throws_dom checks that reference e.code.
2026-02-20 08:13:23 +01:00
Karl Seguin
188d45e002 Get both start and end bytes in a single pass
Follow up to https://github.com/lightpanda-io/browser/pull/1605 to calculate
both start and end bytes in a single pass.
2026-02-20 10:14:47 +08:00
Karl Seguin
7c4c2f7860 Merge pull request #1605 from egrs/wpt-chardata-utf16
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix CharacterData methods to use UTF-16 code unit offsets
2026-02-20 09:35:26 +08:00
Karl Seguin
90b7f2ff3b Improve and fix sanitized value ownership.
1 - Fix an issue where build would persist a value in the call_arena
2 - Remove double allocation (call_arena -> page_arena)
3 - Improve ergonomics of sanitizeValue with a comptime value indicating whether
    or not to always dupe the value.
2026-02-20 09:30:44 +08:00
Karl Seguin
d3f0041e93 Merge pull request #1607 from arrufat/markdown-anchors
markdown: handle block-level and standalone anchors
2026-02-20 08:59:41 +08:00
Karl Seguin
9d60142828 Add js.NullableString
When a WebAPI takes `[]const u8`, we coerce values to strings. But when it
takes a `?[]const u8` how should we handle `null`?  Some APIs might want to know
that it was null, others might just want `"null``.

Currently when `null` is passed to `?[]const u8`, we'll get null.

This adds a discriminator type, js.NullableString. When `null` is passed to it
it'll be converted to `"null"`.
2026-02-20 07:24:43 +08:00
Adrià Arrufat
68d5edca60 markdown: use node.is() for type checking and casting 2026-02-20 08:14:15 +09:00
Karl Seguin
1b369489df Merge pull request #1602 from lightpanda-io/css-delcaration
parse style attribute on CSSStyleDeclaration init
2026-02-20 06:57:58 +08:00
Karl Seguin
600ddfbf2d Merge pull request #1587 from lightpanda-io/label_control
Add HTMLLabelElement.control getter
2026-02-20 06:56:37 +08:00
Karl Seguin
415d4dde2a Merge pull request #1606 from lightpanda-io/form_selectors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
More pseudo-seletors
2026-02-19 23:49:35 +08:00
Pierre Tachoire
1867245ed3 Merge pull request #1598 from egrs/input-value-sanitization
input value sanitization per WHATWG spec
2026-02-19 16:48:56 +01:00
Karl Seguin
71d34592d9 add frame created cdp messages 2026-02-19 23:47:33 +08:00
Karl Seguin
db2927eea7 cleanup a not-so-great rebase 2026-02-19 23:47:33 +08:00
Karl Seguin
bb01a5cb31 Make CDP frame-aware 2026-02-19 23:47:33 +08:00
Karl Seguin
815319140f cleanupany incomplete scheduled_navigation on renavigate or page.deinit 2026-02-19 23:47:33 +08:00
Karl Seguin
6e6082119f Remove session.transfer_arena
This no longer works with frames. Multiple frames could have a scheduled
navigation, so a single arena no longer has a clear lifecycle. Instead an arena
from the pool is used per navigation event, thus the queued_navigation is self-
contained.

This required having libcurl copy the body. Unfortunate. Currently we free the
arena as soon as the navigation begins. This is clean. But it means the body is
immediately freed (thus we need libcurl to copy it). As an alternative, each
page could maintain an optional transfer_arena, which it could free on
httpDone/Error.
2026-02-19 23:47:33 +08:00
Karl Seguin
da48ffe05c Move page.wait to session.wait
page.wait is the only significant difference between the "root" page and a page
for an iframe. I think it's more explicit to move this out of the page and
into the session, which was already the sole entry-point for page.wait.
2026-02-19 23:47:33 +08:00
Karl Seguin
081979be3b Initial support for frames
Missing:

- [ ] Navigation support within frames (in fact, as-is, any navigation done
      inside a frame, will almost certainly break things
- [ ] Correct CDP support. I don't know how frames are supposed to be exposed
      to CDP. Normal navigate events? Distinct CDP frame_ids?
- [ ] Cross-origin restrictions. The interaction between frames is supposed to
      change depending on whether or not they're on the same origin
- [ ] Potentially handling src-less frames incorrectly. Might not really matter

Adds basic frame support. Initially explored adding a BrowsingContext and
embedding it in Page, with the goal of also having it embedded in a to-be
created Frame. But it turns out that 98% of Page _was_ BrowsingContext and
introducing a BrowsingContext as the primary interaction unit broke pretty much
_every_ single WebAPI. So Page was expanded:

- Added `_parent: ?*Page`, which is `null` for "root" page.
- Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the
  HTMLIFrameElement for frame-pages.
- Added a _type: enum{root, frame}, which is currently only used to improve
  the logs
- Added a frames: std.ArrayList(*Page). This is a list of frames for the page.
  Note that a "frame-page" can itself haven nested frames.

Besides the above, there were 3 "big" changes.

1 - Adding frames (dynamically, parsed) has to create a new page, start
    navigation, track it (in the frames list). Part of this was just
    piggybacking off of code that handles <script>

2 - The page "load" event blocks on the frame "load" event. This cascades.
    when a page triggers it's load, it can do:
```zig
      if (self._parent) |p| {
        p.iframeLoaded(self);
      }
```
   Pages need to keep track of how many iframes they're waiting to load. When
   all iframes (and all scripts) are loaded, it can then triggers its own load
   event.

3 - Our JS execution expects 1 primary entered context (the pages). But we now
    have multiple page contexts, and we need to be in the correct one based
    on where javascript is being executed. There is no more an default entered
    context. Creating a Local.Scope enters the context, and ls.deinit() exits
    the context.
2026-02-19 23:47:33 +08:00
egrs
3673956c1c add pure zig tests for utf16Len and utf16OffsetToUtf8 2026-02-19 16:29:49 +01:00
egrs
bdd3c274ed address review: arena param + pure zig tests
- sanitizeDatetimeLocal takes arena: Allocator instead of *Page
- add unit tests for isValidFloatingPoint, isValidDate, isValidMonth,
  isValidWeek, isValidTime, sanitizeDatetimeLocal, parseAllDigits,
  daysInMonth, maxWeeksInYear
2026-02-19 16:22:13 +01:00
Adrià Arrufat
423034d5c4 markdown: handle block-level and standalone anchors in
Adds logic to detect if an anchor contains block descendants or is a
standalone element within a layout block. These are now rendered with
appropriate spacing and link formatting. Also adds `.main` to the list
of block elements.
2026-02-20 00:11:38 +09:00
Pierre Tachoire
19fd2b12c0 Update src/browser/webapi/css/CSSStyleDeclaration.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 15:29:36 +01:00
Karl Seguin
21cd17873f More pseudo-seletors
:invalid, :valid and :indeterminate implementation

Fix Element.closest with :scope pseudo-selector.
2026-02-19 22:28:14 +08:00
egrs
9870fa9e34 fix CharacterData methods to use UTF-16 code unit offsets
The DOM spec requires CharacterData offset/length to count UTF-16 code
units, not UTF-8 bytes or Unicode codepoints. Multi-byte characters
(CJK = 3 bytes UTF-8 / 1 code unit, emoji = 4 bytes / 2 code units)
were getting mangled.

- Add utf16Len/utf16OffsetToUtf8 helpers for UTF-8 ↔ UTF-16 conversion
- Fix .length to count UTF-16 code units instead of codepoints
- Fix substringData, deleteData, insertData, replaceData, splitText
- Fix data setter: null → "" (LegacyNullToEmptyString), undefined → "undefined"

Flips 6 WPT files (+134 subtests), 0 regressions.
2026-02-19 15:18:28 +01:00
Karl Seguin
938cd5e136 Merge pull request #1582 from lightpanda-io/cdp_per_page_frame_id
Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
2026-02-19 22:16:52 +08:00
Karl Seguin
e8025ad4b3 Merge pull request #1592 from lightpanda-io/element_render_property_optimization
Reduce cost of various Element render-related properties.
2026-02-19 22:16:17 +08:00
Karl Seguin
07fa141aaa Merge pull request #1593 from lightpanda-io/focus_noop_disconnected
make element.focus()  noop when element is disconnected
2026-02-19 22:16:04 +08:00
Pierre Tachoire
18bdf1e8b3 Merge pull request #1594 from lightpanda-io/fix_flaky_scroll_test
Fix flaky window.scrollTo test
2026-02-19 14:58:28 +01:00
Pierre Tachoire
5be977005e avoid useless priority parsing in CSSStyleDeclaration 2026-02-19 14:49:49 +01:00
Karl Seguin
282b64278e Merge pull request #1601 from lightpanda-io/animation-cancel
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
implement Animation.cancel()
2026-02-19 21:37:08 +08:00
Pierre Tachoire
7263d484de Update src/browser/webapi/css/CSSStyleDeclaration.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 14:36:02 +01:00
egrs
bdb059b6c9 spec compliance: missing validation guards
- Event.preventDefault() and returnValue respect cancelable=false
- MutationObserver.observe() validates options per spec
- detached checkbox/radio click suppresses input/change events
- doctype insertion into non-document throws HierarchyRequestError
- error.TypeError maps to JS TypeError (not generic Error)
- enable dom_exception on Element/DocumentFragment mutation methods
2026-02-19 14:23:11 +01:00
Pierre Tachoire
de3f5011bc parse style attribute on CSSStyleDeclaration init
To reflect the current style attribute, CSSStyleDeclaration now parses
it on init.

Moreover, this PR synchronizes the element's style attribute with the
dynamic changes.
2026-02-19 12:26:04 +01:00
Pierre Tachoire
de9faffa33 implement Animation.cancel() 2026-02-19 12:12:59 +01:00
Karl Seguin
f67ca69e05 FormData recognizes (and skips over) disabled fieldsets 2026-02-19 18:35:23 +08:00
egrs
dd19e880c5 merge main, resolve comment conflicts in Input.zig 2026-02-19 10:16:34 +00:00
egrs
b5e8fa007c input value sanitization per WHATWG spec
- number: validate against spec grammar (reject "+1", "1.", "Infinity",
  "NaN", overflow values like "2e308")
- date: validate YYYY-MM-DD with day-of-month and leap year checks
- time: validate HH:MM[:SS[.sss]]
- month: validate YYYY-MM
- week: validate YYYY-Www with ISO 8601 week count
- datetime-local: validate and normalize (T separator, shortest time)
- color: normalize hex to lowercase per spec
- checkbox/radio: return "on" as default value
- sanitize initial value from HTML attributes in created()
2026-02-19 11:02:03 +01:00
Karl Seguin
c3555bfcab Merge pull request #1596 from lightpanda-io/animation-improve
Improve Animation support: async update from idle => running => finished
2026-02-19 18:01:17 +08:00
Karl Seguin
0383db8788 Merge pull request #1595 from egrs/wpt-value-fixes
fix input value defaults, color normalization, and event propagation resets
2026-02-19 17:56:18 +08:00
Karl Seguin
d7af122c18 Merge pull request #1564 from lightpanda-io/nikneym/create-image-data
Add `createImageData` and `putImageData` to `CanvasRenderingContext2D`
2026-02-19 17:56:02 +08:00
Pierre Tachoire
e15b8145b1 create Animation in the pool arena 2026-02-19 10:50:12 +01:00
Pierre Tachoire
d75f5f9231 don't play animation when startTime is set to null 2026-02-19 10:41:07 +01:00
Pierre Tachoire
9939797792 fix comment 2026-02-19 10:36:29 +01:00
Pierre Tachoire
5248b9fc6f Update src/browser/webapi/animation/Animation.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 10:35:46 +01:00
Pierre Tachoire
e15295bdac Merge pull request #1560 from arrufat/dump-markdown
Add support for dumping output to markdown
2026-02-19 10:32:57 +01:00
Karl Seguin
4e1f96e09c Merge pull request #1597 from lightpanda-io/CSSStyleProperties_setNamed
add CSSStyleProperties array set support
2026-02-19 17:30:54 +08:00
Halil Durak
96cfdebced ImageData#constructor: check bounds of dimensions + don't overflow size
Also adds a related `too-large` test.
2026-02-19 12:09:24 +03:00
Halil Durak
944f34b833 createImageData: remove unnecessary unreachable 2026-02-19 12:09:24 +03:00
Halil Durak
1023b2ca9c test blocks need at least a single assertion 2026-02-19 12:09:24 +03:00
Halil Durak
16318bb9f6 add tests 2026-02-19 12:09:24 +03:00
Halil Durak
350586335d add createImageData and putImageData to CanvasReneringContext2D 2026-02-19 12:09:23 +03:00
egrs
9d809499a5 fix input value defaults, color normalization, and event propagation resets
- checkbox/radio getValue() returns "on" when no value attribute set
- color input sanitization normalizes hex to lowercase per spec
- initial input value is sanitized per input type during element creation
- initEvent resets both stop_propagation and stop_immediate_propagation
- dispatchNode resets propagation flags after dispatch per DOM spec step 12
2026-02-19 09:53:13 +01:00
Pierre Tachoire
fdd52c17d7 add CSSStyleProperties array set support 2026-02-19 09:52:27 +01:00
Pierre Tachoire
1461d029db Improve Animation support: async update from idle => running => finished 2026-02-19 09:50:16 +01:00
Karl Seguin
07cefd71df Merge pull request #1571 from lightpanda-io/nikneym/persisted-typed-arrays
Persisted typed arrays
2026-02-19 15:46:10 +08:00
Halil Durak
abab10b2cc move createTypedArray to Local 2026-02-19 10:03:04 +03:00
Karl Seguin
e37d4a6756 Fix flaky window.scrollTo test
The test is sensitive to any delay in running the schedule task exactly when
it's scheduled. Testing this feature isn't worth making the build flaky.
2026-02-19 14:15:19 +08:00
Karl Seguin
e2a1ce623c Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
Our BrowsingContext currently supports 1 target. So we have a per-BC target_id.
Previously, our target had 1 "frame" - our page. So we often treated the
targetId as the frameId. But to work with frames, we need page-specific
frameIds and loaderIds.

This tries to clean up our ids (a little). frameIds are now ids derived from
a new incrementing page.id. This page.id has to be passed around (via http
Requests and through notifications) in order to properly generate messages with
a frameId.
2026-02-19 13:01:41 +08:00
Karl Seguin
0ff243266c make element.focus() noop when element is disconnected 2026-02-19 10:35:11 +08:00
Karl Seguin
645da2e307 Reduce cost of various Element render-related properties.
Added a get-only `getStyle` which doesn't lazily create a new style if none
exists. This can be used in the (frequently used) `checkVisibility` to avoid
an allocation. Added a specialized getBoundingClientRectForVisible which
skips the checkVisibility check, since a few callers have already done their
own visibility check.

DOMRect is now off the heap. This avoids _a lot_ of allocation when a DOMRect
is only needed for internal calculation, e.g. in Document.elementFromPoint.
2026-02-19 09:45:56 +08:00
Karl Seguin
5fd95788f9 Merge pull request #1585 from egrs/focusin-focusout-events
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
dispatch focusin/focusout events with relatedTarget
2026-02-19 08:15:54 +08:00
Karl Seguin
bd29f168e0 Merge pull request #1590 from egrs/range-tostring-fix
fix Range.toString() for cross-container and element ranges
2026-02-19 08:08:25 +08:00
Karl Seguin
dc97e33cd6 Merge pull request #1591 from lightpanda-io/input_and_window_test
Remove duplicate window test
2026-02-19 07:59:31 +08:00
Karl Seguin
caf7cb07cd Remove duplicate window test
Re-enable some commented out input tests
2026-02-19 07:47:00 +08:00
Karl Seguin
ad5df53ee7 Merge pull request #1583 from egrs/window-htmlelement-input-props
add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
2026-02-19 07:44:36 +08:00
Halil Durak
95920bf207 ArrayBufferRef(...).Global: consistent, persisted typed arrays 2026-02-18 21:43:19 +03:00
egrs
6700166841 fix Range.toString() for cross-container and element ranges
implement proper tree-walking in writeTextContent to handle all cases:
same-element containers, cross-container ranges, and comment exclusion.
uncomment ~800 lines of Range tests and add 5 new toString tests.
2026-02-18 16:25:34 +01:00
Karl Seguin
b8196cd06e Merge pull request #1588 from egrs/click-to-focus
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
click on focusable elements calls focus() with events
2026-02-18 23:12:21 +08:00
egrs
c28afbf193 address review feedback: move stubs test, inline bridge functions, catch unreachable 2026-02-18 15:40:59 +01:00
egrs
84ffffb3f3 dispatch select event from input.select() and textarea.select() 2026-02-18 15:31:52 +01:00
egrs
b2c030140c click on focusable elements calls focus() with events
handleClick() was setting _active_element directly, bypassing
Element.focus() — so no blur/focus events fired on click.
Now calls focus() for input, button, select, textarea, anchor.
2026-02-18 15:08:40 +01:00
Muki Kiboigo
90138ed574 use applyModify generally 2026-02-18 06:07:06 -08:00
Adrià Arrufat
92f131bbe4 Inline writeIndentation helper 2026-02-18 23:02:10 +09:00
Karl Seguin
338580087e Add HTMLLabelElement.control getter 2026-02-18 21:59:15 +08:00
Pierre Tachoire
deda53a842 Merge pull request #1353 from lightpanda-io/wp/mrdimidium/multicontext
Use thread per connection
2026-02-18 14:59:15 +01:00
Pierre Tachoire
5391854c82 Merge pull request #1586 from lightpanda-io/makefile-typo-fix
typo fix
2026-02-18 14:51:46 +01:00
Pierre Tachoire
e288bfbec4 typo fix 2026-02-18 14:50:43 +01:00
egrs
377fe5bc40 add comment on _active_element ordering constraint 2026-02-18 14:50:20 +01:00
Adrià Arrufat
d264ff2801 Use attributes for checkbox rendering 2026-02-18 22:48:46 +09:00
egrs
a21bb6b02d dispatch focusin/focusout events with relatedTarget
focus() and blur() now dispatch all four spec-required FocusEvents:
blur (no bubble) → focusout (bubbles) → focus (no bubble) → focusin (bubbles)

Each event carries the correct relatedTarget: the element gaining focus
for blur/focusout, and the element losing focus for focus/focusin.
All four events are composed per W3C spec.

Relates to #1161
2026-02-18 14:31:11 +01:00
egrs
37ecd5cdef use textarea.innerInsert for selection-aware text insertion
Both insertText() and handleKeydown() were manually concatenating text
to the textarea value, ignoring the current selection range. This uses
the existing innerInsert() method which correctly handles full, partial,
and no-selection states — matching the Input element behavior.
2026-02-18 13:47:09 +01:00
egrs
07a87dfba7 fix tabIndex default for interactive elements per spec
interactive elements (input, button, a, select, textarea, iframe)
default to 0 when tabindex attribute is absent; others default to -1.
also add TODO for hidden "until-found" tristate.
2026-02-18 13:13:12 +01:00
egrs
9e4db89521 add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
- Window: alert/confirm/prompt (no-op stubs), devicePixelRatio
- HTMLElement: hidden (boolean), tabIndex (integer)
- Input: placeholder, min, max, step, multiple, autocomplete
2026-02-18 13:04:12 +01:00
Karl Seguin
536d394e41 Merge pull request #1579 from egrs/style-element-sheet
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
return CSSStyleSheet from HTMLStyleElement.sheet
2026-02-18 19:59:38 +08:00
Karl Seguin
c0580c7ad0 Merge pull request #1581 from egrs/element-attribute-reflections
add attribute reflections for 8 HTML element types
2026-02-18 19:50:23 +08:00
egrs
488e72ef4e wire up CSSStyleSheet.ownerNode to the style element 2026-02-18 12:41:31 +01:00
Karl Seguin
01c224d301 Merge pull request #1580 from egrs/image-complete
add HTMLImageElement.complete property
2026-02-18 19:02:07 +08:00
egrs
eaf95a85a8 fix OL.type default and TableCell span clamping per spec
OL.type returns "1" (not "") when attribute absent.
TableCell.colSpan clamps to 1-1000, rowSpan to 0-65534.
2026-02-18 12:00:44 +01:00
Pierre Tachoire
ba1d084660 Merge pull request #1570 from egrs/media-play-pause-events
Dispatch play, pause, and emptied events from HTMLMediaElement
2026-02-18 11:46:28 +01:00
egrs
2e64c461c3 add attribute reflections for 8 HTML element types
Wire up missing IDL properties for HTMLTimeElement (dateTime),
HTMLLIElement (value), HTMLOListElement (start, reversed, type),
HTMLOptGroupElement (disabled, label), HTMLQuoteElement (cite),
HTMLTableCellElement (colSpan, rowSpan), HTMLLabelElement (htmlFor),
and HTMLFieldSetElement (disabled, name).
2026-02-18 11:36:58 +01:00
Adrià Arrufat
ce5dad722f Make --dump format optional and improve markdown rendering 2026-02-18 19:33:32 +09:00
egrs
7675feca91 revert _playing flag: playing event fires on every resume
Tested in Chrome (headless, --autoplay-policy=no-user-gesture-required):
playing fires on every pause-to-play transition, not just the first
time. The _playing flag was incorrectly suppressing this. Removed it
and updated tests to match verified Chrome behavior.
2026-02-18 10:49:27 +01:00
egrs
c66d74e135 clarify comment: images are in broken state, not fully fetched 2026-02-18 10:42:01 +01:00
egrs
54d6eed740 fix type check: case-insensitive, accept empty string, clear on disconnect
Per spec, valid type values are absent, empty string, or a
case-insensitive match for "text/css". Also clear cached sheet
when the element is disconnected or type becomes invalid.
2026-02-18 10:40:42 +01:00
Nikolay Govorov
dc4b75070d Increases the memory limit on CI :/ 2026-02-18 09:23:37 +00:00
egrs
830eb74725 track playing state: only dispatch playing on first start
Per review: playing event should only fire on first start, not
on every resume from pause. Add _playing field, reset on load().
2026-02-18 10:22:49 +01:00
Nikolay Govorov
4f21d8d7a8 Implement multi-cdp architecture 2026-02-18 09:22:31 +00:00
Nikolay Govorov
424deb8faf Run cdp client on dedicated thread 2026-02-18 09:22:29 +00:00
Nikolay Govorov
b4a40f1257 Move IO loops from Server to cdp Client 2026-02-18 09:22:27 +00:00
Nikolay Govorov
9296c10ca4 Use per-cdp connection HttpClient 2026-02-18 09:22:26 +00:00
Nikolay Govorov
fbe65cd542 Use std.Atomic.Value in Server instead of direct atomic operations 2026-02-18 09:22:24 +00:00
Nikolay Govorov
ccbb6e4789 Make ArenaPool, Robots and Env thread safety 2026-02-18 09:22:23 +00:00
Nikolay Govorov
d70f436304 Fix use-after-free in Fetch 2026-02-18 09:22:21 +00:00
Nikolay Govorov
16aaa8201c Drop unused config opts 2026-02-18 09:22:18 +00:00
Karl Seguin
acc1f2f3d7 Merge pull request #1578 from lightpanda-io/detach_attached_parsed_node
Detach attached nodes on appendBeforeSibling callback
2026-02-18 15:42:13 +08:00
egrs
433d254c70 add HTMLImageElement.complete property
Per spec, complete returns true when the image has no src, is fully
fetched, or is broken. Since this is a headless browser that does
not fetch images, complete always returns true.
2026-02-18 08:18:54 +01:00
egrs
ea4eebd2d6 return CSSStyleSheet from HTMLStyleElement.sheet
Per spec, connected style elements with type text/css should
return a CSSStyleSheet object. Previously always returned null.
The sheet is lazily created and cached on first access.
2026-02-18 08:11:08 +01:00
Karl Seguin
3c00a527dd Merge pull request #1574 from egrs/performance-observer-buffered
Support buffered option in PerformanceObserver.observe()
2026-02-18 14:48:15 +08:00
egrs
f72a354066 address review: clear marks before test, assert exactly 2 2026-02-18 07:15:17 +01:00
egrs
7c92e0e9ce address review: fix doc comment, skip buffered if already queued 2026-02-18 07:14:12 +01:00
Karl Seguin
4f6868728d Detach attached nodes on appendBeforeSibling callback
html5ever generally makes guarantees about nodes being parentless when
appending, but we've already seen 1 case where appendCallback receives a
connected node.

We're now seeing something in appendBeforeSiblingCallback, but we have a clearer
picture of how this is happening. In this case, it's via custom element
upgrading and the custom element constructor has already placed the node in
the document.

It's worth pointing, html5ever just has an opaque reference to our node. While
it guarantees that it will give us parent-less nodes, it doesn't actually know
anything about our nodes, or our node._parent. The guarantee is only from its
own point of view. There's nothing stopping us from giving a node a default
parent as soon as html5ever asks us to create a new node, in which case, the
node _will_ have a parent.
2026-02-18 10:52:51 +08:00
Adrià Arrufat
0ec4522f9e Update test with new --dump flag 2026-02-18 11:39:14 +09:00
Adrià Arrufat
c6e0c6d096 Simplify tests in markdown 2026-02-18 11:20:22 +09:00
Adrià Arrufat
dc0fb9ed8a Remove unused import in markdown 2026-02-18 11:04:12 +09:00
Adrià Arrufat
66d9eaee78 Simplify block element rendering in Markdown 2026-02-18 11:01:00 +09:00
Karl Seguin
3797272faf Merge pull request #1576 from lightpanda-io/navigation-is-event-target
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Navigation is the EventTarget
2026-02-18 07:33:48 +08:00
Karl Seguin
682b302d04 Merge pull request #1573 from egrs/response-statustext
Populate Response.statusText for network responses
2026-02-18 07:28:55 +08:00
Karl Seguin
1de10f9b05 Merge pull request #1572 from egrs/intersection-observer-timestamp
Use performance.now() for IntersectionObserverEntry.time
2026-02-18 07:21:57 +08:00
Muki Kiboigo
c4391ff058 refactor modifyBy implementations 2026-02-17 12:11:29 -08:00
Muki Kiboigo
3822e3f8d9 pass selection/modify-extend-word-trailing-inline-block.tentative.html 2026-02-17 12:02:34 -08:00
Muki Kiboigo
f8f99f3878 pass selection/modify.tentative.html 2026-02-17 11:58:13 -08:00
Muki Kiboigo
e77e4acea9 add tests for Selection.modify 2026-02-17 11:50:38 -08:00
Muki Kiboigo
c6de444d0b add support for Selection.modify 2026-02-17 11:50:30 -08:00
Muki Kiboigo
89e38c34b8 remove NavigationEventTarget 2026-02-17 11:13:39 -08:00
Muki Kiboigo
246d17972c add standaloneEventTarget to the Factory 2026-02-17 11:13:27 -08:00
Pierre Tachoire
55a8b37ef8 Merge pull request #1575 from lightpanda-io/remove-merge-marker
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
remove merge marker from test file
2026-02-17 18:29:12 +01:00
Pierre Tachoire
445183001b remove merge marker from test file 2026-02-17 18:27:54 +01:00
egrs
ca9e2200da use async delivery for buffered performance observer entries
Per spec, buffered entries should be delivered via a queued task,
not synchronously. Extract scheduling logic into
schedulePerformanceObserverDelivery() and use it from both
notifyPerformanceObservers and the buffered observe path.
2026-02-17 18:16:42 +01:00
Pierre Tachoire
eba3f84c04 Merge pull request #1569 from egrs/cdp-cookie-size
add cookie size to CDP response
2026-02-17 17:57:47 +01:00
Pierre Tachoire
867e6a8f4b Merge pull request #1566 from egrs/mouseevent-buttons-property
implement MouseEvent.buttons property
2026-02-17 17:57:30 +01:00
egrs
df9779ec59 restrict buffered option to type-based observation per spec
The buffered option is only valid with the type option, not entryTypes,
per the Performance Observer specification.
2026-02-17 16:12:20 +01:00
egrs
1b71d1e46d fix playing event: only dispatch on paused-to-playing transition
Per MDN, playing fires "after playback is first started, and whenever
it is restarted." A second play() while already playing should be a
no-op. Both play and playing now only fire on the paused -> playing
transition.
2026-02-17 16:10:49 +01:00
egrs
0a58918f47 address review: conditional event dispatch and explicit options
- play event only fires when transitioning from paused to playing
- pause event only fires when transitioning from playing to paused
- playing event always fires on play() per spec
- explicitly set bubbles: false, cancelable: false on events
- updated tests to verify no duplicate events on repeated calls
2026-02-17 16:03:00 +01:00
egrs
afbd927fc0 support buffered option in PerformanceObserver.observe()
When buffered is true, deliver existing performance entries that match
the observer's interest immediately, per the Performance Observer spec.
2026-02-17 15:58:38 +01:00
egrs
2aa09ae18d populate Response.statusText for network responses
Fetch responses from the network now have their statusText set using
the HTTP status phrase (e.g. "OK", "Not Found"), matching the Fetch
spec and the existing XMLHttpRequest behavior.
2026-02-17 15:49:20 +01:00
egrs
09789b0b72 use performance.now() for IntersectionObserverEntry.time
The time field was hardcoded to 0.0. Now uses the Performance API
to provide a real DOMHighResTimeStamp, matching the spec.
2026-02-17 15:38:02 +01:00
Halil Durak
2426abd17a introduce persisted typed arrays 2026-02-17 16:35:42 +03:00
Pierre Tachoire
db4a97743f Merge pull request #1562 from lightpanda-io/robots-cdp-failure
dispatch .page_navigated event on page error callback and create HTML page
2026-02-17 14:17:44 +01:00
Pierre Tachoire
7ca98ed344 Merge pull request #1568 from lightpanda-io/invalid_cookie_samesite
protect against long invalid samesite cookie values
2026-02-17 14:10:24 +01:00
egrs
c9d3d17999 dispatch play, playing, pause, and emptied events from HTMLMediaElement
play(), pause(), and load() now fire the corresponding DOM events,
matching the HTMLMediaElement spec behavior.
2026-02-17 13:22:32 +01:00
egrs
628049cfd7 add cookie size to CDP response
Compute and include the cookie size field (name.len + value.len)
in Storage.getCookies and Network.getCookies CDP responses,
matching Chrome's behavior.
2026-02-17 13:08:02 +01:00
egrs
ae9a11da53 implement MouseEvent.buttons property
Add the `buttons` read-only property to MouseEvent as specified by
the W3C UI Events spec (unsigned short bitmask of currently pressed
buttons). Propagate the field through PointerEvent and WheelEvent
constructors which inherit from MouseEvent.
2026-02-17 12:52:44 +01:00
Karl Seguin
7e097482bc protect against long invalid samesite cookie values 2026-02-17 19:09:29 +08:00
Pierre Tachoire
df1b151587 Merge pull request #1567 from lightpanda-io/http_linefeed_only_ending
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Support HTTP headers which are \n terminated (as opposed to \r\n).
2026-02-17 12:09:05 +01:00
Karl Seguin
45eb59a5aa Support HTTP headers which are \n terminated (as opposed to \r\n).
Looks like curl will accept these as valid headers, and won't normalize the
header, so we have to deal with either a 2-byte or 1-byte terminated header
2026-02-17 18:55:50 +08:00
Karl Seguin
687c17bbe2 Merge pull request #1557 from lightpanda-io/internal_field_caching
Add internal field caching (for window.document and window.console)
2026-02-17 18:25:51 +08:00
Pierre Tachoire
7505aec706 generate always an HTML on pageDoneCallback
Add also image support
2026-02-17 10:31:47 +01:00
Pierre Tachoire
c7b414492d add image content type detection into Mime 2026-02-17 10:30:47 +01:00
Pierre Tachoire
14b0095822 move page error HTML creation into pageDoneCallback
Now pageErrCllaback call pageDoneCallback to finalize the page.
2026-02-17 09:47:45 +01:00
Karl Seguin
a1256b46c8 Merge pull request #1553 from lightpanda-io/nikneym/image-data
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Basic support for `ImageData`
2026-02-17 08:57:31 +08:00
Halil Durak
094270dff7 prefer snake case in enums 2026-02-17 02:48:30 +03:00
Halil Durak
d4e24dabc2 internal -> handle 2026-02-17 02:47:51 +03:00
Halil Durak
842df0d112 extern struct -> struct 2026-02-17 02:46:36 +03:00
Halil Durak
cfa9427d7c ImageData: make sure that width and height are not 0 2026-02-17 02:18:43 +03:00
Halil Durak
3c01e24f02 ImageData: remove unnecessary comments 2026-02-17 02:18:13 +03:00
Karl Seguin
22dbf63ff9 Merge pull request #1563 from lightpanda-io/wp/mrdimidium/fix-sighandler-rc
Fix race condition in sighandler
2026-02-17 07:08:39 +08:00
Karl Seguin
814f7394a0 Merge pull request #1556 from lightpanda-io/robots-perf
`robots.txt` performance improvements
2026-02-17 06:58:28 +08:00
Halil Durak
9a4cebaa1b ImageData: prefer new typed array type 2026-02-17 01:45:40 +03:00
Halil Durak
c30207ac63 introduce js.createTypedArray
A new way to create typed arrays that allows using the same memory.
2026-02-17 01:45:19 +03:00
Nikolay Govorov
77afbddb91 Fix race condition in sighandler 2026-02-16 21:28:29 +00:00
Nikolay Govorov
18feeabe15 Merge pull request #1561 from lightpanda-io/wp/mrdimidium/drop-logs-interceptors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Remove logs interceptors feature
2026-02-16 17:45:58 +00:00
Pierre Tachoire
c3811d3a14 Merge pull request #1559 from lightpanda-io/handle_undefined_symbol
handle undefined symbol in debug log
2026-02-16 18:32:50 +01:00
Pierre Tachoire
f20d6b551d Merge pull request #1558 from lightpanda-io/empty_array_buffer
don't try to create empty backing store data
2026-02-16 18:31:51 +01:00
Pierre Tachoire
311bcadacb create HTML page error on page error callback. 2026-02-16 18:17:07 +01:00
Pierre Tachoire
2189c8cd82 dispatch .page_navigated event on page error callback
When a CDP client navigates to a page and the page generates an error,
it blocks waiting for the .page_navigated event.

It currently happens w/ robots.txt denied page.
Example: https://httpbin.io/deny
2026-02-16 17:46:37 +01:00
Nikolay Govorov
6553bb8147 Remove los interceptors feature 2026-02-16 15:48:18 +00:00
Adrià Arrufat
dea492fd64 Unify dump flags into --dump <format> 2026-02-17 00:42:06 +09:00
Karl Seguin
00ab7f04fa handle undefined symbol in debug log 2026-02-16 23:41:04 +08:00
Adrià Arrufat
d3ba714aba Rename --dump flag to --html
--dump is still supported but deprecated
2026-02-17 00:35:15 +09:00
Adrià Arrufat
748b37f1d6 Rename --dump-markdown to --markdown 2026-02-17 00:21:10 +09:00
Karl Seguin
b83b188aff don't try to create empty backing store data 2026-02-16 23:18:25 +08:00
Karl Seguin
cfefa32603 Merge pull request #1555 from lightpanda-io/link_crossorigin
add link crossOrigin accessor
2026-02-16 22:56:50 +08:00
Karl Seguin
85d8db3ef9 Merge pull request #1554 from lightpanda-io/crypto_digest
Add SubtleCrypto.digest (used in nytimes)
2026-02-16 22:56:34 +08:00
Adrià Arrufat
3c14dbe382 Trim trailing whitespace in pre blocks in Markdown 2026-02-16 21:34:46 +09:00
Adrià Arrufat
b49b2af11f Stop escaping periods in markdown 2026-02-16 21:29:57 +09:00
Adrià Arrufat
425a36aa51 Add support for table rendering in Markdown 2026-02-16 21:24:24 +09:00
Adrià Arrufat
ec0b9de713 Add support for ordered lists in Markdown 2026-02-16 21:04:06 +09:00
Adrià Arrufat
9f13b14f6d Add support strikethrough and task lists in Markdown 2026-02-16 20:55:51 +09:00
Karl Seguin
01e83b45b5 Add internal field caching (for window.document and window.console)
This expands the caching capabilities which were first added in
https://github.com/lightpanda-io/browser/pull/1552

Internal field caching requires up-front memory, but is faster. It is currently
enabled for window.document and window.console - two very frequently accessed
values.

Implementations must correctly provide an internal field index, with
consideration for index 0 which may or may not be reserved for the type (it
depends on the type). comptime checks run to make sure this is correct, but it
would probably be nice to at least let them be declared in any order.

This commit also removes the special handling for loading the window. This used
to rely on the window not having any internal fields, but it now has them for
caching so it can't be detected that way. Instead, the window is loaded like any
other object. (But now we have to special case the initial window TAO creation
to make it behave like any other Zig instance).
2026-02-16 19:16:39 +08:00
Halil Durak
f80566e0cb ImageData: add a type entry in bridge.zig 2026-02-16 11:21:33 +03:00
Karl Seguin
42afacf0af add link crossOrigin accessor 2026-02-16 08:32:42 +08:00
Karl Seguin
2e61e7e682 Add SubtleCrypto.digest (used in nytimes) 2026-02-16 08:10:06 +08:00
Karl Seguin
3de9267ea7 Merge pull request #1552 from lightpanda-io/v8_private_cache
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add v8 private object cache + Node.childNodes caching
2026-02-16 06:58:14 +08:00
Karl Seguin
8c99d4fcd2 Merge pull request #1547 from lightpanda-io/more_events
add FocusEvent, TextEvent and WheelEvent
2026-02-16 06:58:03 +08:00
Adrià Arrufat
be4e6e5ba5 Escape special characters and handle whitespace in Markdown 2026-02-16 00:10:00 +09:00
Adrià Arrufat
1b5efea6eb Add --dump-markdown flag
Add a new module to handle HTML-to-Markdown conversion and
integrate it into the fetch command via a new CLI flag.
2026-02-15 23:18:01 +09:00
Halil Durak
6554f80fad ImageData: constructor can throw DOM exceptions 2026-02-15 16:59:02 +03:00
Halil Durak
2e8a9f809e ImageData: add test 2026-02-15 16:52:50 +03:00
Halil Durak
dc66032720 ImageData: initial support
We're missing fancy HDR range currently, though, most websites stick to sRGB.
2026-02-15 16:52:35 +03:00
Karl Seguin
c9433782d8 Merge pull request #1551 from lightpanda-io/nikneym/u8-clamped-array
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
`simpleZigValueToJs`: support `Uint8ClampedArray`
2026-02-15 19:55:11 +08:00
Karl Seguin
fef5586ff5 add FocusEvent, TextEvent and WheelEvent 2026-02-15 19:45:00 +08:00
Pierre Tachoire
1f4a2fd654 Merge pull request #1549 from lightpanda-io/script_header_assertion
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add more granular assertions
2026-02-15 11:52:26 +01:00
Pierre Tachoire
8243385af6 Merge pull request #1546 from lightpanda-io/document_createEvent
add more types to document.createEvent
2026-02-15 11:51:13 +01:00
Halil Durak
26ce9b2d4a Uint8ClampedArray: implement .copy variant 2026-02-15 12:12:45 +03:00
Halil Durak
119f3169e2 Uint8ClampedArray: update comment + change enum literals
I feel `ref` and `copy` is on-point on what's being actually happening.
2026-02-15 12:11:44 +03:00
Karl Seguin
16bd22ee01 improve comments 2026-02-15 17:05:37 +08:00
Karl Seguin
f4a5f73ab2 Add v8 private object cache + Node.childNodes caching
Very simply, this PR ensures that:

  div.childNodes === div.childNodes

Previously, each invocation of childNodes would return a distinct object. Not
just inefficient, but incorrect.

Where this gets more complicated is the how.

The simple way to do this would be to have an optional `_child_nodes` field in
Node. When it's called the first time, we load and return it and, on subsequent
calls we can return it from the field directly.

But we generally avoid this pattern for data that we don't expect to be called
often relative to the number of instances. A page with 20K nodes _might_ see
.childNodes called on 1% of those, so storing a pointer in Nodes which isn't
going to be used isn't particularly memory efficient.

Instead, we have (historically) opted to store this in a page-level map/lookup.
This is used extensively for various element properties, e.g. the page
_element_class_lists lookup.

I recently abandoned work on v8 property caching
(https://github.com/lightpanda-io/browser/pull/1511). But then I looked into the
performance on a specific website with _a lot_ of DOMRect creation and I started
to think about both caching and pure-v8 DOM objects. So this PR became a
two-birds with one stone kind of deal. It re-introduces caching as a means to
solve the childNodes correctness. This uses 1 specific type of caching mechanism,
hooking into a v8::object's Private data map, but the code should be easily
extendable to support a faster (but less memory efficient, depending on the use
case) option: internal fields.
2026-02-15 11:34:01 +08:00
Karl Seguin
e61a4564ea Merge pull request #1545 from lightpanda-io/node_list_index
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
NodeList index fix + Node.childNode fix
2026-02-15 09:32:28 +08:00
Karl Seguin
e72edee1f2 Merge pull request #1548 from lightpanda-io/HTMLDListElement
Add HTMLDListElement
2026-02-15 07:45:38 +08:00
Karl Seguin
e8c150fcac Merge pull request #1544 from lightpanda-io/caller_function
Re-arrange Caller
2026-02-15 07:45:25 +08:00
Halil Durak
52418932b1 simpleZigValueToJs: support Uint8ClampedArray
Needed this to implement `ImageData#data` getter. This works differently than other typed arrays since returned object can be mutated from both Zig and JS ends.
2026-02-15 02:03:11 +03:00
Karl Seguin
4f81cb9333 Add more granular assertions
Trying to see how the "ScriptManager.Header buffer" assertion is failing. Either
`headerCallback` is being called multiple times, or the script is corrupt. By
adding a similar assertion in various places, we can hopefully narrow (a) what's
going on and (b) what code is involved.

Also, switched the BufferPool from DoublyLinkedList to SinglyLinkedList. Was
just reviewing this code (to see if the buffer could possibly become corrupt)
and realized this could be switched.
2026-02-14 20:01:09 +08:00
Karl Seguin
db46f47b96 Add HTMLDListElement
Fix names for ol and ul elements
2026-02-14 17:11:29 +08:00
Karl Seguin
edfe5594ba add more types to document.createEvent 2026-02-14 15:09:46 +08:00
Karl Seguin
f25e972594 NodeList index fix + Node.childNode fix
An index out of range request to a nodelist , e.g. childNodes[1000] now properly
returns a error.NotHandled error, which is given to v8 as a non-intercepted
property.

When a ChildNode node list is created from Node.childNode, we store the *Node
rather than its children. ChildNode is meant to be live, so if the node's
children changes, we should capture that.
2026-02-14 14:51:33 +08:00
Karl Seguin
d5488bdd42 Re-arrange Caller
This is preparatory work for re-introducing property caching and pure v8 WebAPIs

It does 3 things:

1 - It removes the duplication of method calling we had in Accessors and
    Functions type briges.

2 - It flattens the method-call chain. It used to be some code in bridge, then
    method, then _method. Most of the code is now in the same place. This will
    be important since caching requires the raw js_value, which we previously
    didn't expose from _method. Now, it's just there.

3 - Caller used to do everything. Then we introduced Local and a lot of Caller
    methods didn't need caller itself, they just needed &self.local. While those
    methods remain in Caller.zig, they now take a *const Local directly and thus
    can be called without Caller, making them usable without a Caller.
2026-02-14 14:22:46 +08:00
Karl Seguin
bbff64bc96 Merge pull request #1543 from lightpanda-io/zig_fmt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig fmt
2026-02-14 14:20:43 +08:00
Karl Seguin
635afefdeb Merge pull request #1540 from lightpanda-io/tao_v8_storage
Optimize tao storage in v8 object.
2026-02-14 14:14:51 +08:00
Karl Seguin
fd3e67a0b4 zig fmt 2026-02-14 14:06:25 +08:00
Karl Seguin
729a6021ee update v8 dep 2026-02-14 14:06:03 +08:00
Karl Seguin
309f254c2c Optimize toa storage in v8 object.
We're currently using Get/SetInternalField to store our toa instance in v8. This
appears to be meant for v8 data itself, as it participates in the GC's
referencing counting. This is a bit obvious by the fact that it expects a
v8::Data, we we're able to do by wrapping our toa into a v8::External.

The Get/SetAlignedPointerFromInternalField seem specifically designed for toa,
as it takes a (void *) (thus, not requiring the external wrapper) and, from what
I understand is more efficient (presumably because the GC ignores it).

Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/149
2026-02-14 14:04:25 +08:00
Karl Seguin
5c37f04d64 Merge pull request #1539 from lightpanda-io/range_set_validation
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add missing validation to range setStart and setEnd
2026-02-14 07:03:27 +08:00
Karl Seguin
7c3dd8e852 Merge pull request #1538 from lightpanda-io/browser_arenas
Remove custom-arenas, use ArenaPool instead
2026-02-14 07:03:14 +08:00
Karl Seguin
66ddedbaf3 Merge pull request #1537 from lightpanda-io/input_click
Default behavior for input click (radio / checkbox).
2026-02-14 07:02:05 +08:00
Karl Seguin
7981b17897 Merge pull request #1541 from lightpanda-io/range_compare_boundary_points
Fix Range.compareBoundaryPoint
2026-02-14 07:01:52 +08:00
Pierre Tachoire
62137d47c8 Merge pull request #1533 from lightpanda-io/element_positions
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add various element position properties
2026-02-13 15:30:21 +01:00
Karl Seguin
e3b5437f61 fix test to match swapped (correct) implementation 2026-02-13 16:28:53 +08:00
Karl Seguin
934693924e Fix Range.compareBoundaryPoint
START_TO_END and END_TO_START had their logic swapped. This fixes the remaining
862 failing cases in dom/ranges/Range-compareBoundaryPoints.html
2026-02-13 15:06:17 +08:00
Muki Kiboigo
308fd92a46 chunking robot store hashing 2026-02-12 22:55:40 -08:00
Karl Seguin
da1eb71ad0 Add missing validation to Range.comparePoint
Also re-order the validation to match what  WPT expects. Fixes all cases in
dom/ranges/Range-comparePoint.html
2026-02-13 14:54:43 +08:00
Muki Kiboigo
576dbb7ce6 switch to iterative match solving 2026-02-12 22:54:35 -08:00
Muki Kiboigo
d0c381b3df partial compilation of robot rules 2026-02-12 22:32:29 -08:00
Muki Kiboigo
55178a81c6 skip empty disallow rules during robots parsing 2026-02-12 22:08:16 -08:00
Muki Kiboigo
249308380b no more longest match tracking in robots 2026-02-12 22:07:04 -08:00
Karl Seguin
d91bec08c3 Add missing validation to range setStart and setEnd
Fixes the remaining failing cases (400!) of WPT dom/ranges/Range-set.html
2026-02-13 13:47:26 +08:00
Karl Seguin
e23ef4b0be Remove custom-arenas, use ArenaPool instead
This removes the browser-specific arenas (session, transfer, page, call) in
favor of the arena pool.

This is a bit of a win-lose commit. It exists as (the last?) step before I can
really start working on frames. Frames will require their own "page" and "call"
arenas, so there isn't just 1 per browser now, but rather N, where N is the
number of frames + 1 page. This change was already done for Contexts when
ExecutionWorld was removed, and the idea is the same: making these units more
self contained so to support cases where we break out of the "1" model we
currently have (1 browser, 1 session, 1 page, 1 context, ...).

But it's a bit of a step backwards because the ArenaPool is dumb and just resets
everything to a single hard-coded (for now) value: 16KB. But in my mind, an
arena that's used for 1 thing (e.g. the page or call arenas) is more likely to
be well-sized for that specific role in the future, even on a different
page/navigate.

I think ultimately, we'll move to an ArenaPool that has different levels, e.g.
acquire() and acquireLarge() which can reset to different sizes, so that a page
arena can use acquireLarge() and retain a larger amount of memory between use.
2026-02-13 12:34:27 +08:00
Karl Seguin
6037521c49 Default behavior for input click (radio / checkbox).
This wasn't 100% intuitive to me. At the start of the event, the input is
immediately toggled. But at any point during dispatching, the default behavior
can be suppressed. So the state of the input's check during dispatching captures
the "intent" of the click. But it's possible for one listener to see that
input.checked == true even though, by the end of dispatching, input.checked ==
false because some other listener called preventDefault().

To support this, we need to capture the "current" state so that, if we need to
rollback, we can. For radio buttons, this "current" state includes capturing
the currently checked radio (if any).
2026-02-13 11:06:46 +08:00
Pierre Tachoire
a27fac3677 Merge pull request #1535 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: fix path
2026-02-12 16:04:40 +01:00
Pierre Tachoire
21f2eb664e ci: fix path 2026-02-12 15:44:28 +01:00
Pierre Tachoire
81546ef4b0 Merge pull request #1534 from lightpanda-io/wp/mrdimidium/ram-e2e-tests 2026-02-12 13:32:15 +01:00
Karl Seguin
4b90c8fd45 Add various element position properties
clientTop, clientLeft, scrollTop, scrollLeft, scrollHeight, scrollWidth,
offsetTop, offsetLeft, offsetWidth, offsetHeight.

These are all dummy implementation that hook, as much as possible, into what
layout information we have.

Explicitly set scroll information is stored on the page.
2026-02-12 19:30:29 +08:00
Pierre Tachoire
c643fb8aac ci: fix var name 2026-02-12 12:07:15 +01:00
Pierre Tachoire
0cae6ceca3 Merge pull request #1510 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
Use cgroups for RAM mesurement
2026-02-12 11:55:02 +01:00
Karl Seguin
5cde59b53c Merge pull request #1528 from lightpanda-io/remove_recurise_curl_calls
Remove potential recursive abort call in curl
2026-02-12 18:42:09 +08:00
Pierre Tachoire
7df67630af ci: add cg_mem_peak into the tracked results 2026-02-12 11:33:52 +01:00
Karl Seguin
0c89dca261 When _not_ in a libcurl callback, deinit the transfer normally 2026-02-12 18:29:35 +08:00
Pierre Tachoire
6b953b8793 ci: keep both vm and cg memory regression tests 2026-02-12 11:24:59 +01:00
Karl Seguin
0d1defcf27 Merge pull request #1522 from lightpanda-io/remove_page_reset
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove Page.reset
2026-02-12 17:47:13 +08:00
Pierre Tachoire
c1db9c19b3 Merge pull request #1532 from lightpanda-io/document_scrollingElement
Add document.scrollingElement read-only property
2026-02-12 09:49:16 +01:00
Pierre Tachoire
95487755ed Merge pull request #1531 from lightpanda-io/dom_token_list_compat
Improve compliance of DOMTokenList
2026-02-12 09:49:09 +01:00
Pierre Tachoire
4813469659 Merge pull request #1530 from lightpanda-io/node_contains_null
Allow node.contains(null) (false), per spec
2026-02-12 09:48:14 +01:00
Karl Seguin
4dfd357c0b Merge pull request #1529 from lightpanda-io/get_elements_by_tag_name_ns
Get elements by tag name ns
2026-02-12 16:41:56 +08:00
Karl Seguin
4ca0486518 Add document.scrollingElement read-only property
From MDN: In standards mode, this is the root element of the document,
document.documentElement.

Easy enough.
2026-02-12 14:10:38 +08:00
Karl Seguin
b139c05960 Improve compliance of DOMTokenList
1 - Make element.classList settable
2 - On replace, validate in expected order
3 - On replace, fire mutation observer even if new == old
4 - On replace, handle duplicate values
2026-02-12 14:07:31 +08:00
Karl Seguin
3d32759030 Allow node.contains(null) (false), per spec 2026-02-12 12:39:07 +08:00
Karl Seguin
badfe39a3d Refactor common Document and Element methods into Node 2026-02-12 12:31:05 +08:00
Karl Seguin
060e2db351 Add getElementsByTagNameNS
Do what we can based on the ns and names that we currently have.

Also also, allow element names with non-ascii characters.

both changes are driven by WPT dom/nodes/case.html
2026-02-12 12:25:21 +08:00
Karl Seguin
ed802c0404 Remove potential recursive abort call in curl
Curl doesn't like recursive calls. For example, you can't call
curl_multi_remove_handle from within a dataCallback.

This specifically means that, as-is, transfer.abort() calls aren't safe to be
called during a libcurl callback. Consider this code:

```
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
  req.abort();
}
req.send();
```

onreadystatechange is triggered by network events, i.e. it executes in libcurl
callback. Thus, the above code fails to truly "abort" the request with
`curl_multi_remove_handle` error, saying it's a recursive call.

To solve this, transfer.abort() now sets an `aborted = true` flag. Callbacks can
now use this flag to signal to libcurl to stop the transfer.

A test was added which reproduced this issue, but this comes from:
https://github.com/lightpanda-io/browser/issues/1527  which I wasn't able to
reliably reproduce. I did see it happen regularly, just not always. It seems
like this commit fixes that issue.
2026-02-12 11:29:47 +08:00
Karl Seguin
5d8739bfb2 Merge pull request #1524 from lightpanda-io/trigger_inline_handlers
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Trigger inline handlers
2026-02-12 07:51:55 +08:00
Karl Seguin
086faf44fc Trigger inline handlers
This is a follow up / fix to https://github.com/lightpanda-io/browser/pull/1487

In that PR we triggered a "load" event for special elements, and as part of that
we triggered both the "onload" attribute via dispatchWithFunction and normal
bubbling with dispatch.

This PR applies this change generically and holistically. For example, if an
"abort" event is raised, the "onabort" attribute will be generated for that
element. Importantly, this gets executed in the correct dispatch order and
respect event cancellation (stopPropagation and stopImmediatePropagation).
2026-02-12 07:38:12 +08:00
Karl Seguin
e5eaa90c61 Merge pull request #1523 from lightpanda-io/query_selector_edge
Support a few more selector edge cases
2026-02-12 07:37:36 +08:00
Karl Seguin
b24807ea29 Merge pull request #1525 from lightpanda-io/global_iterability
Ability to remove a type from the global's iterable list.
2026-02-12 07:36:25 +08:00
Karl Seguin
d68bae9bc2 Merge pull request #1526 from lightpanda-io/node_properties
Passes all test for WPT dom/nodes/Node-properties.htm
2026-02-12 07:36:13 +08:00
Halil Durak
b891fb4502 Merge pull request #1486 from lightpanda-io/nikneym/hash-map-change
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
Change hash generation of global event handlers
2026-02-11 16:55:07 +03:00
Halil Durak
ea69b3b4e3 wrap assertions with comptime if 2026-02-11 16:45:31 +03:00
Karl Seguin
23c8616ba5 Passes all test for WPT dom/nodes/Node-properties.htm
Documents created via DOMImplementation should not inherit the page.URL.

Add textContent for DocumentFragment

Fix creteElement for XML namespace when not explicitly specified
2026-02-11 21:20:16 +08:00
Karl Seguin
b25c91affd Fix a few more cases
From dom/nodes/ParentNode-querySelector-escapes.html
2026-02-11 21:11:27 +08:00
Karl Seguin
151cefe0ec Ability to remove a type from the global's iterable list.
Some types, like AbortController, shouldn't be iterable on the window. This
commit (a) adds the ability to control this in the snapshot, and sets the
iterability based on the dom/interface-objects.html wpt test list.
2026-02-11 20:53:13 +08:00
Karl Seguin
3412ff94bc Support a few more selector edge cases
Trailing escape sequence (https://github.com/lightpanda-io/browser/issues/1515)
and tags started with non-ascii letters.
2026-02-11 14:37:20 +08:00
Karl Seguin
77aa2241dc Merge pull request #1520 from lightpanda-io/robots-fix-wikipedia
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix how `robots.txt`handles utf8
2026-02-11 14:05:38 +08:00
Karl Seguin
0766d08479 Merge pull request #1508 from lightpanda-io/selectionchange-event
Support `selectionchange` Event
2026-02-11 14:02:44 +08:00
Karl Seguin
14112ed294 Remove Page.reset
Page.reset exists for 1 use case: multiple calls to the Page.navigate CDP
method. At an extreme, something like this in puppeteer:

```
await page.goto(baseURL + '/campfire-commerce/');
await page.goto(baseURL + '/campfire-commerce/');
```

Rather than handling this generically in Page, we now handle this case
specifically at the CDP layer. If the page isn't in its initial load state,
i.e. page._load_state != .waiting, then we reload the page from the session.

For reloading, my initial inclination was to do session.removePage then
session.createPage(). This behavior still seems potentially correct to me, but
compared to our `reset`, this would trigger extra notifications, namely:

self.notification.dispatch(.page_remove, .{});

and

self.notification.dispatch(.page_created, page);

Bacause of https://github.com/lightpanda-io/browser/pull/1265/ I guess that
could have side effects. So, to keep the behavior as close to the current
"reset", a new `session.replacePage()` has been added which behaves a lot like
removePage + createPage, but without the notifications being sent.

While I generally think this is just cleaner, this was largely driven by some
planning for frame support. The entity for a Frame will share a lot with the
Page (we'll extract that logic), so simplifying the Page, especially around
initialization, helps simplify frame support.
2026-02-11 13:53:49 +08:00
Karl Seguin
f6ed0d43a2 Merge pull request #1521 from lightpanda-io/update-nix-flake
Update Nix Flake
2026-02-11 13:53:10 +08:00
Muki Kiboigo
c8413cb029 run selectionchange tests eventually 2026-02-10 21:48:56 -08:00
Karl Seguin
97d53b81a7 Give EventManager.dispatch and explicit error set
This allows potentially recursive callers to use an implicit error set return.
2026-02-11 12:50:59 +08:00
Muki Kiboigo
ab888f5cd0 update nix flake 2026-02-10 20:37:19 -08:00
Muki Kiboigo
f54246eac1 remove anyerror from TextArea dispatchSelectionChangeEvent 2026-02-10 20:35:37 -08:00
Muki Kiboigo
7de9422b75 strip utf8 bom from start of robots.txt 2026-02-10 20:29:39 -08:00
Muki Kiboigo
f02a37d3f0 properly handle failed parsing on robots 2026-02-10 20:09:32 -08:00
Karl Seguin
28815a0ae6 Merge pull request #1518 from lightpanda-io/visual_viewport
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
implement a dummy VisualViewport
2026-02-11 07:14:54 +08:00
Karl Seguin
70c7dfd0f4 Merge pull request #1517 from lightpanda-io/input_type_change
Sanitize input values based on type (and on type change)
2026-02-11 07:14:44 +08:00
Karl Seguin
9c2ebd308b Merge pull request #1516 from lightpanda-io/unhandle_rejection_callback
Adds PromiseRejectionCallback
2026-02-11 07:14:26 +08:00
Karl Seguin
11d8412591 Merge pull request #1514 from lightpanda-io/selection_wpt_fixes
Fixes extend-20.html and extend-00.html
2026-02-11 07:14:14 +08:00
Karl Seguin
32ca170c4d Merge pull request #1513 from lightpanda-io/fix_crash_on_double_http_abort
Fix double-free of XHR on double client abort
2026-02-11 07:14:01 +08:00
Karl Seguin
388ed08b0e Merge pull request #1512 from lightpanda-io/fix_scheduled_navigation
Fix schedule navigation
2026-02-11 07:13:48 +08:00
Pierre Tachoire
3e1909b645 ci: use cgroups with user's permissions 2026-02-10 18:43:31 +01:00
Pierre Tachoire
b408f88b8c Merge pull request #1519 from lightpanda-io/readme-robotstxt
Update README w/ robots.txt
2026-02-10 14:40:49 +01:00
Pierre Tachoire
09087401b4 update README examples 2026-02-10 14:35:39 +01:00
Pierre Tachoire
c68692d78e add --obey_robots into README examples 2026-02-10 14:24:38 +01:00
Karl Seguin
ee2a4d0a5d implement a dummy VisualViewport 2026-02-10 18:08:52 +08:00
Karl Seguin
a15885fe80 Merge pull request #1505 from lightpanda-io/class_name_tokenizer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix class name iterator
2026-02-10 17:07:51 +08:00
Karl Seguin
24111570cf Sanitize input values based on type (and on type change)
Per spec.
2026-02-10 16:59:00 +08:00
Karl Seguin
ded203b1c1 Adds PromiseRectionCallback
Fires the window.onunhandledrejection. This API is a bit different than
everything else, because it's entered from the Isolate/Env. So there's a bit
more js -> webapi awareness baked into Env now to handle it.

Also touched up existing Events that have .Global data and changed it to .Temp
and cleaned it up in their deinit.
2026-02-10 16:50:11 +08:00
Karl Seguin
e43fc98c0d Merge pull request #1511 from lightpanda-io/remove_unused_cache
Remove unused cache v8 bridge hint.
2026-02-10 16:11:07 +08:00
Karl Seguin
1efd13545e apply similar fix to selectAllChildren 2026-02-10 12:23:41 +08:00
Karl Seguin
1193ee1ab9 Fixes extend-20.html and extend-00.html 2026-02-10 12:04:02 +08:00
Karl Seguin
a6ba801738 Fix double-free of XHR on double client abort
It's possible (in fact normal) for client.abort to be called twice on a schedule
navigation. We immediately abort any pending requests once a secondary
navigation is called (is that right?), and then again when the page shuts down.

The first abort will kill the transfer, so the XHR object has to null this value
so that, on context shutdown, when the finalizer is called, we don't try to
free it again.
2026-02-10 11:30:10 +08:00
Karl Seguin
e7958f2910 Fix schedule navigation
Previously, the Session depended on the page to return a .navigate state when
a secondary navigation should take place. There were a lot of places where the
page returned .done rather than .navigate.

To simplify the code, .navigate is no longer a WaitResult. The page now just has
to return .done, and the Session will check if there's a queued sub-navigation.

This will fix cases where sub-navigation is triggered by a script being executed
as the last step of page loading.
2026-02-10 10:36:18 +08:00
Karl Seguin
cbac9a7703 Remove unused cache v8 bridge hint.
Pre-zigdom, we could use the postAttach behavior to store commonly accessed and
never-changed accessors directly on the v8::object. I've tried to re-enable this
feature a couple times, and have failed.

Between the new Snapshot and the always-on unhandled property handler for Window
it never seems to work very well. Plus, Gemini continues to insist that changing
the shape of the Object is more like to hurt than help performance.

There's possibly an optimization here worth revisiting in the future, but for
the time being, this code does nothing except for possibly deceive a reader that
some optimization is going on when it isn't.
2026-02-10 09:57:28 +08:00
Karl Seguin
60d8f2323e Merge pull request #1509 from lightpanda-io/atob-trim
window.atob must trim input
2026-02-10 09:54:31 +08:00
Karl Seguin
70ae6b8d72 Merge pull request #1407 from lightpanda-io/robots
Support for `robots.txt`
2026-02-10 09:51:32 +08:00
Nikolay Govorov
a4b1fbd6ee Use cgroups for RAM mesurement 2026-02-10 01:34:17 +00:00
Muki Kiboigo
e1850440b0 shutdown queued req on robots shutdown 2026-02-09 15:24:35 -08:00
Pierre Tachoire
d5c2aaeea3 window.atob must trim input 2026-02-09 17:08:32 +01:00
Karl Seguin
a06b7acc85 Merge pull request #1501 from lightpanda-io/html_picture_element
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add HTMLPictureElement
2026-02-09 23:00:59 +08:00
Pierre Tachoire
615168423a Merge pull request #1482 from lightpanda-io/nikneym/failing-document-element-test
Add failing `document.documentElement` `load` event test
2026-02-09 15:56:44 +01:00
Muki Kiboigo
73abf7d20e add tests for selectionchange in HTML Elements 2026-02-09 06:51:26 -08:00
Muki Kiboigo
fcea42e91e properly expose selection api in HTMLTextAreaElement 2026-02-09 06:51:17 -08:00
Pierre Tachoire
14f7574c41 ci: run test on any change into src/ 2026-02-09 15:47:11 +01:00
Muki Kiboigo
8f15ded650 use anyerror for dispatchSelectionChangeEvent 2026-02-09 06:44:32 -08:00
Muki Kiboigo
4aec4ef80a add selectionchange to HTMLTextAreaElement 2026-02-09 06:42:35 -08:00
Muki Kiboigo
ecb8f1de30 add selectionchange to HTMLInputElement 2026-02-09 06:41:58 -08:00
Pierre Tachoire
4c28180125 Merge pull request #1503 from lightpanda-io/node_iterator
Fix 3 WPT cases for NodeIterator
2026-02-09 15:39:24 +01:00
Pierre Tachoire
4138180f43 Merge pull request #1506 from lightpanda-io/allow-propfind-method
http: allow PROPOFIND http method
2026-02-09 15:35:44 +01:00
Muki Kiboigo
0d508a88f6 add selectionchange tests 2026-02-09 06:32:36 -08:00
Muki Kiboigo
7c8fcf73f6 dispatch selectionchange when Selection changes 2026-02-09 06:27:50 -08:00
Karl Seguin
5904d72776 Merge pull request #1504 from lightpanda-io/selection_fix_2
WPT Selection fixes
2026-02-09 22:27:38 +08:00
Muki Kiboigo
5e32ccbf12 add selectionchange handler on Document 2026-02-09 06:27:34 -08:00
Karl Seguin
6ce136bede Merge pull request #1502 from lightpanda-io/selection_fix
Improve Selection compatibility
2026-02-09 22:27:13 +08:00
Pierre Tachoire
b9f8ce5729 http: allow PROPOFIND http method
PROPOFIND is used by webdav to retrieve resource's properties.
http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND

For example, Nextcloud public sharing uses PROPOFIND to list shared resources.
2026-02-09 15:16:28 +01:00
Pierre Tachoire
115530a104 http: add PROPOFIND method test case 2026-02-09 15:16:17 +01:00
Muki Kiboigo
65c9b2a5f7 add robotsShutdownCallback 2026-02-09 05:51:42 -08:00
Muki Kiboigo
46c73a05a9 panic instead of unreachable on robots callbacks 2026-02-09 05:35:32 -08:00
Karl Seguin
c5f7e72ca8 Fix class name iterator
Used to use std.ascii.whitespace, but per spec, it's only a subset of that which
are valid separators. Other characters, line line tabulation, are valid class
names.
2026-02-09 18:34:53 +08:00
Karl Seguin
e6fb63ddba WPT Selection fixes
Same as https://github.com/lightpanda-io/browser/pull/1502 but applied to other
functions.
2026-02-09 18:25:28 +08:00
Karl Seguin
e2645e4126 Fix 3 WPT cases for NodeIterator
- Prevent recursive filters
- Rethrow on filter exception
- Add no-op (as per spec) `detach`
2026-02-09 18:19:36 +08:00
Karl Seguin
36d267ca40 Improve Selection compatibility
collapse-30.html WPT test from from 2753/5133 to 5133/5133.

-Return dom_exception from setPosition
-Verify node containment in document
2026-02-09 17:38:11 +08:00
Karl Seguin
2e5d04389b Merge pull request #1479 from lightpanda-io/event_finalizers_and_arenas
Add Finalizers to events
2026-02-09 17:10:08 +08:00
Karl Seguin
6130ed17a6 Update src/browser/js/Local.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-09 16:56:45 +08:00
Karl Seguin
c4e82407ec Add Finalizers to events
At a high level, this does for Events what was recently done for XHR, Fetch and
Observers. Events are self-contained in their own arena from the ArenaPool and
are registered with v8 to be finalized.

But events are more complicated than those other types. For one, events have
a prototype chain. (XHR also does, but it's always the top-level object that's
created, whereas it's valid to create a base Event or something that inherits
from Event). But the _real_ complication is that Events, unlike previous types,
can be created from Zig or from V8.

This is something that Fetch had to deal with too, because the Response is only
given to V8 on success. So in Fetch, there's a period of time where Zig is
solely responsible for the Response, until it's passed to v8. But with events
it's a lot more subtle.

There are 3 possibilities:
1 - An Event is created from v8. This is the simplest, and it simply becomes a
    a weak reference for us. When v8 is done with it, the finalizer is called.
2 - An Event is created in Zig (e.g. window.load) and dispatched to v8. Again
    we can rely on the v8 finalizer.
3 - An event is created in Zig, but not dispatched to v8 (e.g. there are no
    listeners), Zig has to release the event.

(It's worth pointing out that one thing that still keeps this relatively
straightforward is that we never hold on to Events past some pretty clear point)

Now, it would seem that #3 is the only issue we have to deal with, and maybe
we can do something like:

```
if (event_manager.hasListener("load", capture)) {
   try event_manager.dispatch(event);
} else {
   event.deinit();
}
```

In fact, in many cases, we could use this to optimize not even creating the
event:

```
if (event_manager.hasListener("load, capture)) {
   const event = try createEvent("load", capture);
   try event_manager.dispatch(event);
}
```

And that's an optimization worth considering, but it isn't good enough to
properly manage memory. Do you see the issue? There could be a listener (so we
think v8 owns it), but we might never give the value to v8. Any failure between
hasListener and actually handing the value to v8 would result in a leak.

To solve this, the bridge will now set a _v8_handover flag (if present) once it
has created the finalizer_callback entry. So dispatching code now becomes:

```
const event = try createEvent("load", capture);
defer if (!event._v8_handover) event.deinit(false);
try event_manager.dispatch(event);
```

The v8 finalizer callback was also improved. Previously, we just embedded the
pointer to the zig object. In the v8 callback, we could cast that back to T
and call deinit. But, because of possible timing issues between when (if) v8
calls the finalizer, and our own cleanup, the code would check in the context to
see if the ptr was still valid. Wait, what? We're using the ptr to get the
context to see if the ptr is valid?

We now store a pointer to the FinalizerCallback which contains the context.
So instead of something stupid like:

```
// note, if the identity_map doesn't contain the value, then value is likely
// invalid, and value.page will segfault
value.page.js.identity_map.contains(@intFromPtr(value))
```

We do:
```
if (fc.ctx.finalizer_callbacks.contains(@intFromPtr(fc.value)) {
  // fc.value is safe to use
}
```
2026-02-09 16:56:43 +08:00
Halil Durak
2e28e68c48 make sure async test is dispatched
Better than `testing.expectEqual(true, true)`.
2026-02-09 11:45:31 +03:00
Halil Durak
a7882fa32b add failing document.documentElement load event test
make sure that we're in capturing phase

`testing.expectEqual(true, result)`
2026-02-09 11:41:56 +03:00
Karl Seguin
82f9e70406 Merge pull request #1498 from lightpanda-io/fast_properties
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Convert more comptime-getters to fast getters
2026-02-09 16:04:10 +08:00
Karl Seguin
3fa27c7ffa Merge pull request #1500 from lightpanda-io/console_table
Add console.table
2026-02-09 16:02:40 +08:00
Karl Seguin
1aa50dee20 Merge pull request #1499 from lightpanda-io/http_max_response_size
Add http_max_response_size
2026-02-09 16:02:27 +08:00
Halil Durak
f58f7257be Merge pull request #1487 from lightpanda-io/nikneym/doc-element-dispatch
Changes to trigger `documentElement`s load event
2026-02-09 10:52:53 +03:00
Halil Durak
e17f1269a2 remove unnecessary comment and comptime if 2026-02-09 10:43:22 +03:00
Halil Durak
82f48b84b3 changes to trigger documentElements load event
This fixes the triggering issue; though the order of execution is wrong...
2026-02-09 10:41:42 +03:00
Karl Seguin
926bd20281 Add HTMLPictureElement
Fix tag mapping for various types (including Source, which is Picture related).
2026-02-09 15:38:41 +08:00
Karl Seguin
a6cd019118 Add http_max_response_size
This adds a --http_max_response_size argument to the serve and fetch command
which is enforced by the HTTP client. This defaults to null, no limit.

As-is, the ScriptManager allocates a buffer based on Content-Length. Without
setting this flag, a server could simply reply with Content-Length: 99999999999
9999999999  to cause an OOM. This new flag is checked both once we have the
header if there's a content-length, and when reading the body.

Also requested in https://github.com/lightpanda-io/browser/issues/415
2026-02-09 13:16:18 +08:00
Karl Seguin
bbfc476d7e Add console.table
+ fix unknown object property filters
2026-02-09 12:38:35 +08:00
Karl Seguin
8d49515a3c Convert more comptime-getters to fast getters
Follow up to https://github.com/lightpanda-io/browser/pull/1495 which introduced
the concept of a fast getter. This commit expands the new behavior to all
comptime-known scalar getters.

It also leverages the new `v8__FunctionTemplate__New__Config` to
1 - flag fast getters as having no side effect
2 - set the length (arity) on all functions
2026-02-09 11:35:27 +08:00
Karl Seguin
0a410a5544 Merge pull request #1495 from lightpanda-io/fast_getter
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Adds Document.prerendering
2026-02-08 21:45:06 +08:00
Karl Seguin
eef203633b Adds Document.prerendering
Expands bridge.property to work as a getter. This previously only worked by
setting a value directly on the TemplatePrototype. This is what you want for
something like Node.TEXT_NODE which is accessible on both Node and an instance
(e.g. document.createElement('div').TEXT_NODE).

Now the property can be configured with .{.template = false}. It essentially
becomes an optimized: bridge.accessor(comptime scalar, null, .{});

There are other accessor that can be converted to this type, but I'll do that
after this is merged to keep this PR manageable.
2026-02-08 20:43:58 +08:00
Pierre Tachoire
122255058e Merge pull request #1497 from lightpanda-io/improve_debug_logs
Reduce some debug logs
2026-02-08 10:47:52 +01:00
Pierre Tachoire
b1681b2213 Merge pull request #1496 from lightpanda-io/remove_custom_element_placeholder_name
Remove the "TODO-CUSTOM-NAME" name on custom elements
2026-02-08 10:46:37 +01:00
Pierre Tachoire
73d79f55d8 Merge pull request #1494 from lightpanda-io/window_opener
Add dummy window.opener accessor
2026-02-08 10:43:17 +01:00
Pierre Tachoire
a02fcd97d6 Merge pull request #1493 from lightpanda-io/document_visibility
Add Document.hidden and visibilityState properties
2026-02-08 10:42:33 +01:00
Pierre Tachoire
016708b338 Merge pull request #1492 from lightpanda-io/create_element_case_insensitivity
Make document.createElement and createElementNS case-insensitive for …
2026-02-08 10:41:51 +01:00
Pierre Tachoire
9f26fc28c4 Merge pull request #1491 from lightpanda-io/xhr_with_credentials
Add support for XHR's withCredentials
2026-02-08 10:38:02 +01:00
Pierre Tachoire
7c1b354fc3 Merge pull request #1490 from lightpanda-io/missing_properties
Add a number of [simple] missing properties
2026-02-08 10:36:28 +01:00
Pierre Tachoire
abeda1935d Merge pull request #1489 from lightpanda-io/fix_profile_stringifiers
Fix profiler stringifiers
2026-02-08 10:34:17 +01:00
Karl Seguin
403ee9ff9e Reduce some debug logs
1 - Remove the double logging of "load" dispatch
2 - On even registration, just log the event target type, not the full node
3 - Most significantly, rather than logging unknown properties (both global and
    per object) as they happen, collect them and log the on context ends. This
    log includes a count of how often it happened. On some pages, this reduces
    the number of unknown property messages by thousands.
2026-02-08 14:48:22 +08:00
Karl Seguin
cccb45fe13 Remove the "TODO-CUSTOM-NAME" name on custom elements
Giving a WebAPI a JsApi.Meta.name registers that name on the global object, e.g
window['TODO-CUSTOM-NAME'] becomes a type.
2026-02-08 09:56:53 +08:00
Nikolay Govorov
8d43becb27 Merge pull request #1488 from lightpanda-io/sighandler_serve_only
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move Sighandler to "serve" mode only
2026-02-07 20:32:14 +00:00
Karl Seguin
ee22e07fff Add dummy window.opener accessor
This seems pretty complicated to implement...mostly because we'd have to keep
a window around in certain cases and because exactly what's accessible on that
window depends on a few factors.
2026-02-07 17:51:58 +08:00
Karl Seguin
37464e2d95 Add Document.hidden and visibilityState properties
Always visible. I understand that this is for things like, being on a different
tab or being minimized.
2026-02-07 17:39:12 +08:00
Karl Seguin
5abcecbc9b Make document.createElement and createElementNS case-insensitive for HTML NS
This could more cleanly be applied to page.createElementNS, but that would add
overhead to the parser. Alternative would be to pass a comptime flag to
page.createElementNS (e.g. like the from_parser we pass elsewhere).
2026-02-07 17:21:11 +08:00
Karl Seguin
cecdf0d511 Add support for XHR's withCredentials
XHR should only send and receive cookies for same-origin requests or if
withCredentials is true.
2026-02-07 16:16:10 +08:00
Karl Seguin
a451fe4248 Add a number of [simple] missing properties 2026-02-07 15:28:53 +08:00
Karl Seguin
d6f801f764 Fix profiler stringifiers
This code is commented out by default, so it's easy to get out of date and get
missed by refactorings.
2026-02-07 10:31:25 +08:00
Karl Seguin
a35e772a6b Move Sighandler to "serve" mode only
Currently the sighandler is setup regardless of the running mode, but it only
does something in "serve" mode. In fetch mode, since there are no registered
listeners, it intercepts the signal and does nothing. On MacOS at least, this
isn't a great experience as it can leave the process running in the background.
2026-02-07 08:34:59 +08:00
Karl Seguin
aca3fae6b1 Merge pull request #1484 from lightpanda-io/fix-replace-with
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix Element.replaceWith crash when self replacing
2026-02-07 07:40:47 +08:00
Karl Seguin
17891f0209 Merge pull request #1485 from lightpanda-io/console-fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add console test
2026-02-07 07:35:47 +08:00
Karl Seguin
aea2b3c8e5 remove empty_with_no_proto from console, since it isn't empty 2026-02-07 07:26:02 +08:00
Halil Durak
6d2ef9be5d change hash generation of global event handlers
Small change inspired from #1475.
2026-02-06 22:35:35 +03:00
Pierre Tachoire
57fb167a9c add console test 2026-02-06 19:19:36 +01:00
Pierre Tachoire
0406bba384 fix Element.replaceWith crash when self replacing
Fix a crash with the WPT test dom/nodes/ChildNode-replaceWith.html
When we call `div.replaceWith(div);` we don't want to touch self at all.
2026-02-06 18:25:04 +01:00
Karl Seguin
7c4c80fe4a Merge pull request #1478 from lightpanda-io/filter_jquery_unknown_object_property
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Filter out jquery* unknown object property
2026-02-06 10:54:53 +08:00
Karl Seguin
bfb267e164 Filter out jquery* unknown object property 2026-02-06 07:55:41 +08:00
Karl Seguin
a0720948a1 Merge pull request #1417 from lightpanda-io/observer_arenas
Leverage finalizers and ArenaPool in Intersction and Mutation Observer
2026-02-06 07:42:38 +08:00
Karl Seguin
9f00159a84 Merge pull request #1475 from lightpanda-io/event_lookup_include_type
Change the listener lookup from element, to (element,type).
2026-02-06 07:41:43 +08:00
Muki Kiboigo
34067a1d70 only use eqlIgnoreCase for RobotStore 2026-02-05 08:02:35 -08:00
Halil Durak
3f6917fdcb Merge pull request #1477 from lightpanda-io/nikneym/load-event-fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Create distinct `Event` objects for each `load` event
2026-02-05 17:01:30 +03:00
Halil Durak
c04a6e501e create distinct Event objects for each load event 2026-02-05 16:47:09 +03:00
Karl Seguin
661b564399 rename field element -> event_target 2026-02-05 20:26:43 +08:00
Halil Durak
761c103373 Merge pull request #1389 from lightpanda-io/nikneym/image-src-dispatch
`Image` & `Style`: Dispatch `load` event
2026-02-05 15:23:38 +03:00
Karl Seguin
f4bd9e3d24 Merge pull request #1476 from lightpanda-io/log_unknown_object_properties
Log unknown object properties in debug builds
2026-02-05 19:25:17 +08:00
Karl Seguin
b9ddac878c Log unknown object properties in debug builds
We currently log any unknown property access on the global (window). This has
proven pretty useful when debugging, often letting us know a type we're
completely missing.

However, it isn't just about what types we have, it's also about the properties
those types expose. Inspired by the wasted time spent on
https://github.com/lightpanda-io/browser/pull/1473, this commit adds the same
type of logging for unknown properties on objects. In debug mode only. This is
only done for objects which don't have their own NamedIndexer.
2026-02-05 17:35:15 +08:00
Karl Seguin
f304ce5ccf Change the listener lookup from element, to (element,type).
Right now, to do anything with listeners, we need to (a) lookup the listeners
registered with the element and (b) walk the linked list to find listeners of
the correct type.

There's no operations that behave on all of an element's listeners, e.g. there's
no element.removeAllListeners(). So, we can optimize the code, and generally
make it simpler to read, by changing our key to include the type (along with
the element).

This is an optimization on its own (and makes the code simpler), but it also
makes it more palatable to do a pre-listener check to avoid creating events when
no listener even exists; if we decide to implement that.
2026-02-05 16:45:29 +08:00
Karl Seguin
828401f057 Merge pull request #1474 from lightpanda-io/zig_fmt
zig fmt
2026-02-05 16:39:29 +08:00
Karl Seguin
445d77a220 Merge pull request #1473 from lightpanda-io/script_text
Expose Script.text property
2026-02-05 16:38:51 +08:00
Karl Seguin
4d768bb5eb zig fmt 2026-02-05 16:24:13 +08:00
Karl Seguin
4e3b87d338 remove extra newline 2026-02-05 16:22:50 +08:00
Karl Seguin
00740b6117 Expose Script.text property
This should fix a number of rendering issues, but was specifically added while
investigating issues rendering sites that used knockout.js
2026-02-05 16:21:03 +08:00
Karl Seguin
7775f203fc Merge pull request #1471 from lightpanda-io/arraylistunmanaged_to_arraylist
Rename all ArrayListUnmanaged -> ArrayList
2026-02-05 15:49:06 +08:00
Karl Seguin
945af879ec Merge pull request #1472 from lightpanda-io/wpt_memory_leak
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
free config on wpt test end
2026-02-05 14:40:34 +08:00
Karl Seguin
b2506f0afe free config on wpt test end 2026-02-05 14:30:46 +08:00
Karl Seguin
2eab4b84c9 Rename all ArrayListUnmanaged -> ArrayList
ArrayListAlignedUnmanaged has been deprecated for a while, and I occasionally
replace them, but doing one complete pass gets it done once and for all.
2026-02-05 11:49:15 +08:00
Karl Seguin
7746d9968d Merge pull request #1470 from lightpanda-io/fix_bsd_test_server_shutdown
use close, not shutdown on BSD/Mac
2026-02-05 11:25:37 +08:00
Karl Seguin
da49d918d6 use close, not shutdown on BSD/Mac 2026-02-05 10:58:10 +08:00
Karl Seguin
804ed758c9 Leverage finalizers and ArenaPool in Intersction and Mutation Observer
Both of these become self-contained with their own arena, which can be cleaned
up when (a) we don't need it anymore and (b) v8 doens't need it. The "we don't
need it anymore" is true when there's nothing being observed.

The underlying records also get their own arena and are handed off to v8 to
finalize whenever they fall out of scope.
2026-02-05 08:05:34 +08:00
Karl Seguin
17aac58e08 Merge pull request #1468 from lightpanda-io/context_safety
Fixes a few context issues.
2026-02-05 07:51:46 +08:00
Muki Kiboigo
a7095d7dec pass robot store into Http init 2026-02-04 12:23:42 -08:00
Halil Durak
3afbb6fcc2 load event dispatching for Style 2026-02-04 23:22:07 +03:00
Halil Durak
8ecbd8e71c add Page._to_load and implement load even dispatching for Image 2026-02-04 23:22:07 +03:00
Halil Durak
988f499723 EventManager: add hasListener
Not sure if this should be in `EventTarget` or `EventManager`, here goes nothing.

`Image`: dispatch `load` event when `src` set
add load event test

remove `hasListener`

let `Scheduler` dispatch `load` event

Simulates async nature.

update test

free `args` when done

implement `load` event dispatch for `<img>` tags

This dispatches `load` events tied to `EventManager` but not the `onload` for some reason...

`"load"` event must be dispatched even if `onload` not set

Resolves the bug that cause event listeners added through `EventTarget` not executing if `onload` not set.

add `onload` getter/setter for `Image`

prefer `attributeChange` to run side-effects

This should give more consistent results than using `setSrc`.
add inline `<img src="..." />` test

`Image`: prefer `inline_lookup` for `onload`

remove incorrect URL check + prefer 0ms in `Scheduler`

change after rebase
2026-02-04 23:22:06 +03:00
Muki Kiboigo
50aeb9ff21 add comment explaining rule choice in robots 2026-02-04 12:19:18 -08:00
Muki Kiboigo
e620c28a1c stop leaking robots_url when in robot queue 2026-02-04 12:19:18 -08:00
Muki Kiboigo
29ee7d41f5 queue requests to run after robots is fetched 2026-02-04 12:19:17 -08:00
Muki Kiboigo
f9104c71f6 log instead of returning error on unexpected rule 2026-02-04 12:16:36 -08:00
Muki Kiboigo
b6af5884b1 use RobotsRequestContext deinit 2026-02-04 12:16:36 -08:00
Muki Kiboigo
e4f250435d include robots url in debug log 2026-02-04 12:16:36 -08:00
Muki Kiboigo
1a246f2e38 robots in the actual http client 2026-02-04 12:15:49 -08:00
Muki Kiboigo
48ebc46c5f add getRobotsUrl to URL 2026-02-04 12:09:05 -08:00
Muki Kiboigo
e27803038c initial implementation of Robots 2026-02-04 12:09:05 -08:00
Pierre Tachoire
babf8ba3e7 Merge pull request #1469 from lightpanda-io/ci-v8-debug
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: use v8 debug mode
2026-02-04 15:20:23 +01:00
Nikolay Govorov
6ccd3f277b Fix race condition 2026-02-04 13:48:07 +00:00
Halil Durak
9d6f9aae9a Merge pull request #1440 from lightpanda-io/nikneym/lazy-eval-handlers
Lazily evaluate inline event listeners
2026-02-04 16:45:09 +03:00
Halil Durak
95a000c279 getOnSubmit can return errors 2026-02-04 16:15:02 +03:00
Halil Durak
b19debff14 move everything to global_event_handlers.zig 2026-02-04 16:11:02 +03:00
Halil Durak
39c9024747 !?js.Function.Global
Also more clear warning in `stringToPersistedFunction` failure.
2026-02-04 16:11:02 +03:00
Halil Durak
3c660f2cb0 remove unnecessary comptime 2026-02-04 16:11:02 +03:00
Halil Durak
13dbdc7dc7 import order 2026-02-04 16:11:02 +03:00
Halil Durak
f903e4b2de bunch of renaming 2026-02-04 16:11:01 +03:00
Halil Durak
b96cb2142b add getAttributeFunction 2026-02-04 16:11:01 +03:00
Halil Durak
cc51cd4476 remove eager event listener parsing 2026-02-04 16:11:01 +03:00
Halil Durak
8a995fc515 createLookupKey -> calcAttrListenerKey 2026-02-04 16:11:01 +03:00
Halil Durak
078eccea2d update doc comment 2026-02-04 16:11:00 +03:00
Karl Seguin
190119bcd4 Merge pull request #1461 from lightpanda-io/blocking_auth_intercept_fix
Fix [I hope] blocking auth interception
2026-02-04 19:40:06 +08:00
Pierre Tachoire
7672b42fbc ci: add missing -Dtsan=tru option 2026-02-04 12:13:46 +01:00
Pierre Tachoire
c590658f16 ci: use debug v8 with zig test 2026-02-04 12:08:12 +01:00
Karl Seguin
017d4e792b Fix [I hope] blocking auth interception
On a blocking request that requires authentication, we now handle the two cases
correctly:
1 - if the request is aborted, we don't continue processing (if we did, that
    would result in (a) transfer.deinit being called twice and (b) the callbacks
    being called twice

2 - if the request is "continue", we queue the transfer to be re-issued, as
    opposed to just processing it as-is. We have to queue it because we're
    currently inside a process loop and it [probaby] isn't safe to re-enter it.
    By using the queue, we wait until the next call to `tick` to re-issue the
    request.
2026-02-04 18:39:23 +08:00
Karl Seguin
0671be870d Fixes a few context issues.
First, for macrotasks, it ensures that the correct context is entered.

More important, for microtasks, it protects against use-after-free. This is
something we already did, to some degree, in page.deinit: we would run
microtasks before erasing the page, in case any microtasks referenced the page.

The new approach moves the guard to the context (since we have multiple
contexts) and protects against a microtasks enqueue a new task during shutdown
(something the original never did).
2026-02-04 18:34:33 +08:00
Pierre Tachoire
2f9ed37db2 ci: remove invalid install option 2026-02-04 11:18:38 +01:00
Karl Seguin
2cf2db3eef Merge pull request #1466 from lightpanda-io/form_onsubmit
Execute form.onsubmit when a form is being submitted
2026-02-04 18:13:15 +08:00
Karl Seguin
11ad025e5d Merge pull request #1467 from lightpanda-io/inspector_context_destroyed
Call Inpsector::ContextDestroyed
2026-02-04 18:12:58 +08:00
Karl Seguin
630cf05b2f Merge pull request #1463 from lightpanda-io/wp/mrdimidium/cleanup-configuration
Centralizes configuration, eliminates unnecessary copying of config
2026-02-04 17:36:39 +08:00
Nikolay Govorov
a72782f91e Eliminates duplication in the creation of HTTP headers 2026-02-04 09:08:57 +00:00
Karl Seguin
fbd554a15f Call Inpsector::ContextDestroyed
This seems to solve some potential use-after-free issues. By informing the
Inspector that the context is gone, it seems to effectively ensure that no more
messages are sent from the inspector for things related to the context.
2026-02-04 16:24:48 +08:00
Nikolay Govorov
f71aa1cad2 Centralizes configuration, eliminates unnecessary copying of config 2026-02-04 07:57:59 +00:00
Nikolay Govorov
6d9517f6ea Merge pull request #1465 from lightpanda-io/wp/mrdimidium/direct-telemetry
Telemetry without notifications
2026-02-04 07:51:46 +00:00
Nikolay Govorov
fd8c488dbd Move Notification from App to BrowserContext 2026-02-04 07:33:45 +00:00
Nikolay Govorov
dbf18b90a7 Removes telemetry dependence on notifications 2026-02-04 07:30:38 +00:00
Pierre Tachoire
d318fe24b8 Merge pull request #1460 from lightpanda-io/dupe-url-nav
fix slice alias crash after same document navigation
2026-02-04 08:25:32 +01:00
Karl Seguin
1352315441 Execute form.onsubmit when a form is being submitted 2026-02-04 13:45:53 +08:00
Karl Seguin
3c635532c4 Merge pull request #1426 from lightpanda-io/update_page_js
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Update page.js based on the current context.
2026-02-04 08:51:53 +08:00
Karl Seguin
f8703bf884 Merge pull request #1459 from lightpanda-io/browser_context_arenas
Add a dedicated browser_context and page_arena to CDP.
2026-02-04 07:51:35 +08:00
Karl Seguin
eea3aa7a27 Merge pull request #1457 from lightpanda-io/arena_pool_free_list_len
Properly maintain the ArenaPool's free_list_len
2026-02-04 07:50:33 +08:00
Nikolay Govorov
6eff448508 Merge pull request #1464 from lightpanda-io/wp/mrdimidium/update-zig-v8-fork
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Use asan/tsan for building v8
2026-02-03 16:18:23 +00:00
Nikolay Govorov
eb8cac5980 Use asan/tsan for building v8 2026-02-03 16:01:18 +00:00
Karl Seguin
1a4086c98c de-duplicate context shutdown in isolated worl deinit 2026-02-03 23:04:44 +08:00
Karl Seguin
5c91076660 Merge pull request #1458 from lightpanda-io/tweaks
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
A few small tweaks
2026-02-03 20:09:38 +08:00
Karl Seguin
5467b8dd0d A few small tweaks
1 - Embed Page into Session, avoids having to allocate/deallocate the page
2 - Fix false-positive arena pool leak detection
3 - On form submit, pre-check if navigation will allowed before building the
    contents
4 - Make sure QueuedNavigation structure isn't use after page is removed
2026-02-03 20:07:47 +08:00
Karl Seguin
d46a9d6286 Merge pull request #1462 from lightpanda-io/check_for_leaks_after_all_releases
Only check for arena leaks AFTER we've triggered all releases
2026-02-03 18:43:40 +08:00
Karl Seguin
2fa7810128 Only check for arena leaks AFTER we've triggered all releases
We already did this for the Context, but shutting down the HTTP client can also
release resources.
2026-02-03 18:31:04 +08:00
Pierre Tachoire
8249725ae7 fix slice alias crash after same document navigation
The WPT tests navigation-api/navigation-methods/navigate-history-push-same-url.html
crashed with a @memcpy arguments alias error.

It seems to be due to the reuse of the previous page.url string.
Forcing to duplicate it fixes the crash.
2026-02-03 11:00:45 +01:00
Karl Seguin
c07b83335b add a few comments 2026-02-03 15:58:29 +08:00
Karl Seguin
7e575c501a Add a dedicated browser_context and page_arena to CDP.
The BrowserContext currently uses 3 arenas:
1 - Command-specific, which is like the call_arena, but for the processing of a
    single CDP command
2 - Notification-specific, which is similar, but for the processing of a single
    internal notification event
3 - Arena, which is just the session arena and lives for the duration of the
    BrowseContext/Session

This is pretty coarse and can results in significant memory accumulation if a
browser context is re-used for multiple navigations.

This commit introduces 3 changes:
1 - Rather than referencing Session.arena, the BrowerContext.arena is now its
    own arena. This doesn't really change anything, but it does help keep things
    a bit better separated.

2 - Introduces a page_arena (not to be confused with Page.arena). This arena
    exists for the duration of a 1 page, i.e. it's cleared when the
    BrowserContext receives the page_created internal notification. The
    `captured_responses` now uses this arena, which means captures only exist
    for the duration of the current page. This appears to be consistent with
    how chrome behaves (In fact, Chrome seems even more aggressive and doesn't
    appear to make any guarantees around captured responses). CDP refers to this
    lifetime as a "renderer" and has an experimental message, which we don't
    support, `Network.configureDurableMessages` to control this.

3 - Isolated Worlds are now more self contained with an arena from the ArenaPool.

There are currently 2 places where the BrowserContext.arena is still used:
1 - the isolated_world list
2 - the custom headers

Although this could be long lived, I believe the above is ok. We should just
really think twice whenever we want to use it for anything else.
2026-02-03 15:48:27 +08:00
Karl Seguin
933e2fb0ef Properly maintain the ArenaPool's free_list_len 2026-02-03 13:08:53 +08:00
Karl Seguin
8d51383fb2 Merge pull request #1450 from lightpanda-io/pluginarray_placeholder
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add Plugin and PluginArray placeholders
2026-02-03 07:10:46 +08:00
Karl Seguin
80f4c83b83 Merge pull request #1453 from lightpanda-io/xhr_arraybuffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add 'arraybuffer' responseType to XHR
2026-02-02 21:55:10 +08:00
Karl Seguin
0d739e4af7 Merge pull request #1446 from lightpanda-io/module_promise
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
When loading a module, make sure we have a module_promise
2026-02-02 18:29:50 +08:00
Karl Seguin
58f9027002 Merge pull request #1454 from lightpanda-io/element_replaceWith
implement Element.replaceWith
2026-02-02 17:56:25 +08:00
Karl Seguin
990f2e2892 Merge pull request #1455 from lightpanda-io/element_scrollIntoView
add dummy scrollIntoView
2026-02-02 17:56:07 +08:00
Karl Seguin
ce7989c171 add dummy scrollIntoView 2026-02-02 16:41:39 +08:00
Karl Seguin
4efb0229d4 implement Element.replaceWith 2026-02-02 16:04:59 +08:00
Karl Seguin
5dd6dc2d69 per-context scheduler 2026-02-02 12:40:54 +08:00
Karl Seguin
20931eb9d6 update page.js on context.deinit 2026-02-02 12:22:00 +08:00
Karl Seguin
c11fa122af Update page.js based on the current context.
page.js currently always references the page context. But through the inspector
JavaScript can be executed in different contexts. When we go from V8->Zig we
correctly capture the current context within the caller's Local. And, because of
this, mapping or anything else that happens against local.ctx, happens in the
right context.

EXCEPT...our code still accesses page.js. So you can have a v8->zig call
happening in Context-2, and our Zig call then tries to do something on Context-1
via page.js.

I'm introducing a change that updates page.js based on the current Caller and
restores it at the end of the Caller. This change is super small, but
potentially has major impact. It's hard to imagine that we haven't run into
problems with this before, and it's hard to imagine what problems this change
might introduce. Certainly, if anyone copies page.js, they'll be in for a rude
surprise, but i don't think we do that anywhere.
2026-02-02 12:22:00 +08:00
Karl Seguin
e9141c8300 Handle more partial-load states + fix possible dangling pointer.
==Fix 1==
The problem flow:
1 - The module is dynamically imported, this creates a cache entry with no
module and no module_promise, and starts an async fetch.

2 - Before dynamicModuleSourceCallback fires (from step 1 above), the same
module is imported as a child of a call graph, i.e. via resolveModuleCallback.
Here the module is compiled, but never evaluated (we only evaluate the root
module). This is where things start to go sour. Our cache entry now has a
module, but no module_promise.

3 - The async fetch completes and calls dynamicModuleSourceCallback which call
Context.module. This returns early (the module is already cached thanks to
step 2). But it then calls resolveDynamicModule which (a) has a module and (b)
no module_promise.

Our fix works because, if Context.module finds the cached module (from step 2),
it now also checks for the module_promise. If it doesn't find it, it evaluates
the module (which sets it).

I've since expanded the code to handle more intermediary states.

The original PR had:

if (gop.value_ptr.module_promise == null) {
    const mod = local.toLocal(cache_mod);
    if (mod.getStatus() == .kInstantiated) {
        return self.evaluateModule(want_result, mod, url, true);
    }
}

But now the code is:

if (gop.value_ptr.module_promise == null) {
    const mod = local.toLocal(cache_mod);
    if (mod.getStatus() == .kUninstantiated and try mod.instantiate(resolveModuleCallback) == false) {
        return error.ModuleInstantiationError;
    }
    return self.evaluateModule(want_result, mod, url, true);
}

It seems that v8 handles double-instantiation and double-evaluations safely.

Handle more partial-load states.
Handle more partial-load states + fix possible dangling pointer.

==Fix 2==
We were using `gop` after potentially writing to the map (via a nested call to
mod.evaluate()). We now re-fetch the map entry to be able to safely write to it
2026-02-02 12:12:21 +08:00
Karl Seguin
1d03b688d9 When loading a module, make sure we have a module_promise
Currently, when loading a module, if the module is found in the cache, it's
immediately returned. However, that can result in a timing issue where the
module is cached, but not evaluated, and doesn't have an associated promise.

This commit tries to ensure a module is always evaluated and that the cache
entry has a module promise.

This might fix an crash handler issue. I couldn't reproduce the issue though.
I believe it requires specific timing which is hard to reproduce in a test.
2026-02-02 11:13:59 +08:00
Karl Seguin
176d42f625 add 'arraybuffer' responseType to XHR 2026-02-02 07:45:21 +08:00
Karl Seguin
7c98a27c53 Merge pull request #1452 from lightpanda-io/css_escape
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
improve correctness of CSS.escape
2026-01-31 19:16:09 +08:00
Karl Seguin
020b30783e Merge pull request #1451 from lightpanda-io/xhr_fanalizer
Don't release XHR object until request complete
2026-01-31 19:15:58 +08:00
Pierre Tachoire
fafbdb0714 Merge pull request #1449 from lightpanda-io/scriptmanager-referer 2026-01-31 10:07:18 +01:00
Karl Seguin
466cdb4ee7 improve correctness of CSS.escape 2026-01-31 10:55:11 +08:00
Karl Seguin
fa66f0b509 Don't release XHR object until request complete
We previously figured that we could release the XHR object as soon as the JS
reference was out of scope. But the callbacks could still exist and thus the
XHR request should proceed.

This commit ensures the XHR instance remains valid so long as we have an active
request.

Might help with https://github.com/lightpanda-io/browser/issues/1448 but I can't
reliably reproduce this, so I'm not 100% sure it resolve the issue. That bug
appears to be caused by some timing interaction between the underlying HTTP
request and the v8 GC.
2026-01-31 10:31:16 +08:00
Karl Seguin
12a566c07e Merge pull request #1445 from lightpanda-io/schedule_task_finalizer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Allow [schedule] tasks to have finalizers
2026-01-31 07:06:24 +08:00
Karl Seguin
bf7a1c6b1f Merge pull request #1444 from lightpanda-io/inspector_rework
Rework Inspector usage
2026-01-31 07:06:11 +08:00
Karl Seguin
55891aa5f8 Add Plugin and PluginArray placeholders 2026-01-31 07:05:28 +08:00
Karl Seguin
7c0acd9fcb Merge pull request #1447 from lightpanda-io/exception_errors
Handle catching exception error objects
2026-01-31 06:41:37 +08:00
Pierre Tachoire
333f1e2c47 use page's headerForRequest with fetch and XHR 2026-01-30 18:18:20 +01:00
Pierre Tachoire
9d30cdfefc add HTTP headers referer for script manager requests 2026-01-30 18:16:33 +01:00
Karl Seguin
324f6fe16e Handle catching exception error objects
https://github.com/lightpanda-io/browser/issues/1443
2026-01-30 18:38:22 +08:00
Karl Seguin
5d96304332 Allow [schedule] tasks to have finalizers
There's no guarantee that a task will ever be run. A page can be shutdown by
the user or timeout or an error. Scheduler cleanup relies on the underlying
page.arena. This forces all tasks to rely on the page.arena as they have no way
to clean themselves.

This commit allows tasks to register a finalizer which is guaranteed to be
called when the scheduler is shutdown.

The window ScheduleCallback, PostMessageCallback now use an arena from the
ArenaPool rather than the page.arena and use the task finalizer to ensure the
arena is released on shutdown.
2026-01-30 17:23:03 +08:00
Pierre Tachoire
e6e32b5fd2 Merge pull request #1442 from lightpanda-io/disable_debug_crash_report
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
2026-01-30 10:14:01 +01:00
Karl Seguin
181f265de5 Rework Inspector usage
V8's inspector world is made up of 4 components: Inspector, Client, Channel and
Session. Currently, we treat all 4 components as a single unit which is tied to
the lifetime of CDP BrowserContext - or, loosely speaking, 1 "Inspector Unit"
per page / v8::Context.

According to https://web.archive.org/web/20210622022956/https://hyperandroid.com/2020/02/12/v8-inspector-from-an-embedder-standpoint/
and conversation with Gemini, it's more typical to have 1 inspector per isolate.
The general breakdown is the Inspector is the top-level manager, the Client is
our implementation which control how the Inspector works (its function we expose
that v8 calls into). These should be tied to the Isolate. Channels and Sessions
are more closely tied to Context, where the Channel is v8->zig and the Session
us zig->v8.

This PR does a few things
1 - It creates 1 Inspector and Client per Isolate (Env.js)
2 - It creates 1 Session/Channel per BrowserContext
3 - It merges v8::Session and v8::Channel into Inspector.Session
4 - It moves the Inspector instance directly into the Env
5 - BrowserContext interacts with the Inspector.Session, not the Inspector

4 is arguably unnecessary with respect to the main goal of this commit, but
the end-goal is to tighten the integration. Specifically, rather than CDP having
to inform the inspector that a context was created/destroyed, the Env which
manages Contexts directly (https://github.com/lightpanda-io/browser/pull/1432)
and which now has direct access to the Inspector, is now equipped to keep this
in sync.
2026-01-30 15:59:33 +08:00
Karl Seguin
e5fc8bb27c Disable crash report in debug
Crashing when developing is more noise than signal
2026-01-30 07:06:09 +08:00
Karl Seguin
34dda780d9 Merge pull request #1441 from lightpanda-io/set_attribute_to_string_api
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix setAttribute for new toString API
2026-01-30 07:05:29 +08:00
Karl Seguin
c7cf4eeb7a fix setAttribute for new toString API 2026-01-30 07:00:33 +08:00
Karl Seguin
a6e5d9f6dc Merge pull request #1439 from lightpanda-io/setAttribute-non-string
accept js.Value for element.setAttribute
2026-01-30 06:58:28 +08:00
Karl Seguin
ea1017584e Merge pull request #1433 from lightpanda-io/js_string
Cleanup js -> string
2026-01-30 06:55:40 +08:00
Karl Seguin
6aef32d7a8 Merge pull request #1438 from lightpanda-io/update_public_suffix_list
Update the public suffix list
2026-01-30 06:55:26 +08:00
Karl Seguin
4a1d71b6b8 Merge pull request #1437 from lightpanda-io/remove_unused
Remove unused import
2026-01-30 06:55:11 +08:00
Karl Seguin
a18b61cb1d Merge pull request #1432 from lightpanda-io/remove_execution_world
Remove js.ExecutionWorld
2026-01-30 06:54:55 +08:00
Karl Seguin
e31e19aeba Merge pull request #1431 from lightpanda-io/crash_handler_discord
add discord link to crash handler
2026-01-30 06:54:34 +08:00
Pierre Tachoire
ef6d8a6554 accept js.Value for element.setAttributeNS 2026-01-29 17:17:08 +01:00
Pierre Tachoire
100764d79e accept js.Value for element.setAttribute 2026-01-29 17:10:43 +01:00
Karl Seguin
75abe7da1b Update the public suffix list 2026-01-29 21:00:06 +08:00
Karl Seguin
a19a125aec Remove unused import
And a few unused functions
2026-01-29 19:44:10 +08:00
Karl Seguin
f02fc95958 Merge pull request #1435 from lightpanda-io/handle_invalid_attribute_functions
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
skip invalid attribute functions
2026-01-29 16:50:40 +08:00
Karl Seguin
175edca8c7 Handle invalid attribute functions 2026-01-29 16:26:27 +08:00
Pierre Tachoire
f1f0a66f41 Merge pull request #1434 from lightpanda-io/update-source-deps-v2
update build from source deps
2026-01-29 09:13:08 +01:00
Pierre Tachoire
496c6905af update build from source deps 2026-01-29 08:42:58 +01:00
Karl Seguin
c84106570f Cleanup js -> string
Converting a JS value to a string is a bit messy right now. There's duplication
between string helpers in js.Local, and what js.String and js.Value provide.

Now, all stringifying functions are in js.String, with some helpers in js.Value.

Also tried to streamline the APIs around most common-cases (e.g. js.String ->
[]u8 using call_arena). js.String now also implements format, so it can be
used as-is in some cases.
2026-01-29 14:45:09 +08:00
Karl Seguin
1a05da9e55 Remove js.ExecutionWorld
The ExecutionWorld doesn't do anything meaningful. It doesn't map to, or
abstract any, v8 concepts. It creates a js.Context, destroys the context and
points to the context. Those all all things the Env can do (and it isn't like
the Env is over-burdened as-is).

Plus the benefit of going through the Env is that we can track/collect all
known Contexts for an isolate in 1 place (the Env), which can facilitate things
like context creation/deletion notifications.
2026-01-29 11:22:01 +08:00
Halil Durak
232e7a1759 Merge pull request #1430 from lightpanda-io/nikneym/attr-event-listeners
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Support HTML inline event listeners
2026-01-29 02:18:37 +03:00
Karl Seguin
c440d41d57 Merge pull request #1427 from lightpanda-io/arena_pool_double_free_detect
Add double-free detection to ArenaPool (in Debug Mode)
2026-01-29 07:04:33 +08:00
Karl Seguin
dfe5c24404 remove unused import and unused export 2026-01-29 07:04:20 +08:00
Karl Seguin
eba5773d56 Merge pull request #1428 from lightpanda-io/parser_arena_pool
Use ArenaPool when parsing HTML and for TextDecoder (with finalizer)
2026-01-29 06:49:14 +08:00
Karl Seguin
5d56fea2d3 check for leak after context is removed, as that can cause finalizers to run 2026-01-29 06:47:55 +08:00
Karl Seguin
946f02b7a2 Add double-free detection to ArenaPool (in Debug Mode)
Double-freeing should eventually cause a segfault (on ArenaPool.deinit, if not
sooner), but having an explicit check allows us to log the responsible owner.
2026-01-29 06:46:18 +08:00
Karl Seguin
8e8ffd21d5 add discord link to crash handler 2026-01-29 06:45:35 +08:00
Pierre Tachoire
d02d974cd0 Merge pull request #1429 from lightpanda-io/update-required-deps
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
update required deps for build from sources
2026-01-28 17:49:59 +01:00
Halil Durak
0a68be695d add tests 2026-01-28 17:46:27 +03:00
Pierre Tachoire
335e781d0c update required deps for build from sources 2026-01-28 15:37:58 +01:00
Halil Durak
9f5c2e4ca7 add getter/setter functions for attribute event listeners
Spec say these belong to `HTMLElement`.
2026-01-28 17:28:16 +03:00
Halil Durak
76a53bedbe split inline event listener logic to Page.zig and Element.zig 2026-01-28 17:26:56 +03:00
Karl Seguin
b0bc84ed21 Merge pull request #1422 from lightpanda-io/log-on-call-err
always log try/catch error on call function
2026-01-28 18:45:02 +08:00
Pierre Tachoire
ae298fc2e6 use caught formatter and init caught into _tryCallWithThis 2026-01-28 11:27:05 +01:00
Pierre Tachoire
3b809b2910 Merge pull request #1421 from lightpanda-io/fix-context-collected
use inspector.resetContextGroup during cdp deinit
2026-01-28 11:22:49 +01:00
Pierre Tachoire
68fbc0bde3 use inspector.resetContextGroup during cdp deinit
Ensure the inspector is correctly reset from context before deinit it.
It fixes the contextCollected crash in a better way.
2026-01-28 11:11:38 +01:00
Pierre Tachoire
9d8e5263a6 Merge pull request #1418 from lightpanda-io/mem-pressure
use less aggressive v8 GC
2026-01-28 11:09:45 +01:00
Pierre Tachoire
7eb026cc0d update zig-v8 deps 2026-01-28 10:38:04 +01:00
Karl Seguin
e51e6aa2b0 Use ArenaPool when parsing HTML and for TextDecoder (with finalizer)
Slowly more page.arena -> ArenaPool wherever possible. In some cases, an arena
from the arenapool will be preferred over the call_arena also.
2026-01-28 14:44:05 +08:00
Karl Seguin
bc700d2044 Merge pull request #1424 from lightpanda-io/parser_append_existing_parent
Add defensiveness around Parser.appendCallback
2026-01-28 09:21:14 +08:00
Karl Seguin
30ed58ff07 fix build 2026-01-28 08:06:22 +08:00
Karl Seguin
066069baad Add defensiveness around Parser.appendCallback
We're seeing an assertion in Page.appendNew fail because the node has a parent.
According to html5ever, this shouldn't be possible (appendNew is only called
from the Parser). BUT, it's possible we're mutating the node in a way that
we shouldn't...maybe there's JavaScript executing as we're parsing which is
mutating the node.

In release, this will be more defensive. In debug, this still crashes. It's
possible this is valid (like I said, maybe there's JS interleaved which is
mutating the node), but if so, I'd like to know the exact scenario that produces
this case.
2026-01-28 07:33:04 +08:00
Karl Seguin
068ec68917 Merge pull request #1420 from lightpanda-io/resolve_fix
Handle URL.resolve with path traversal as part of the filename
2026-01-28 06:46:35 +08:00
Halil Durak
560f028bda remove unused getListenerType 2026-01-28 01:33:17 +03:00
Halil Durak
fd1e77df8f parse event listeners provided as attributes 2026-01-28 01:31:43 +03:00
Karl Seguin
864ac08f16 optimize this more 2026-01-28 06:17:52 +08:00
Halil Durak
6ad1a11593 catch pointer overflows in createLookupKey
Its better to have this; if this is incorrect, its better to get notified.
2026-01-27 23:52:12 +03:00
Halil Durak
89174ba0b6 EventManager: introduce inline_lookup
Idea with this is to have a key-to-function for known event listeners. We pack pointer to event target with listener type to generate key and set function as value. By doing this, we save bytes for optionally and rarely set functions in elements.
2026-01-27 23:37:46 +03:00
Pierre Tachoire
fc5496e570 always log try/catch error on call function
We force log of detailled error caught during function call.
2026-01-27 18:41:12 +01:00
Karl Seguin
fd21d952ac Handle URL.resolve with path traversal as part of the filename 2026-01-27 21:45:16 +08:00
Karl Seguin
073fea2bde Merge pull request #1419 from lightpanda-io/arena_pool_leak_track_use_after_free
Reset _arena_pool_leak_track after the page.arena is reset
2026-01-27 18:04:46 +08:00
Karl Seguin
e548712f5e Reset _arena_pool_leak_track after the page.arena is reset 2026-01-27 17:55:32 +08:00
Pierre Tachoire
c3ba83ff93 use less aggressive v8 GC
Isolate.lowMemoryNotification runs an aggrissive GC.
Using Isolate.memoryPressureNotification allow a more granular control
of GC.
2026-01-27 09:39:08 +01:00
Karl Seguin
451dd0fd64 Merge pull request #1411 from lightpanda-io/more_SSO
more small strings (string.String)
2026-01-27 13:18:56 +08:00
Karl Seguin
aa805c2428 Merge pull request #1415 from lightpanda-io/dynamic_module_import
Improve dynamic module loading
2026-01-27 13:09:13 +08:00
Karl Seguin
58a7590aff Merge pull request #1416 from lightpanda-io/zigfmt
zig fmt
2026-01-27 13:08:54 +08:00
Karl Seguin
563ab30564 Merge pull request #1412 from lightpanda-io/response_arena
Add finalizer to Response and use an pooled arena
2026-01-27 12:55:52 +08:00
Karl Seguin
5050b34361 zig fmt 2026-01-27 12:55:25 +08:00
Karl Seguin
3bb86f196b Improve dynamic module loading
We're seeing cases where known dynamic modules are being requested when the
module isn't compiled yet. It's not clear how this is happening. I believe an
empty cache entry is being created in postCompileModule and then the request
for the dynamic module is happening before the sycnhronous module is loaded.
This seems like the only way to get into this state ,but I can't reproduce it.

Still, we now try to handle this case by simply having the dynamic module
request overwrite the placeholder cache-entry created in postCompileModule.
2026-01-27 12:51:37 +08:00
Karl Seguin
51dca3be11 Merge pull request #1414 from lightpanda-io/cheak_leak_before_free
Check for arena pool leak _before_ resetting page arena
2026-01-27 08:55:31 +08:00
Karl Seguin
adeda6cd75 Merge pull request #1413 from lightpanda-io/always_trycatch_function_call
Always use TryCatch when calling a function
2026-01-27 08:55:20 +08:00
Karl Seguin
09665c3a4a Check for arena pool leak _before_ resetting page arena
Cherry-picked from 31c0ac33d82271ee70b48b209b74a578e5e5c019
2026-01-27 06:43:23 +08:00
Karl Seguin
8f5f6212d2 Always use TryCatch when calling a function
And always ensure caught is initialized.
2026-01-26 20:06:47 +08:00
Karl Seguin
a11ae912b4 Add finalizer to Response and use an pooled arena
Unlike XHR, Response is a bit more complicated as it can exist in Zig code
without ever being given to v8. So we need to track this handoff to know who is
responsible for freeing it (zig code, on error/shutdown) or v8 code after
promise resolution.

This also cleansup a bad merge for the XHR finalizer and adds cleaning up the
`XMLHttpRequestEventTarget` callbacks.
2026-01-26 19:18:32 +08:00
Karl Seguin
3b12240615 remove newString helper in favor of .wrap 2026-01-26 08:00:04 +08:00
Karl Seguin
862520e4b1 micro-optimize String.eql(String) 2026-01-26 07:52:27 +08:00
Karl Seguin
a3d2dd8366 Convert most Attribute related calls from []const u8 -> String 2026-01-26 07:52:27 +08:00
Karl Seguin
16ef487871 Make "Safe" variants of Attribute work on String 2026-01-26 07:52:27 +08:00
Karl Seguin
54c45a0cfd Make js.Bridge aware of string.String for input parameters
Avoids having to allocate small strings when going from v8 -> Zig. Also
added a discriminatory type, string.Global which uses the arena, rather than
the call_arena, if an allocation _is_ necessary. (This is similar to a feature
we had before, but was lost in zigdom). Strings from v8 that need to be
persisted, can be allocated directly v8 -> arena, rather than v8 -> call_arena
-> arena.

I think there are a lot of places where we should use string.String - where
strings are expected to be short (e.g. attribute names). But started with just
document.querySelector and querySelectorAll.
2026-01-26 07:52:27 +08:00
Karl Seguin
1f14eb62d4 Merge pull request #1410 from lightpanda-io/insertAdjacentHtml
Fix insertAdjacentHtml
2026-01-26 07:45:11 +08:00
Karl Seguin
0db86a8b3d Merge pull request #1396 from lightpanda-io/eager_global_reset
Start to eagerly reset globals.
2026-01-26 07:45:00 +08:00
Karl Seguin
c63c85071a Start to eagerly reset globals.
Currently, when you create a Global (Value, Object, Function, ...) it exists
until the context is destroyed.

This PR adds the ability to eagerly free them when they fall out of scope, which
is only possible because of the new finalizer hooks.

Previously, we had js.Value, js.Value.Global; js.Function, js.Function.Global,
etc. This PR introduces a .Temp variant: js.Value.Temp and js.Function.Temp.
This is purely a discriminatory type and it behaves (and IS) a Global. The
difference is that it can be released:

  page.js.release(self._on_ready_state_change.?)

Why a new type? There's no guarantee that a global (the existing .Global or the
new .Temp) will get released before the context ends. For this reason, we always
track them in order to free the on context deninit:

```zig
    for (self.global_functions.items) |*global| {
        v8.v8__Global__Reset(global);
    }
```

If a .Temp is eagerly released, we need to remove it from this list. The simple
solution would be to switch `global_functions` from an ArrayList to a HashMap.
But that adds overhead for values that we know we'll never be able to eagerly
release. For this reason, .Temp are stored in a hashmap (and can be released)
and .Globla are stored in an ArrayList (and cannot be released). It's a micro-
optimization...eagerly releasing doesn't have to O(N) scan the list, and we only
pay the memory overhead of the hashmap for values that have a change to be
eagerly freed.

Eager-freeing is now applied to both the callbacn and the values for window
timers (setTimeout, setInterval, RAF). And to the XHR ready_state_change
callback. (we'll do more as we go).
2026-01-26 07:39:05 +08:00
Karl Seguin
b63d93e325 Add XHR finalizer and ArenaPool
Any object we return from Zig to V8 becomes a v8::Global that we track in our
`ctx.identity_map`. V8 will not free such objects. On the flip side, on its own,
our Zig code never knows if the underlying v8::Object of a global can still be
used from JS. Imagine an XHR request where we fire the last readyStateChange
event..we might think we no longer need that XHR instance, but nothing stops
the JavaScript code from holding a reference to it and calling a property on it,
e.g. `xhr.status`.

What we can do is tell v8 that we're done with the global and register a callback.
We make our reference to the global weak. When v8 determines that this object
cannot be reached from JavaScript, it _may_ call our registered callback. We can
then clean things up on our side and free the global (we actually _have_ to
free the global).

v8 makes no guarantee that our callback will ever be called, so we need to track
these finalizable objects and free them ourselves on context shutdown. Furthermore
there appears to be some possible timing issues, especially during context shutdown,
so we need to be defensive and make sure we don't double-free (we can use the
existing identity_map for this).

An type like XMLHttpRequest can be re-used. After a request succeeds or fails,
it can be re-opened and a new request sent. So we also need a way to revert a
"weak" reference back into a "strong" reference. These are simple v8 calls on
the v8::Global, but it highlights how sensitive all this is. We need to mark
it as weak when we're 100% sure we're done with it, and we need to switch it to
strong under any circumstances where we might need it again on our side.

Finally, none of this makes sense if there isn't something to free. Of course,
the finalizer lets us release the v8::Global, and we can free the memory for the
object itself (i.e. the `*XMLHttpRequest`). This PR also adds an ArenaPool. This
allows the XMLHTTPRequest to be self-contained and not need the `page.arena`.
On init, the `XMLHTTPRequest` acquires an arena from the pool. On finalization
it releases it back to the pool. So we now have:

- page.call_arena: short, guaranteed for 1 v8 -> zig -> v8 flow
- page.arena long: lives for the duration of the entire page
- page.arena_pool: ideally lives for as long as needed by its instance (but no
guarantees from v8 about this, or the script might leak a lot of global, so worst
case, same as page.arena)
2026-01-26 07:38:24 +08:00
Karl Seguin
12c6e50e16 Merge pull request #1383 from lightpanda-io/xhr_finalizer
Add XHR finalizer and ArenaPool
2026-01-26 07:34:23 +08:00
Karl Seguin
53ccc2e04c Merge pull request #1404 from lightpanda-io/crash_handler_stack_trace
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Capture the stack trace on the crash handler report
2026-01-24 18:29:18 +08:00
Karl Seguin
2d3234b54d Fix insertAdjacentHtml
Handle a wider range of HTML, including invalid html, empty string, head only
comment only, etc..
2026-01-24 10:04:19 +08:00
Karl Seguin
9a57c2a0d4 fix merge 2026-01-24 08:28:26 +08:00
Karl Seguin
fc64abee8f Add finalizer mode
When a type is finalized by V8, it's because it's fallen out of scope. When a
type is finalized by Zig, it's because the Context is being shutdown.

Those are two different environments and might require distinct cleanup logic.
Specifically, a zig-initiated finalization needs to consider that the page and
context are being shutdown. It isn't necessarily safe to execute JavaScript at
this point, and thus, not safe to execute a callback (on_error, on_abort,
ready_state_change, ...).
2026-01-24 07:59:43 +08:00
Karl Seguin
d5f26f6d15 remove temp variable 2026-01-24 07:59:43 +08:00
Karl Seguin
97f9c2991b on XHR shutdown, use terminate to prevent any client callbacks into the XHR 2026-01-24 07:59:43 +08:00
Karl Seguin
81378d4353 Simplify XHR lifetime
Keep this a weak reference (the default now).And rely on transfer abort to
ensure reference isn't needed after finalizer.
2026-01-24 07:59:43 +08:00
Karl Seguin
9f0c902030 more explicit arena pool debug parameter 2026-01-24 07:59:43 +08:00
Karl Seguin
3c0c75be10 Add XHR finalizer and ArenaPool
Any object we return from Zig to V8 becomes a v8::Global that we track in our
`ctx.identity_map`. V8 will not free such objects. On the flip side, on its own,
our Zig code never knows if the underlying v8::Object of a global can still be
used from JS. Imagine an XHR request where we fire the last readyStateChange
event..we might think we no longer need that XHR instance, but nothing stops
the JavaScript code from holding a reference to it and calling a property on it,
e.g. `xhr.status`.

What we can do is tell v8 that we're done with the global and register a callback.
We make our reference to the global weak. When v8 determines that this object
cannot be reached from JavaScript, it _may_ call our registered callback. We can
then clean things up on our side and free the global (we actually _have_ to
free the global).

v8 makes no guarantee that our callback will ever be called, so we need to track
these finalizable objects and free them ourselves on context shutdown. Furthermore
there appears to be some possible timing issues, especially during context shutdown,
so we need to be defensive and make sure we don't double-free (we can use the
existing identity_map for this).

An type like XMLHttpRequest can be re-used. After a request succeeds or fails,
it can be re-opened and a new request sent. So we also need a way to revert a
"weak" reference back into a "strong" reference. These are simple v8 calls on
the v8::Global, but it highlights how sensitive all this is. We need to mark
it as weak when we're 100% sure we're done with it, and we need to switch it to
strong under any circumstances where we might need it again on our side.

Finally, none of this makes sense if there isn't something to free. Of course,
the finalizer lets us release the v8::Global, and we can free the memory for the
object itself (i.e. the `*XMLHttpRequest`). This PR also adds an ArenaPool. This
allows the XMLHTTPRequest to be self-contained and not need the `page.arena`.
On init, the `XMLHTTPRequest` acquires an arena from the pool. On finalization
it releases it back to the pool. So we now have:

- page.call_arena: short, guaranteed for 1 v8 -> zig -> v8 flow
- page.arena long: lives for the duration of the entire page
- page.arena_pool: ideally lives for as long as needed by its instance (but no
guarantees from v8 about this, or the script might leak a lot of global, so worst
case, same as page.arena)
2026-01-24 07:59:41 +08:00
Karl Seguin
90d23abe18 fix null-byte 2026-01-24 07:58:36 +08:00
Karl Seguin
82eccf36d4 Merge pull request #1408 from lightpanda-io/fix-inspector-ctx-collected-crash
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix use after free during inspector contextCollected
2026-01-24 07:52:29 +08:00
Karl Seguin
342cb52887 Merge pull request #1409 from lightpanda-io/cleanup
zig fmt, remove unused code
2026-01-24 07:49:25 +08:00
Karl Seguin
cafa4f5173 correct crash report host 2026-01-24 07:46:14 +08:00
Karl Seguin
67cff5af8b zig fmt 2026-01-24 07:46:14 +08:00
Karl Seguin
6d23d91aa5 Capture the stack trace on the crash handler report 2026-01-24 07:46:13 +08:00
Karl Seguin
3a0699fc1d Merge pull request #1405 from lightpanda-io/prevent_fast_double_navigate
Handle fast double navigate
2026-01-24 07:39:39 +08:00
Karl Seguin
027e569087 Merge pull request #1398 from lightpanda-io/handle_non_200_scripts
Handle scripts that don't return a 200 status code
2026-01-24 07:39:19 +08:00
Karl Seguin
830f759f0b zig fmt, remove unused code 2026-01-24 07:37:30 +08:00
Pierre Tachoire
969891c71c fix use after free during inspector contextCollected
This commit fix the use after free crash into inspector contextCollected
run in the pumpMessageLoop.

Removing a context linked to an inspector triggers a contextCollected
task in the message queue.
But if the contextCollected task run after the GC it try to use free
memory. Forcing the message loop to run before the GC fix the issue.
2026-01-23 20:07:49 +01:00
Pierre Tachoire
4eb5c3e907 Merge pull request #1399 from alexisbouchez/window-onerror
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
invoke window.onerror callback in reportError
2026-01-23 12:36:32 +01:00
Karl Seguin
23303a759b Prevents fast double navigate
This puppeteer script [likely will] crash the brower:

page.goto(baseURL + '/campfire-commerce/');
await page.goto(baseURL + '/campfire-commerce/');

The quick double-navigation means parse_state remains .pre and thus the page
isn't reset. We introduce a new load_state, .waiting, which can be used to
detect this state.
2026-01-23 19:09:36 +08:00
Karl Seguin
d1e7f46994 Merge pull request #1402 from lightpanda-io/defensive_local_scopes
Some checks failed
e2e-test / zig build release (push) Waiting to run
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
Explicitly creates LocalScope in hard-to-reason callsites
2026-01-23 18:59:45 +08:00
Karl Seguin
65ea70ae90 Merge pull request #1403 from lightpanda-io/module_async_import_self
Support a module dynamically importing itself
2026-01-23 18:59:26 +08:00
Karl Seguin
7522b71c86 Support a module dynamically importing itself 2026-01-23 10:45:41 +08:00
Karl Seguin
70625c86c3 Explicitly creates LocalScope in hard-to-reason callsites
In some cases, it's straightforward to know whether or not there's an implicit
local scope available. But both Navigation and ReadableStream* have more complex
flows. Both could, in theory, be initiated from non-V8 calls, so relying on
the implicit scope isn't safe.

This adds an explicit scope to most callbacks in Navigation and ReadbleStream*.
However, I still don't quite understand how / if these are being initiated from
Zig currently (I could see how they would be, in the future). Therefore, in
debug mode, this will still panic if there's no implicit scope, because I want
to understand what's going on.
2026-01-23 09:58:36 +08:00
Alexis Bouchez
74354d2027 invoke window.onerror callback in reportError 2026-01-22 10:12:06 +01:00
Karl Seguin
f6397e2731 Handle scripts that don't return a 200 status code
This was already being handled for async scripts, but for sync scripts, we'd
log the error then proceed to try and execute the body (which would be some
error message).

This allows the header_callback to return a boolean to indicate whether or not
the http client should continue to process the request or abort it.
2026-01-22 14:15:00 +08:00
Karl Seguin
065ca39d60 Merge pull request #1397 from lightpanda-io/heapprofiler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Ability to capture a V8 heap profile and a heap snapshot
2026-01-22 12:17:47 +08:00
Karl Seguin
b4759ae261 Ability to capture a V8 heap profile and a heap snapshot 2026-01-22 10:27:58 +08:00
Karl Seguin
c095950ef9 Merge pull request #1395 from lightpanda-io/microtasks_on_local
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move runMicrotask from Context to Local
2026-01-22 08:37:56 +08:00
Karl Seguin
24b7035b1b Merge pull request #1394 from lightpanda-io/avoid_double_doctype
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
On dump, use the HTMLDocument's doctype if available
2026-01-21 18:21:43 +08:00
Karl Seguin
7b1f157cf8 Merge pull request #1392 from lightpanda-io/handle_scope_for_message_loop
Create HandleScope for PumpMessageLoop
2026-01-21 15:44:14 +08:00
Karl Seguin
8b8bee4e9c Move runMicrotask from Context to Local
This ensures that there's always a HandleScope avaialble when running microtasks
2026-01-21 15:40:32 +08:00
Pierre Tachoire
c27ab35600 Merge pull request #1393 from lightpanda-io/remove_js_obj_cache
Disable JS object cache
2026-01-21 08:14:24 +01:00
Karl Seguin
446b4dc461 On dump, use the HTMLDocument's doctype if available
We currently force-write a simple HTML doctype for HTMLDocument. But if the
document already has a doctype, that results in us writing the forced one then
writing the correct one. This adds a check and only force-writes a doctype if
the first child of the document isn't a document_type node.
2026-01-21 14:15:11 +08:00
Karl Seguin
ff8ed24622 Merge pull request #1391 from lightpanda-io/lowmem-on-page-reset
call env.lowMemoryNotification() during page reset
2026-01-21 13:23:24 +08:00
Karl Seguin
ae2d6a122b Disable JS object cache
Was added here 43805ad698

But causes segfaults. The issue is hard to understand. At first, it seemed like
the value cached in a v8::Object was persisting through v8::contexts of the
same isolate. Set window.document to the current &document, and in a different
context, it retrieves that cached value (which is now an invalid pointers).

However, upon further investigation, this appears to be limited to a mix of
navigation (which causes a new context to be created, and old values to be
invalidated) + Inspector which continues to send commands to the old context.

Since contextDestroyed is something we're aware of and planning to do shortly,
I think we can disable the cache until that's fixed.
2026-01-21 11:26:59 +08:00
Karl Seguin
3cac375f21 Merge pull request #1386 from lightpanda-io/tweak_global_setup
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move global setup to the Env (Isolate)
2026-01-21 07:11:47 +08:00
Karl Seguin
7d806dd161 Merge pull request #1369 from lightpanda-io/selection-webapi
`Selection` WebAPI
2026-01-21 07:11:11 +08:00
Karl Seguin
db037c704e Merge pull request #1388 from lightpanda-io/pointer_event
add PointerEvent
2026-01-21 07:10:32 +08:00
Karl Seguin
954184f742 Create HandleScope for PumpMessageLoop 2026-01-21 07:05:59 +08:00
Muki Kiboigo
7650e0b61a fix selection start updating to new len 2026-01-20 11:25:04 -08:00
Muki Kiboigo
4a5c93988f fix selection test expectation 2026-01-20 11:24:50 -08:00
Pierre Tachoire
8ceaf0ac66 call env.lowMemoryNotification() during page reset
calling env.lowMemoryNotification() on page reset encourages v8 to free
memory and keep low usage.
2026-01-20 18:27:25 +01:00
Pierre Tachoire
ca60aa1cc6 Merge pull request #1387 from lightpanda-io/lower_perf_regression
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
Reduce perf regression max
2026-01-20 13:36:16 +01:00
Karl Seguin
596d5906a0 add PointerEvent 2026-01-20 18:38:03 +08:00
Karl Seguin
c02db94522 Reduce perf regression max
Mem 28MB -> 26MB  (currently a 24.1MB)
Time 23 -> 17  (currently at 14)

We've made some memory and performance optimization gains lately. Lowering
these will let us spot incremental changes better.
2026-01-20 18:07:28 +08:00
Karl Seguin
3970803575 Merge pull request #1382 from lightpanda-io/cached_properties
Re-enable cached property support
2026-01-20 18:00:19 +08:00
Karl Seguin
43805ad698 Re-enable cached property support
The idea is that frequently accessed properties, e.g. window.document, can be
cached directly as data properties on the underlying v8::Object, removing the
need for the access call into Zig. This is only used on a handful of properties,
almost all of which are on the Window. It is important that the property be
read-only. For example, window.location cannot be cached this way because
window.location is writable (e.g. window.location.hash = '#blah').

This existed briefly before Zigdom, but was removed as part of the migration.
The implementation has changed. This previously relied on a "postAttach" feature
which no longer exists. It is not integrated in the bridge/callback directly and
lazily applied after the first access.
2026-01-20 17:34:52 +08:00
Karl Seguin
2498e12f19 Move global setup to the Env (Isolate)
Previously, we were doing some global setup in the Snapshot, but this was
always being overwritten when creating a context. This meaningless setup in
the snapshot was removed.

The global setup is now done once per isolate, rather than once per context.
2026-01-20 17:21:45 +08:00
Karl Seguin
6f3cb4b48e Merge pull request #1385 from lightpanda-io/remove_debug_print
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove a debug print
2026-01-20 16:36:26 +08:00
Karl Seguin
fbd047599e Merge pull request #1374 from lightpanda-io/fix_context_lifetime
Fix context lifetime
2026-01-20 16:24:12 +08:00
Karl Seguin
da00117622 Remove a debug print 2026-01-20 16:23:22 +08:00
Karl Seguin
e44c73bdf6 Merge pull request #1384 from lightpanda-io/htmlscript-src-absolute
`HTMLScriptElement` should return an absolute URL in `src`
2026-01-20 12:38:42 +08:00
Karl Seguin
e3cb7bd9f0 add test 2026-01-20 11:14:20 +08:00
Muki Kiboigo
08f5889ee5 getSrc should return an absolute URL 2026-01-19 18:50:24 -08:00
Muki Kiboigo
d5bfe74e1a add selection api to HTMLTextAreaElement 2026-01-19 18:37:52 -08:00
Muki Kiboigo
d7015fa3b6 add selection api to HTMLInputElement 2026-01-19 18:34:02 -08:00
Karl Seguin
9092651b5b Merge branch 'main' into fix_context_lifetime 2026-01-20 08:50:41 +08:00
Karl Seguin
2c53b48e0a add missing handlescope 2026-01-20 08:11:38 +08:00
Muki Kiboigo
319a1c3367 update WPT to include Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
80dd590e8f add toString to Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
992a8e8774 handle null anchor or focus nodes in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
f56d3bd193 do not modify old range in collapseToX 2026-01-19 07:12:40 -08:00
Muki Kiboigo
4ecc59d0c0 Fix a lot of Selection Issues
This uses the Chrome/Safari approach of only
having a single Range exist in the Selection.
This also better follows the W3C spec of Selection
2026-01-19 07:12:40 -08:00
Muki Kiboigo
5ebf82874b fix selection test inconsistency 2026-01-19 07:12:40 -08:00
Muki Kiboigo
12670a3153 fix extend direction in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
fa3a23134e properly return NotFoundError on removeRange 2026-01-19 07:12:39 -08:00
Muki Kiboigo
8291044abc fix collapseToStart on Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
505e0799da add remaining functions to Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
be1d463775 add Selection WebAPI test 2026-01-19 07:12:39 -08:00
Muki Kiboigo
a6fc5aa345 add getSelection to Window, Document 2026-01-19 07:12:37 -08:00
Muki Kiboigo
0e6e4db08b add Selection WebAPI 2026-01-19 07:11:45 -08:00
Karl Seguin
a84708e99d Merge pull request #1359 from lightpanda-io/crash_handler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Improve crash handling
2026-01-19 16:50:08 +08:00
Halil Durak
6b6c0e930e Merge pull request #1376 from lightpanda-io/nikneym/attribute-ns
Add simplified `setAttributeNS` and `getAttributeNS`
2026-01-19 11:08:49 +03:00
Halil Durak
926892be01 add not_implemented warnings 2026-01-19 10:57:48 +03:00
Karl Seguin
2894bef9ef Update src/crash_handler.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-01-19 15:06:43 +08:00
Karl Seguin
a6e7ecd9e5 Move more asserts to custom asserter.
Deciding what should be an lp.assert, vs an std.debug.assert, vs a debug-only
assert is a little arbitrary.

debug-only asserts, guarded with an `if (comptime IS_DEBUG)` obviously avoid the
check in release and thus have a performance advantage. We also use them at
library boundaries. If libcurl says it will always emit a header line with a
trailing \r\n, is that really a check we need to do in production? I don't think
so. First, that code path is checked _a lot_ in debug. Second, it feels a bit
like we're testing libcurl (in production!)..why? A debug-only assertion should
be good enough to catch any changes in libcurl.
2026-01-19 09:12:16 +08:00
Karl Seguin
9b000a002e Hook v8 crashes into new crash handler 2026-01-19 07:37:10 +08:00
Karl Seguin
0f9c9e2089 Improve crash handling
This adds a crash handler which reports a crash (if telemetry is enabled). On a
crash, this looks for `curl` (using the PATH env), and forks the process to then
call execve. This relies on a new endpoint to be setup to accept the "report".
Also, we include very little data..I figured just knowing about crashes would
be a good place to start.

A panic handler is provided, which override's Zig default handler and hooks
into the crash handler.

An `assert` function is added and hooks into the crash handler. This is
currently only used in one place (Session.zig) to demonstrate its use. In
addition to reporting a failed assert, the assert aborts execution in
ReleaseFast (as opposed to an undefined behavior with std.debug.assert).

I want to hook this into the v8 global error handler, but only after direct_v8
is merged.

Much of this is inspired by bun's code. They have their own assert (1) and
a [more sophisticated] crashHandler (2).
:

(1) beccd01647/src/bun.zig (L2987)
(2) beccd01647/src/crash_handler.zig (L198)
2026-01-19 07:36:46 +08:00
Karl Seguin
0edc1fcec7 fix rebase + migrate SubtleCrypto to new local 2026-01-19 07:36:14 +08:00
Karl Seguin
b46d3b22e2 Remove unnecessary handlescope
There's one _always_ created immediately before it.
2026-01-19 07:28:57 +08:00
Karl Seguin
412c881cd4 fix wpt and legacy_test runners 2026-01-19 07:28:56 +08:00
Karl Seguin
48f07a110f fix bad great rebase 2026-01-19 07:28:35 +08:00
Karl Seguin
5c1b7935e2 remove global handlescope 2026-01-19 07:28:35 +08:00
Karl Seguin
62aa564df1 Remove Global v8::Local<V8::Context>
When we create a js.Context, we create the underlying v8.Context and store it
for the duration of the page lifetime. This works because we have a global
HandleScope - the v8.Context (which is really a v8::Local<v8::Context>) is that
to the global HandleScope, effectively making it a global.

If we want to remove our global HandleScope, then we can no longer pin the
v8.Context in our js.Context. Our js.Context now only holds a v8.Global of the
v8.Context (v8::Global<v8::Context).

This PR introduces a new type, js.Local, which takes over a lot of the
functionality previously found in either js.Caller or js.Context. The simplest
way to think about it is:

1 - For v8 -> zig calls, we create a js.Caller (as always)
2 - For zig -> v8 calls, we go through the js.Context (as always)
3 - The shared functionality, which works on a v8.Context, now belongs to js.Local

For #1 (v8 -> zig), creating a js.Local for a js.Caller is really simple and
centralized. v8 largely gives us everything we need from the
FunctionCallbackInfo or PropertyCallbackInfo.  For #2, it's messier, because we
can only create a local v8::Context if we have a HandleScope, which we may or
may not.

Unfortunately, in many cases, what to do becomes the responsibility of the caller
and much of the code has to become aware of this local-ness. What does it means
for our code? The impact is on WebAPIs that store .Global. Because the global
can't do anything. You always need to convert that .Global to a local
(e.g. js.Function.Global -> js.Function).

If you're 100% sure the WebAPI is only being invoked by a v8 callback, you can
use `page.js.local.?.toLocal(some_global).call(...)` to get the local value.

If you're 100% sure the WebAPI is only being invoked by Zig, you need to create
 `js.Local.Scope` to get access to a local:

```zig
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(some_global).call(...)
// can also access `&ls.local` for APIs that require a *const js.Local
```
For functions that can be invoked by either V8 or Zig, you should generally push
the responsibility to the caller by accepting a `local: *const js.Local`. If the
caller is a v8 callback, it can pass `page.js.local.?`. If the caller is a Zig
callback, it can create a `Local.Scope`.

As an alternative, it is possible to simply pass the *Page, and check
`if page.js.local == null` and, if so, create a Local.Scope. But this should only
be done for performance reasons. We currently only do this in 1 place, and it's
because the Zig caller doesn't know whether a Local will actually be needed and
it's potentially called on every element creating from the parser.
2026-01-19 07:28:33 +08:00
Karl Seguin
798ee4a4d5 Make js.Object and js.Value have explicit global
See: bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for details on this change.
2026-01-19 07:27:03 +08:00
Karl Seguin
7d87fb80ec Make Global Function explicit.
This is the first in a series of changes to make globals explicit. The ultimate
goal of having explicit Globals is to move away from the global HandleScope and
to explicit HandleScopes.

Currently, we treat globals and locals interchangeably. In fact, for Global ->
Local, we just ptrCast. This works because we have 1 global HandleScope, which
effectively disables V8's GC and thus nothing ever gets moved.

If we're going to introduce explicit HandleScopes, then we need to first have
correct Globals. Specifically, when we want to act on the global, we need to
get the local value, and that will eventually mean making sure there's a
HandleScope.

While adding explicit globals, we're keeping the global HandleScope so that we
can minimize the change. So, given that we still have the global HandleScope
the change is largely two things:
1 - js.Function.persit() returns a js.Function.Global. Types that persist global
   functions must be updated to js.Function.Global.
2 - To turn js.Function.Global -> js.Function, we need to call .local() on it.

The bridge has been updated to support js.Function.Global for both input and
output parameters. Thus, window.setOnLoad can now directly take a
js.Function.Global, and window.getOnLoad can directly return that
js.Function.Global.
2026-01-19 07:26:33 +08:00
Karl Seguin
393227a786 Merge pull request #1373 from lightpanda-io/explicit_globals
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Explicit globals
2026-01-19 07:26:04 +08:00
Karl Seguin
c5870353e3 update v8 dep 2026-01-19 07:17:45 +08:00
Karl Seguin
7c9941c629 Make Promise, PromiseResolver and Module have explicit globals.
See bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for an explanation.
2026-01-19 07:15:48 +08:00
Karl Seguin
c7dbb6792d Make js.Object and js.Value have explicit global
See: bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for details on this change.
2026-01-19 07:15:48 +08:00
Karl Seguin
728b2b7089 update v8 dep 2026-01-19 07:15:48 +08:00
Karl Seguin
5def997bed Make Global Function explicit.
This is the first in a series of changes to make globals explicit. The ultimate
goal of having explicit Globals is to move away from the global HandleScope and
to explicit HandleScopes.

Currently, we treat globals and locals interchangeably. In fact, for Global ->
Local, we just ptrCast. This works because we have 1 global HandleScope, which
effectively disables V8's GC and thus nothing ever gets moved.

If we're going to introduce explicit HandleScopes, then we need to first have
correct Globals. Specifically, when we want to act on the global, we need to
get the local value, and that will eventually mean making sure there's a
HandleScope.

While adding explicit globals, we're keeping the global HandleScope so that we
can minimize the change. So, given that we still have the global HandleScope
the change is largely two things:
1 - js.Function.persit() returns a js.Function.Global. Types that persist global
   functions must be updated to js.Function.Global.
2 - To turn js.Function.Global -> js.Function, we need to call .local() on it.

The bridge has been updated to support js.Function.Global for both input and
output parameters. Thus, window.setOnLoad can now directly take a
js.Function.Global, and window.getOnLoad can directly return that
js.Function.Global.
2026-01-19 07:15:48 +08:00
Karl Seguin
a30c65966b Merge pull request #1380 from lightpanda-io/static_accessor_fix
Fix static accessors
2026-01-19 07:15:09 +08:00
Karl Seguin
cd67ed8a27 Fix static accessors
These are called without a self from v8, and should match that in Zig code.
2026-01-19 07:08:58 +08:00
Pierre Tachoire
5400dc783e Merge pull request #1379 from lightpanda-io/textarea_setDefaultValue
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add TextArea.defaultValue  setter
2026-01-18 17:12:49 +01:00
Pierre Tachoire
2880e9867d Merge pull request #1378 from lightpanda-io/performance_observer_use_after_free
Fix potential use-after-free with PerformanceObserver.
2026-01-18 17:03:36 +01:00
Karl Seguin
58f9469a6f Add TextArea.defaultValue setter 2026-01-18 07:49:58 +08:00
Karl Seguin
30d052db99 Fix potential use-after-free with PerformanceObserver.
TL;DR - use page.arena instead of page.call_arena

This probably comes from copying the implementation of MutationObserver and/or
IntersectionObserver. But those dispatches are different in that they directly
dispatch a slice (e.g. of MutationRecords) which gets mapped to a v8::Array when
doing the callback. The MutationRecords exist on the heap, not in
_pending_records, so the call_arena is fine.

PerformanceObserver returns an Zig object, not a slice. Therefore it gets mapped
to a v8::Object which references the Zig object. The state of that object, the
_entries list, has to exist for the lifetime of that object, not the call_arena.
2026-01-17 15:57:43 +08:00
Karl Seguin
744311f107 Merge pull request #1375 from lightpanda-io/nikneym/audio-constructor
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add `Audio` constructor
2026-01-16 23:25:56 +00:00
Karl Seguin
656674a477 Merge pull request #1356 from lightpanda-io/nikneym/subtle-crypto
Initial support for `SubtleCrypto` API
2026-01-16 23:21:35 +00:00
Karl Seguin
0e4aa38aaa Merge pull request #1312 from lightpanda-io/stagehand-zigdom
Stagehand zigdom
2026-01-16 23:10:19 +00:00
Pierre Tachoire
fdc267fa1f Merge pull request #1308 from lightpanda-io/axtree-backport
cdp: add AXTree
2026-01-16 17:56:40 +01:00
Pierre Tachoire
4325b80d64 axnode: small fixes 2026-01-16 17:30:43 +01:00
Pierre Tachoire
fbe07836f9 cdp: return a valide response for Page.getFrameTree on STARTUP
Stagehand expects a valid response for this specific command.
Add also `Target.activateTarget`
2026-01-16 16:27:55 +01:00
Halil Durak
304681bd21 add simplified setAttributeNS and getAttributeNS
This ignores namespaces for now, we have to come up with a solution if it becomes a necessity.
2026-01-16 18:13:43 +03:00
Halil Durak
05a01bb7c4 add Audio constructor 2026-01-16 17:38:54 +03:00
Pierre Tachoire
cbc028b040 cdp: accept multiple attachToTarget calls 2026-01-16 09:10:41 +01:00
Pierre Tachoire
2074c0149f axnode: add aria-labelledby support 2026-01-16 09:01:39 +01:00
Pierre Tachoire
61ed97dd45 axnode: use writeString for content's name 2026-01-16 09:00:57 +01:00
Pierre Tachoire
a358c46b9f axnode: ignore script and style children 2026-01-16 08:28:16 +01:00
Pierre Tachoire
50c1e2472b axnode: encode json string into stripWhitespaces 2026-01-16 08:27:43 +01:00
Halil Durak
ea2fc76d3c don't @panic! 2026-01-15 20:40:53 +03:00
Halil Durak
58634b54ec add tests for implemented bits of SubtleCrypto 2026-01-15 19:10:02 +03:00
Halil Durak
4b4bc1a4d3 don't allocate new SubtleCrypto for each access 2026-01-15 19:10:01 +03:00
Halil Durak
0549e07a90 implement deriveBits for X25519 2026-01-15 19:10:01 +03:00
Halil Durak
42666b1d30 add bindings needed for X25519 deriveBits implementation 2026-01-15 19:10:01 +03:00
Halil Durak
0a8be77233 create public/private key objects out of raw keys
This is needed for `deriveKey()` and `deriveBits()`.
2026-01-15 19:10:01 +03:00
Halil Durak
b26fb0e6c7 add more libcrypto bindings 2026-01-15 19:10:00 +03:00
Halil Durak
1699a92822 support x25519 init
Created a mess in previous commit.
2026-01-15 19:10:00 +03:00
Halil Durak
7ae3e8cb47 code cleanup, support keypairs, init support for X25519 2026-01-15 19:10:00 +03:00
Halil Durak
fd26ae4b5b parse keyUsages properly 2026-01-15 19:10:00 +03:00
Halil Durak
9945a5f9cc implement sign and verify for HMAC 2026-01-15 19:09:59 +03:00
Halil Durak
d5e9ae23ef ground zero SubtleCrypto 2026-01-15 19:09:59 +03:00
Pierre Tachoire
d50e056114 axnode: ignore non-html tags 2026-01-15 16:42:40 +01:00
Pierre Tachoire
d7d956d966 axnode: fix invalid enum 2026-01-15 15:40:52 +01:00
Pierre Tachoire
bd3966bf8d axnode: add focus on webroot 2026-01-15 15:37:49 +01:00
Pierre Tachoire
74578ba274 axnode: implement list marker 2026-01-15 15:37:49 +01:00
Pierre Tachoire
cb89742d2f axnode: add li level 2026-01-15 15:37:48 +01:00
Pierre Tachoire
6d0f991c17 axnode: add hr properties 2026-01-15 15:37:48 +01:00
Pierre Tachoire
d126d2a0f9 axnode: ignore hidden input 2026-01-15 15:37:47 +01:00
Pierre Tachoire
b51cca5617 axnode: use select.getValue 2026-01-15 15:37:47 +01:00
Pierre Tachoire
dc54dad290 axnode: add more attributes for input elements 2026-01-15 15:37:47 +01:00
Pierre Tachoire
7d6ab5a708 axnode: force manual formatting in switches
In order to uses less space and improve the readability.

zig fmt allows only 1 switch case per line or all in one line.
When having a lot of conditions, splitting the line is useful.
2026-01-15 15:37:46 +01:00
Pierre Tachoire
07acb9308d axnode: fallback button name to their tagname 2026-01-15 15:37:46 +01:00
Pierre Tachoire
ef315a46bc axnode: don't extract all text content as name
ignore name extraction for more elements
2026-01-15 15:37:45 +01:00
Pierre Tachoire
eb45bd051c axtree: simpler AXValue 2026-01-15 15:37:45 +01:00
Pierre Tachoire
65102edc98 axtree: remove useless error return 2026-01-15 15:37:44 +01:00
Pierre Tachoire
04eda96416 axtree: reverse writeNode return logic 2026-01-15 15:37:44 +01:00
Pierre Tachoire
f5036bdf5e axtree: use a simpler union switch 2026-01-15 15:37:44 +01:00
Pierre Tachoire
b6df85da7a axtree: add improvements 2026-01-15 15:37:43 +01:00
Pierre Tachoire
9775b39a8d axnode: use absolute urls 2026-01-15 15:37:43 +01:00
Pierre Tachoire
d6d74c5024 first version of AXTree 2026-01-15 15:37:42 +01:00
Pierre Tachoire
e09d15b12a add more generic HTML types 2026-01-15 15:37:35 +01:00
Karl Seguin
6d33d23935 Merge pull request #1371 from lightpanda-io/reject_non_new_constructor
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Reject constructor calls without new
2026-01-15 12:06:55 +00:00
Karl Seguin
47760e00f7 Reject constructor calls without new
This was previously a fixed bug, but it got lost in the direct_v8 merging.

https://github.com/lightpanda-io/browser/pull/1316
2026-01-15 19:25:43 +08:00
Karl Seguin
72e8421099 Merge pull request #1366 from lightpanda-io/details_are_values
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
use js.Value when input can be a value
2026-01-14 23:25:17 +00:00
Karl Seguin
844b0ed457 Merge pull request #1368 from lightpanda-io/dupe_remove_id
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Make removeIds lookup own the key
2026-01-14 11:52:06 +00:00
Karl Seguin
7e37db796f Make removeIds lookup own the key
Virtually all string are page-owned (in the page.arena), but because of the
Small String Optimization we use (string.zig), a string could be stack-allocated

The correct solution is probably to change the key to be a string.String. But
I want to give more thought to memory in general, and strings specifically need
to be thought about. So this is a quick fix for crashing.
2026-01-14 18:35:26 +08:00
Karl Seguin
3e5b506675 Merge pull request #1367 from lightpanda-io/readable_stream_cancel_persist
persist the readable stream's cancel callback
2026-01-14 10:20:14 +00:00
Karl Seguin
d356dbfc06 Merge pull request #1365 from lightpanda-io/try_catch_caught
Try catch caught
2026-01-14 09:59:19 +00:00
Karl Seguin
f5aee1f4c0 persist the readable stream's cancel callback 2026-01-14 17:58:41 +08:00
Karl Seguin
de4926d87d fix legacy runner, manual merge 2026-01-14 17:49:27 +08:00
Karl Seguin
56a39e2cc7 Apply tryCatch change to wpt runner 2026-01-14 17:34:08 +08:00
Karl Seguin
8e14dacc32 Improve ergonomics of try catch (and Function's tryCall)
It now returns a Caught struct which contains all information. The Caught struct
can be logged directly, providing more consistent logs for caught errors.
2026-01-14 17:34:02 +08:00
Karl Seguin
05102c673a use js.Value when input can be a value
We previously treated v8::Object and v8::Values interchangeably, and would just
ptrCast one to the other. So, if an API was defined with a js.Object but was
given a non-object value, e.g. 9001, it would still work.

This has since been tightened. If an API takes a js.Object, than the v8 value
must be an object. Passing a non-object will result in a InvalidArgument error.

CustomEvent.detail and PerformanceMark.detail can both be any value, so the
apis/fields have been updated from js.Object -> js.Value.
2026-01-14 15:38:34 +08:00
Karl Seguin
db2ecfe159 Merge pull request #1307 from lightpanda-io/direct_v8
Direct v8
2026-01-14 07:27:42 +00:00
Karl Seguin
640cb0d489 Merge pull request #1364 from lightpanda-io/observer_try_catch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
add trycatch to Intersection and Performance Observers
2026-01-14 02:10:56 +00:00
Karl Seguin
223a6170d5 Fix use-after free
On CDP.BrowserContext.deinit, clear the isolated world ExecutionContext before
terminating the session. This is important as the isolated_world list is
allocated from the session.arena.

Also, semi-revert 63f1c85964. Before all this
we were running microtasks on ExecutionWorld.removeContext. That didn't seem
right (and I thought it was the original source of the bug). But, for the "real"
Page context, this is critical, since Microtasks can reference the Page object.
Since microTasks are isolation-level, it's possible for a microtasks for Page1
to execute after Page1 goes away (if we create a new page, Page2). This re-adds
the microtask "draining", but only for the Page (i.e. in Page.deinit).
2026-01-14 09:37:10 +08:00
Karl Seguin
63f1c85964 Remove unnecessary microtask run.
This crashes linux in releasesafe without an embedded snapshot. Not sure why,
but it shouldn't be necessary. This was added back when we were executing
microtasks on a schedule, rather than manually at explicit points.
2026-01-13 18:09:58 +08:00
Karl Seguin
c252c8e870 update v8 dep version 2026-01-13 16:12:28 +08:00
Karl Seguin
801c019150 update v8 2026-01-13 16:07:49 +08:00
Karl Seguin
d77a6620f3 merge main 2026-01-13 13:05:16 +08:00
Karl Seguin
4e4a615df8 Move Env's FunctionTemplate from Global -> Eternal
(we'll move more to Eternal's, this is just a first teaser)
2026-01-13 12:58:31 +08:00
Karl Seguin
1b0ea44519 merge main 2026-01-13 12:58:31 +08:00
Karl Seguin
86f4ea108d Store snapshot templates in isolate, not context.
This lets us load the isolate without having to create a temp/dummy context
just to get the templates.

Call ContextDisposedNotification when a context is removed. Supposedly this can
help/hint to the isolate about memory management.
2026-01-13 12:58:30 +08:00
Karl Seguin
2322cb9b83 remove unused code, remove references to v8::Persistent 2026-01-13 12:58:30 +08:00
Karl Seguin
4720268426 Don't dupe StartupData, use what v8 gives us directly. 2026-01-13 12:58:30 +08:00
Karl Seguin
b4f134bff6 Prefer js.Value over js.Object in History/Navigation
Persist function callback in PerformanceObserver
2026-01-13 12:58:30 +08:00
Karl Seguin
f2a9125b99 js.v8 is not equal to js.v8.c
This means the C funtions/types now sit in the root of v8.
2026-01-13 12:58:30 +08:00
Karl Seguin
8438b7d561 remove remaining direct v8 references 2026-01-13 12:58:30 +08:00
Karl Seguin
18c846757b migrate almost all types 2026-01-13 12:58:28 +08:00
Karl Seguin
bc11a48e6b migrate most cases, merge Caller into bridge 2026-01-13 12:57:06 +08:00
Karl Seguin
01ecd725b8 cleanup resolvers 2026-01-13 12:57:06 +08:00
Karl Seguin
e6af7d1bd0 import more types 2026-01-13 12:57:06 +08:00
Karl Seguin
701de08e8a have our js.Context directly hold a js handle 2026-01-13 12:57:06 +08:00
Karl Seguin
363b95bdef Isolate and HandleScope 2026-01-13 12:57:06 +08:00
Karl Seguin
ca5a385b51 Port js.Object
Use js.Value in apis that should take values (not objects), like console.log
and setTimeout and reportError.
2026-01-13 12:57:03 +08:00
Karl Seguin
93f0d24673 port TryCatch 2026-01-13 12:56:07 +08:00
Karl Seguin
a5038893fe port Snapshot 2026-01-13 12:56:07 +08:00
Karl Seguin
3442f99a49 remove unused js.This 2026-01-13 12:56:07 +08:00
Karl Seguin
6ecf52cc03 port Platform and Inspector to use v8's C handles/functions directly 2026-01-13 12:56:07 +08:00
Karl Seguin
8aaef674fe Migrate Function and String 2026-01-13 12:56:07 +08:00
Karl Seguin
3b1cd06615 Make js.Array and js.Value directly contain their v8 handles. 2026-01-13 12:56:06 +08:00
Karl Seguin
4841f8cc8f add trycatch to Intersection and Performance Observers 2026-01-13 12:55:10 +08:00
Karl Seguin
d9d8f68bf8 Merge pull request #1361 from lightpanda-io/mutation-observer-trycall
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
use tryCall for MutationObserver records callback
2026-01-12 22:42:33 +00:00
Pierre Tachoire
cf726d9813 fix double slash in import path 2026-01-12 17:59:49 +01:00
Pierre Tachoire
92be2c45d6 use tryCall for MutationObserver records callback
Instead of `call` to avoid uncaught error
2026-01-12 17:58:40 +01:00
Pierre Tachoire
914092b538 Merge pull request #1355 from lightpanda-io/console_apis
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add some missing APIs to Console
2026-01-12 15:15:24 +01:00
Pierre Tachoire
a8cd5fc266 Merge pull request #1354 from lightpanda-io/node_document
Node document
2026-01-12 15:14:57 +01:00
Pierre Tachoire
643f07fa10 Merge pull request #1352 from lightpanda-io/mutation_character_data
Mutation character data
2026-01-12 15:13:32 +01:00
Pierre Tachoire
0d77ff661b Merge pull request #1360 from lightpanda-io/wpt-v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: fix wpt path
2026-01-12 10:53:51 +01:00
Pierre Tachoire
70d84b2f72 ci: fix wpt build path 2026-01-12 10:38:13 +01:00
Pierre Tachoire
41905ef735 Merge pull request #1358 from lightpanda-io/wpt-v8
ci: move fetch test from integration to e2e
2026-01-12 09:26:55 +01:00
Pierre Tachoire
2a468cc750 ci: split wpt build and run
vv8 build can pollute stdout output.
2026-01-12 09:18:16 +01:00
Pierre Tachoire
32520000c6 ci: use releaseFast mode for wpt 2026-01-12 09:09:36 +01:00
Pierre Tachoire
14db7a8eb3 ci: move fetch test from integration to e2e 2026-01-12 08:53:24 +01:00
Pierre Tachoire
8460e9a385 Merge pull request #1357 from lightpanda-io/wpt-v8
CI changes
2026-01-12 08:42:53 +01:00
Pierre Tachoire
933a93a703 ci: move fetch tests into 2e2 2026-01-12 08:32:52 +01:00
Pierre Tachoire
c2e09d3084 ci: build only run cmd forbuild dev test 2026-01-12 08:28:03 +01:00
Pierre Tachoire
98397401b8 ci: use compiled v8 with wpt tests 2026-01-12 08:11:55 +01:00
Karl Seguin
e042b1105a Merge pull request #1311 from lightpanda-io/nikneym/backport-canvas
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Backport dummy canvas APIs
2026-01-10 23:26:42 +00:00
Halil Durak
ee4775eb1a prefer underscore on fields 2026-01-10 14:13:41 +03:00
Halil Durak
6ff6232316 move isHexColor to color.zig 2026-01-10 14:13:09 +03:00
Karl Seguin
10035ab2f4 add some missing APIs to Console 2026-01-10 17:45:25 +08:00
Karl Seguin
2679175ae9 make createElement return DOMException on error 2026-01-10 16:00:11 +08:00
Karl Seguin
8d3aa1f3fa validate tag name given to document.createElement 2026-01-10 10:43:43 +08:00
Karl Seguin
75e78795ec Add Document.replaceChildren
Improve correctness (hierarchy validation) of various Document functions.
2026-01-10 10:32:02 +08:00
Karl Seguin
05f0f8901e make Node.isConnected() shadowroot-aware 2026-01-10 08:24:12 +08:00
Karl Seguin
6917aeb47b Walk document for doctype 2026-01-10 08:05:03 +08:00
Karl Seguin
516a86e33f Merge pull request #1331 from lightpanda-io/zigdom-selector-case-insensitive
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Adds case insensitivity to `Element.querySelector`
2026-01-09 23:20:39 +00:00
Halil Durak
7184a91c95 finalize canvas backport 2026-01-09 19:46:11 +03:00
Halil Durak
83e9d705cf backport dummy canvas APIs 2026-01-09 16:47:19 +03:00
Karl Seguin
bb907f5adb Support range mutation across nodes
Range mutation will trigger MutationObserver

MutationObserver with characterDataOldValue=true implicitly means
characterData=true

For MutationObserver-characterData test.
2026-01-09 20:42:23 +08:00
Karl Seguin
f1b60453bd Add getAttributeNamespace to MutationRecord 2026-01-09 20:25:49 +08:00
Pierre Tachoire
0ef339f12a Merge pull request #1349 from lightpanda-io/build-timeout
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: increase timeout limit for build
2026-01-09 12:34:18 +01:00
Pierre Tachoire
5c0169ee05 ci: force release to be the latest 2026-01-09 11:44:38 +01:00
Pierre Tachoire
daf959ee90 Merge pull request #1347 from lightpanda-io/registerProtocolHandler
Add Navigator.registerProtocolHandler and unregisterProtocolHandler p…
2026-01-09 11:42:49 +01:00
Pierre Tachoire
89b43b6102 Merge pull request #1348 from lightpanda-io/wpt_events
Wpt events
2026-01-09 11:41:41 +01:00
Pierre Tachoire
d3b05201b9 Merge pull request #1350 from lightpanda-io/css_selector_escape_sequence
Support escape sequences in CSS selector for id and class selectors
2026-01-09 11:40:33 +01:00
Karl Seguin
127e53cf3a Merge pull request #1344 from lightpanda-io/indexed_fix
Define the index handler on the instance, not the prototype.
2026-01-09 10:38:54 +00:00
Pierre Tachoire
29281fe3ec Merge pull request #1346 from lightpanda-io/more_cloneNode
Support cloneNode for DocumentType and Attribute
2026-01-09 11:38:18 +01:00
Pierre Tachoire
a0fb55802f Merge pull request #1345 from lightpanda-io/add_more_explicit_types
Adds a number of HTML elements
2026-01-09 11:37:51 +01:00
Pierre Tachoire
90ec068367 Merge pull request #1351 from lightpanda-io/inspector-deinit-handlescope
use temporary handle scope to deinit inspector
2026-01-09 11:37:00 +01:00
Pierre Tachoire
f57cf1be75 use temporary handlescope to deinit inspector 2026-01-09 11:25:34 +01:00
Karl Seguin
3f44dee367 Support escape sequences in CSS selector for id and class selectors
Improves dom/nodes/ParentNode-querySelector-escapes.html from 20/68 -> 64/68.

Previous main had 66/68..but the last 4 are really edge cases that add a lot
of complexity.
2026-01-09 16:42:57 +08:00
Pierre Tachoire
82161ce94c ci: increase timeout limit for build 2026-01-09 09:14:01 +01:00
Karl Seguin
27b8e2a38c fix test 2026-01-09 14:38:47 +08:00
Karl Seguin
e5f2fbdcb2 clear isTrusted on redispatch and prevent redispatching while dispatching 2026-01-09 14:37:34 +08:00
Karl Seguin
cdf0cdd0ea Don't require handleEvent function to be on object event listener
When registering an object as an event listener, the handleEvent function
doesn't have to be defined then and there. The handleEvent function can be added
at any point in the future.
2026-01-09 14:27:00 +08:00
Karl Seguin
f12ff2c7bd Add Navigator.registerProtocolHandler and unregisterProtocolHandler placeholders 2026-01-09 13:40:19 +08:00
Karl Seguin
6c7c507d32 Support cloneNode for DocumentType and Attribute 2026-01-09 11:10:50 +08:00
Karl Seguin
0c97b8238b Adds a number of HTML elements
Instead of being mapped to HTMLUnknownElement, these will all be mapped to the
correct type. This is important for many WPT tests. But it's not impossible that
some script checks `if (x instanceof HTMLBaseElement)` and, without this, that
would error since HTMLBaseElement wouldn't be defined.
2026-01-09 10:56:23 +08:00
Karl Seguin
967a2030e6 Define the index handler on the instance, not the prototype.
While it sorta works if done on the prototype, it's incorrect as these are no
longer "own" properties (which some WPT tests care about). NamedIndexes were
already correctly defined on the instance.
2026-01-09 10:31:01 +08:00
Karl Seguin
78ebd5faf8 Merge pull request #1342 from lightpanda-io/better_namespace_support
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Improve support for non-HTML namespace
2026-01-09 07:05:36 +08:00
Karl Seguin
9d498fa069 Improve support for non-HTML namespace
This does a better job of tracking the implicit namespace based on the context.
For example, when using DOMParser.parseFromString with an XML namespace, all
subsequent elements will be in the XML namespace.

Adds support for null namespace.

Rather than defaulting to HTML, unknown namespaces now map to a special unknown
type. We don't currently preserve the original namespace, but we're at least
able to properly handle the casing in this case.
2026-01-09 07:03:52 +08:00
Karl Seguin
0db1ceaea7 Merge pull request #1339 from lightpanda-io/remove_className
Remove className function from every type
2026-01-09 06:57:40 +08:00
Karl Seguin
df27aeef6c Merge pull request #1343 from lightpanda-io/navigator-ua-suffix
return the app's user agent on Navigator.userAgent
2026-01-09 06:56:01 +08:00
Karl Seguin
5ae0df53bb Remove className function from every type
The toString symbol is now automatically implemented on any type with a
JsApi.Meta.Name, so className is no longer used.
2026-01-09 06:55:07 +08:00
Karl Seguin
48df6ae159 Merge pull request #1338 from lightpanda-io/HTMLSpanElement
add an explicit HTMLSpanElement
2026-01-09 06:52:17 +08:00
Pierre Tachoire
6cae2fcea7 return the app's user agent on Navigator.userAgent 2026-01-08 15:23:02 +01:00
Pierre Tachoire
d1d4d4894d Merge pull request #1341 from lightpanda-io/test-bench
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
tests: re-enable metrics JSON output
2026-01-08 14:01:56 +01:00
Pierre Tachoire
adfcf7bb2c tests: re-enable metrics JSON output
METRICS=true zig build test
2026-01-08 13:07:27 +01:00
Karl Seguin
c8f75cd266 Merge pull request #1340 from lightpanda-io/nikneym/parse-from-string-return
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Return `*Document` instead of tagged union in `parseFromString`
2026-01-08 19:16:14 +08:00
Halil Durak
282a9bbf65 return *Document instead of tagged union in parseFromString
Did a detour to XML PR and realized this is simpler.
2026-01-08 12:46:46 +03:00
Karl Seguin
d4c8af2a61 add an explicit HTMLSpanElement 2026-01-08 16:03:50 +08:00
Karl Seguin
060afcd459 Merge pull request #1313 from lightpanda-io/nikneym/xml-parsing
Support XML parsing
2026-01-08 08:42:21 +08:00
Karl Seguin
5d1522a61f Don't dispatch to listeners added during dispatching
Use the last current listener as a sentinel, so that any listener added during
dispatching can be skipped.
2026-01-08 08:39:49 +08:00
Karl Seguin
b1b54afc56 Merge pull request #1335 from lightpanda-io/build
Embed v8 snapshot in builds
2026-01-08 06:54:38 +08:00
Pierre Tachoire
2abc490732 ci: support build on tag push 2026-01-07 21:11:20 +01:00
Pierre Tachoire
d4807df2e9 add v8 snapshot instructions into the README 2026-01-07 17:22:50 +01:00
Pierre Tachoire
d5f4ca15cc add v8 snapshot in build processes 2026-01-07 17:22:49 +01:00
Pierre Tachoire
e642c85ebd use ReleaseFast build 2026-01-07 17:22:49 +01:00
Muki Kiboigo
3930524bbf use tokenizeAny instead of tokenizeScalar in Selector 2026-01-07 06:12:49 -08:00
Halil Durak
7ea0cdba36 update DomParser test 2026-01-07 14:37:44 +03:00
Halil Durak
612b3a26b7 allow other XML MIMEs in parseFromString 2026-01-07 14:37:44 +03:00
Halil Durak
56d89895a8 initial XML parsing support in DOMParser 2026-01-07 14:37:43 +03:00
Karl Seguin
21d502b81f Merge pull request #1326 from lightpanda-io/wp/mrdimidim/use-css-tokenizer
Use css tokenizer for parsing style attrs
2026-01-07 18:09:06 +08:00
Karl Seguin
dd3de6efea Merge pull request #1327 from lightpanda-io/zigdom-cdata-length
Fix `dom/nodes/CharacterData-appendData` WPT
2026-01-07 18:04:05 +08:00
Karl Seguin
d934fe6d4e Merge pull request #1330 from lightpanda-io/zigdom-element-get-by-class-name-fix
fix `dom/nodes/Element-getElementsByClassName` wpt
2026-01-07 18:02:50 +08:00
Karl Seguin
dab6345885 dispatch events with proper this 2026-01-07 17:57:34 +08:00
Karl Seguin
39874137d6 Merge pull request #1333 from lightpanda-io/attribute_removeNamedItem
add attribute.removeNamedItem
2026-01-07 17:47:33 +08:00
Karl Seguin
89f215c3ee Merge pull request #1332 from lightpanda-io/getElementById
getElementById duplicate-id handling
2026-01-07 17:47:19 +08:00
Karl Seguin
408d3f0a53 Track owning documents for nodes which aren't the default document
Track this in a lookup on the page, to avoid having to store a pointer for
_every_ node, given that most nodes _are_ owned by the document.

This helps us ensure nodes can be properly adopted.
2026-01-07 17:46:09 +08:00
Karl Seguin
a010684ce9 Add deprecated Node constants
Remove toString where the [new] auto-generated toString symbol works.

Reject node mutation on attributes.
2026-01-07 17:36:26 +08:00
Karl Seguin
a4a98da4a4 add attribute.removeNamedItem 2026-01-07 16:51:12 +08:00
Karl Seguin
6f30d459d5 getElementById duplicate-id handling
If 2 elements have the same id then,
1 - The first in document-order has to be retrieved. We were ordering by
    insertion order.

2 - When the element is removed, then document.getElementById should return the
    next element with that id in document-order. We were returning null
2026-01-07 15:49:17 +08:00
Muki Kiboigo
622ca3121f add case insensitivity support to selector parsing 2026-01-06 23:31:34 -08:00
Muki Kiboigo
71f27a55e1 fix duping of string for getElementsByClassName 2026-01-06 23:07:32 -08:00
Karl Seguin
c92903aae5 Merge pull request #1328 from lightpanda-io/set_outerHTML
add outerHTML setter
2026-01-07 15:04:14 +08:00
Karl Seguin
518e0aa07a add outerHTML setter 2026-01-07 14:49:30 +08:00
Nikolay Govorov
b908b0bf8a Used css tokenizer for parse html attributes 2026-01-07 05:46:18 +00:00
Muki Kiboigo
f9fa5be324 count utf8 codepoints for CData getLength 2026-01-06 21:29:15 -08:00
Karl Seguin
8ec6bb1577 Merge pull request #1322 from lightpanda-io/input_clone
Add 'clone' callback to build, implement for Input
2026-01-07 13:08:20 +08:00
Karl Seguin
70f8c53703 add deprecated properties to Event and improve initEvent 2026-01-07 12:05:11 +08:00
Karl Seguin
6d5a984413 Merge pull request #1323 from lightpanda-io/document_title
Improve document.title getter
2026-01-07 10:42:03 +08:00
Karl Seguin
5fa8fbc6f8 Merge pull request #1324 from lightpanda-io/elements_by_name_nodelist
getElementsByName now returns a NodeList rather than an HTMLCollection
2026-01-07 10:41:54 +08:00
Karl Seguin
7050d5fc68 Merge pull request #1325 from lightpanda-io/tokenlist_treewalker
support element.relList and improve TreeWalker
2026-01-07 10:41:42 +08:00
Karl Seguin
6af9d12f71 support element.relList and improve TreeWalker 2026-01-07 10:34:47 +08:00
Karl Seguin
a54e1db784 getElementsByName now returns a NodeList rather than an HTMLCollection
Auto-implement a toString accessor for any type that has a JsApi.Meta.name
2026-01-07 09:17:51 +08:00
Karl Seguin
2319b0fda5 Improve document.title getter
Collapse whitespace and find the first title, no matter where it is.
2026-01-07 07:52:20 +08:00
Karl Seguin
6864a22721 fix datetime-local input type 2026-01-07 07:39:42 +08:00
Karl Seguin
c9d0e2097d add input indeterminate accessor 2026-01-07 07:35:24 +08:00
Karl Seguin
d8f7eb3f24 Add 'clone' callback to build, implement for Input 2026-01-07 07:29:43 +08:00
Karl Seguin
90ee919f45 Merge pull request #1321 from lightpanda-io/event-init
set _time_stamp in the Event factory
2026-01-07 07:14:34 +08:00
Karl Seguin
ddc6431720 Merge pull request #1316 from lightpanda-io/reject_non_new_constructor
Reject constructors called as function (i.e. without 'new')
2026-01-07 07:14:13 +08:00
Pierre Tachoire
2ea6557fb7 add initEvent into Factory
and remove default value for Event._time_stamp
2026-01-06 15:30:13 +01:00
Karl Seguin
15358c1928 Improve Range, adding missing functions and more validation 2026-01-06 20:27:16 +08:00
Karl Seguin
d65025b3cb Merge pull request #1320 from lightpanda-io/fix-replaceChildren
remove children from previous parent
2026-01-06 19:07:15 +08:00
Pierre Tachoire
54fa3bc054 remove children from previous parent 2026-01-06 11:52:47 +01:00
Pierre Tachoire
68f5fa738c remove dead code Page._appendNode 2026-01-06 11:49:13 +01:00
Karl Seguin
2ea57ba979 update v8 dep 2026-01-06 18:30:25 +08:00
Karl Seguin
1acc0b0dc8 Merge pull request #1310 from lightpanda-io/zigdom-named-access
Named Access on the Window Object
2026-01-06 18:07:43 +08:00
Karl Seguin
645ec79fce access page from context, document call_depth usage 2026-01-06 18:04:17 +08:00
Karl Seguin
97e897e80e Merge pull request #1318 from lightpanda-io/node-self-replace
handle Node self replacement in insertBefore
2026-01-06 17:23:12 +08:00
Karl Seguin
6f72eeae65 Merge pull request #1319 from lightpanda-io/script_list_cleanup
Handle immediate call to Script.errorCallback
2026-01-06 17:22:14 +08:00
Karl Seguin
a845b2e35e Handle immediate call to Script.errorCallback
It's possible for Script.errorCallback to be called as part of the call to
`client.request`. This happens because we eagerly pump the libcurl message loop
to get the request going ASAP. For very obvious failures (e.g. an invalid URL)
this means that the error callback can be called from `client.request`.

Previously, we were only adding the script to its list _after_ the call to
`client.request`, but the error handler tries to remove the script from the list
.

This commit changes the order so that the script is first added to the list
and then the request is made.
2026-01-06 17:03:27 +08:00
Pierre Tachoire
b164ffeb95 handle Node self replacement in insertBefore 2026-01-06 09:49:08 +01:00
Karl Seguin
7ba34af884 Merge pull request #1317 from lightpanda-io/zigValueToJs-opts-pass-down
pass down opts to zigValueToJs
2026-01-06 16:36:44 +08:00
Pierre Tachoire
7f543ac7c8 pass down opts to zigValueToJs 2026-01-06 09:35:38 +01:00
Karl Seguin
a1bf92c07f Reject constructors called as function (i.e. without 'new')
Previously, `MessageEvent('')` would have been allowed, but invalid. This caused
problems as the receiver was the window. All such calls are now rejected.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/131
2026-01-06 16:03:37 +08:00
Karl Seguin
0b221615b7 Merge pull request #1315 from lightpanda-io/replaceChild-itself
fix Node.replaceChild when of new child equals old
2026-01-06 08:00:03 +08:00
Pierre Tachoire
f81a9b54a7 fix Node.replaceChild when of new child equals old 2026-01-05 21:48:59 +01:00
Muki Kiboigo
05da040ce1 increment call_depth on callWithThis 2026-01-05 09:27:17 -08:00
Muki Kiboigo
b911051842 add named access shadowing test 2026-01-05 09:08:57 -08:00
Muki Kiboigo
a67f46b550 add named access on the Window object 2026-01-05 08:41:42 -08:00
Karl Seguin
dcde19de3c Merge pull request #1309 from lightpanda-io/zigdom-anchor-click
Anchor Click
2026-01-05 12:30:30 +08:00
Muki Kiboigo
a8b4e8c1bc fix handleclick for hash href on anchor 2026-01-04 20:28:34 -08:00
Karl Seguin
7b0e256408 copy history test from legacy 2026-01-05 10:12:41 +08:00
Karl Seguin
5a974f0d77 Merge pull request #1282 from lightpanda-io/nikneym/performance-observer
Support `PerformanceObserver` API
2026-01-05 07:52:58 +08:00
Halil Durak
f7fe8d00fb add a PerformanceObserver test 2026-01-04 18:09:45 +03:00
Halil Durak
946b6d8226 PerformanceObserver changes 2026-01-04 18:09:28 +03:00
Karl Seguin
25366f0e47 Support multiple blocking scripts waiting for the same response
I thought we eliminated this from being possible, but I guess not. It seems to
be related to multiple async scripts direct or indirectly (or a specific
combination?) waiting for the same synchronous script. The first async script
to block, grabs it and deletes it, making it invalid for the next.

This puts back a counter on blocking scripts which I took out when I thought
this could no longer happen.
2026-01-02 20:57:29 +08:00
Karl Seguin
562e8e8d87 ignore empty pseudo_element in getComputedStyle 2026-01-02 16:47:04 +08:00
Karl Seguin
11ff9ed366 update log src for fetch (xhr -> fetch) 2025-12-30 17:08:27 +08:00
Halil Durak
9a9f2ab94b rm ring_buffer.zig 2025-12-30 11:46:58 +03:00
Karl Seguin
27048fb06d Merge pull request #1305 from lightpanda-io/nikneym/observer-lists
prefer `DoublyLinkedList` for storing `MutationObserver`s in `Page`
2025-12-30 16:43:40 +08:00
Halil Durak
e103ee1ffa prefer low priority queue to execute performance observer 2025-12-30 11:43:24 +03:00
Halil Durak
acebbb9041 don't prefer microtask queue for execution
This still needs investigation. Spec doesn't refer usage of microtask queue for this, yet the current behavior doesn't match to Firefox and Chrome.
2025-12-30 11:43:24 +03:00
Halil Durak
0264c94426 proper interested function 2025-12-30 11:42:21 +03:00
Halil Durak
88de72a9ea core performance observer logic
Heavily based on MutationObserver and IntersectionObserver.
2025-12-30 11:42:21 +03:00
Halil Durak
9306adc786 add an overwriting ring buffer implementation 2025-12-30 11:42:20 +03:00
Halil Durak
43c30f8a34 avoid inline + don't initialize node 2025-12-30 11:39:09 +03:00
Karl Seguin
7c7240d5ab Try to protect against invalid use of document.write
Specifically, try to block multiple document.write which, when combined, have
multiple html documents.
2025-12-30 10:07:56 +08:00
Karl Seguin
169582c992 DOMRect constructor
More default computed styles (color and backgroundColor)

HTMLMetaElement properties

Case-insensitive findAdjacentNodes position parameter

Allow computedStyle pseudo_element parameter (ignore, log not implemented)

Window.isSecureContext always returns false
2025-12-30 09:33:00 +08:00
Karl Seguin
7b74161e9c Merge pull request #1270 from lightpanda-io/wp/mrdimidium/css-parsing
CSS parsing
2025-12-30 07:05:56 +08:00
Karl Seguin
633e98c8f4 Merge pull request #1306 from lightpanda-io/generic-tags
add more generic tags
2025-12-30 07:01:52 +08:00
Pierre Tachoire
5743c4fc93 add more generic tags 2025-12-29 18:15:02 +01:00
Nikolay Govorov
9984b3445f Add css tokenazer for parse style attribute 2025-12-29 15:06:07 +00:00
Pierre Tachoire
90a7e96181 Merge pull request #1301 from lightpanda-io/backport-zig-versions
update ci scripts
2025-12-29 16:00:36 +01:00
Pierre Tachoire
00d4ac6137 update ci scripts
* use checkout v6
* remove useless target from Makefile
2025-12-29 15:17:19 +01:00
Halil Durak
ee432c54b8 prefer DoublyLinkedList for storing MutationObservers in Page 2025-12-29 16:18:09 +03:00
Pierre Tachoire
76ec3eb738 Merge pull request #1303 from lightpanda-io/Makefile
build: standardize ansi escape sequences in makefile
2025-12-29 13:42:02 +01:00
Pierre Tachoire
37832c63a4 Merge pull request #1302 from lightpanda-io/backport-graceful-shutdown
Add a synchronous signal handler for graceful shutdown
2025-12-29 13:41:47 +01:00
Pierre Tachoire
d1c33f0872 build: standardize ansi escape sequences in makefile 2025-12-29 12:55:56 +01:00
Pierre Tachoire
4684b8611d Add a synchronous signal handler for graceful shutdown 2025-12-29 12:43:52 +01:00
Karl Seguin
f4961ee8b2 Merge pull request #1299 from lightpanda-io/cdp-inserttext
backport cdp Input.insertText
2025-12-29 19:11:46 +08:00
Pierre Tachoire
27f6f4243f Apply suggestions from code review
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2025-12-29 12:08:07 +01:00
Karl Seguin
dcf1d34889 Merge pull request #1292 from lightpanda-io/nikneym/script-execution-changes
Run microtasks after each script execution
2025-12-29 18:42:03 +08:00
Pierre Tachoire
76f30dc985 zig fmt 2025-12-29 11:40:32 +01:00
Pierre Tachoire
2d6c37fa6f handle input selection when keydown 2025-12-29 11:40:32 +01:00
Pierre Tachoire
3e52abf471 cdp: add input.insertText 2025-12-29 11:40:27 +01:00
Pierre Tachoire
d697944b5a add Input.select() 2025-12-29 10:35:26 +01:00
Pierre Tachoire
cf14b9e762 add Document.hasFocus placeholder 2025-12-29 10:35:05 +01:00
Pierre Tachoire
121cf40062 Merge pull request #1291 from lightpanda-io/docker-update
Docker update for zigdom
2025-12-29 10:01:00 +01:00
Halil Durak
abc89b7eae run tasks after microtasks
Also removes `page.tick`.
2025-12-29 11:04:22 +03:00
Karl Seguin
dc33c4d5fd improve console.log output when using logfmt 2025-12-29 12:58:20 +08:00
Karl Seguin
087086c308 remove some unused imports 2025-12-26 12:40:20 +08:00
Karl Seguin
05cb5221d4 Quick-check sameness in Node.isEqualNode
Exclusively use the not_implemented log filter.
2025-12-26 09:57:33 +08:00
Karl Seguin
0fff379ee0 dummy createAttributeNS 2025-12-26 09:30:54 +08:00
Karl Seguin
0c23818470 Merge branch 'zigdom-history-fixes' into zigdom 2025-12-26 09:19:30 +08:00
Karl Seguin
25dbac9945 event isTrusted support and better composedPath for shadowroots 2025-12-26 08:45:57 +08:00
Karl Seguin
b379b775f9 Merge pull request #1296 from lightpanda-io/v8-json-parser
Backport: Use V8 to parse JSON with fetch/xhr
2025-12-25 20:48:38 +08:00
Pierre Tachoire
7cc2c2344e use V8 json parser with xhr/fetch webAPIs
The pure zig JSON parser didn't generate the same type of values than JS
JSON.parse command.
Using directly V8's JSON parser gives the assurance to have the right
JS types.
Moreover, it avoid data transformations between Zig and V8.
2025-12-25 12:50:45 +01:00
Pierre Tachoire
d50f6b830a add Value.persist 2025-12-25 12:50:44 +01:00
Pierre Tachoire
8f2921f61f add test for big json number with fetch/xhr 2025-12-25 12:50:44 +01:00
Karl Seguin
e9ec089f76 legacy keyboard and mouse events 2025-12-25 18:52:34 +08:00
Karl Seguin
dca99c338e more Animation accessors 2025-12-25 17:00:27 +08:00
Karl Seguin
cc3a498294 legacy tests 2025-12-25 16:47:08 +08:00
Karl Seguin
c88cb35b84 add AbstractRange 2025-12-25 13:15:57 +08:00
Karl Seguin
8be7a9f2bc more legacy test fixes 2025-12-25 11:39:32 +08:00
Karl Seguin
899567328e more legacy test tweaks (mostly around CSS) 2025-12-25 10:02:04 +08:00
Karl Seguin
9f3cb4349d more legacy test fixes 2025-12-25 09:08:01 +08:00
Karl Seguin
b2b890b8b1 Merge pull request #1294 from lightpanda-io/zigdom-history-scroll-restoration
backport `ScrollRestoration` to `History`
2025-12-25 07:40:33 +08:00
Karl Seguin
f266dbc171 remove unecessary task execution in legacy_tests 2025-12-25 07:35:02 +08:00
Halil Durak
b28ac8ca19 run microtasks after each script execution
This don't change the behavior for async and deferred scripts.

just run microtasks after a script execution
2025-12-24 21:50:44 +03:00
Muki Kiboigo
248ce4f1a8 add .skip.html to skip files in legacy tests 2025-12-24 09:04:33 -08:00
Muki Kiboigo
872ec33662 use scheduleNavigation instead of navigate 2025-12-24 09:04:33 -08:00
Muki Kiboigo
b3e6186c78 history tests pass without crash 2025-12-24 09:04:33 -08:00
Muki Kiboigo
a31497937b use session arena instead of storing arena in Navigation 2025-12-24 09:04:25 -08:00
Karl Seguin
90088c5d7c Merge pull request #1290 from lightpanda-io/zigdom_request_interception
Zigdom request interception
2025-12-25 00:40:48 +08:00
Muki Kiboigo
4c8abd4680 add scrollRestoration to History 2025-12-24 07:46:34 -08:00
Karl Seguin
a25fb4a8e4 re-enable crashing legacy tests 2025-12-24 18:41:44 +08:00
Karl Seguin
29efb467f0 Various input fixes (support for more attributes) based on legacy tests
AbortSignal.timeout function

LocalStorage named getter/setter
2025-12-24 18:36:46 +08:00
Pierre Tachoire
ffe2bc9a02 update README 2025-12-24 10:35:11 +01:00
Pierre Tachoire
8105dff167 remove useless step from README 2025-12-24 10:04:30 +01:00
Pierre Tachoire
8d992d74c0 update Dockerfile for zigdom 2025-12-24 10:04:10 +01:00
Karl Seguin
296fa2a2f4 Update src/http/Client.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-12-24 16:37:16 +08:00
Karl Seguin
a9e6051867 HTMLElement.click() 2025-12-24 16:17:17 +08:00
Karl Seguin
0fcb316837 Move HTML-specific behavior from Element to HTMLElement. 2025-12-24 16:17:17 +08:00
Karl Seguin
c0704f822b Merge pull request #1288 from lightpanda-io/pseudo-frames
backport frames access from Window
2025-12-24 15:51:41 +08:00
Pierre Tachoire
ba974f695d use a better comparison 2025-12-24 08:46:46 +01:00
Karl Seguin
3ca82b9ab5 Tweak CSS
Give default styles to visibility properties. Unblocks various playwright
behavior
2025-12-24 15:04:06 +08:00
Karl Seguin
df4e5d859f Enable blocking auth request interception 2025-12-24 12:19:11 +08:00
Karl Seguin
67875036c5 Rework request interception for Zigdom
Zigdom broke request interception. It isn't zigdom specifically, but in zigdom
we properly block the parser when executing a normal (not async, not defer)
script. This does not work well with request interception, because an
intercepted request isn't blocked on HTTP data, it's blocked on a message from
CDP. Generally, neither our Page nor ScriptManager are CDP-aware. And, even if
they were, it would be hard to break out of our parsing and return control to
the CDP server.

To fix this, we expand on the HTTP Client's basic awareness of CDP (via its
extra_socket field). The HTTP client is now able to block until an intercepted
request is continued/aborted/fulfilled. it does this by being able to ask the
CDP client to read/process data.

This does not yet work for intercepted authentication requests.
2025-12-24 11:49:05 +08:00
Karl Seguin
83f008de1f Correctly handle setting textContent to empty for DocFrag and Element
Fixes an [non-critical] error on old.reddit.com
2025-12-24 11:43:43 +08:00
Karl Seguin
7183b0339b fix crash on amazon product page 2025-12-24 08:00:26 +08:00
Karl Seguin
9969ff7165 implement html5ever append_based_on_parent_node and append_before_sibling 2025-12-24 07:37:44 +08:00
Karl Seguin
0ca97d01ac Merge pull request #1287 from lightpanda-io/window.scrollTo
Add Window.scrollTo
2025-12-24 07:16:13 +08:00
Karl Seguin
fc4dbb6184 Merge pull request #1286 from lightpanda-io/zigdom-single-build
Single Build Command
2025-12-24 07:09:59 +08:00
Karl Seguin
9b16212d4b Merge pull request #1285 from lightpanda-io/base_url
implement base_url
2025-12-24 07:09:39 +08:00
Pierre Tachoire
4d67cfa340 backport frames access from Window 2025-12-23 17:14:34 +01:00
Pierre Tachoire
2bd38608e9 throttle scroll event 2025-12-23 16:13:02 +01:00
Karl Seguin
6ce117e5fa Add padding to DOMImplementation to prevent ptr collision with other empty types 2025-12-23 21:36:27 +08:00
Pierre Tachoire
2b10b1c17a webapi: add window.scrollTo 2025-12-23 12:07:07 +01:00
Karl Seguin
bbf58a2807 Move page out of arena so that the arena can be reset between navigates 2025-12-23 16:26:28 +08:00
Pierre Tachoire
44ffcaeed8 fix legacy test expected port 2025-12-23 08:44:25 +01:00
Pierre Tachoire
a597d31505 set page base_url during HTML parsing 2025-12-23 08:44:24 +01:00
Pierre Tachoire
6dbd008724 page: use optional base_url to resolve urls 2025-12-23 08:44:24 +01:00
Pierre Tachoire
7d47f8623a webapi: add Node.baseURI accessor 2025-12-23 08:18:06 +01:00
Karl Seguin
7c755483b1 Register HTMLImageElement name.
Handle DOMParser with empty string

This gets DDG results working.
2025-12-23 14:33:47 +08:00
Karl Seguin
e387e005d8 try to improve page re-navigate (reset) memory usage 2025-12-23 12:32:16 +08:00
Muki Kiboigo
c9f6cb7520 fix single build with rust in ci 2025-12-22 10:41:22 -08:00
Muki Kiboigo
596ee82a52 zig build builds everything 2025-12-22 09:57:34 -08:00
Karl Seguin
79b62e0dfc Merge pull request #1284 from lightpanda-io/fix-page-navigate
Fix page navigate with legacy_test
2025-12-22 22:58:41 +08:00
Karl Seguin
e67cf21917 quick fix for segfault 2025-12-22 22:52:41 +08:00
Pierre Tachoire
8fb1c3971c fix page.navigate into legacy_test and wpt 2025-12-22 15:39:46 +01:00
Karl Seguin
437df18a07 form submitt 2025-12-22 19:45:29 +08:00
Karl Seguin
8215f2fd8f Merge branch 'snapshots_v2' into zigdom 2025-12-22 17:03:38 +08:00
Karl Seguin
af7f51a647 start handling page clicks and key presses 2025-12-22 17:02:20 +08:00
Karl Seguin
3ab09d87f2 Update src/browser/js/ExecutionWorld.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-12-22 16:44:55 +08:00
Karl Seguin
4c1d82162f Update src/browser/js/Snapshot.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-12-22 16:44:49 +08:00
Karl Seguin
3830e2610b Update src/browser/js/Snapshot.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-12-22 16:44:42 +08:00
Karl Seguin
e3265d400e Update src/browser/js/Env.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-12-22 16:44:33 +08:00
Karl Seguin
d9c53a3def Page.scheduleNavigation for location changes 2025-12-22 12:19:08 +08:00
Karl Seguin
da32440a14 pass IdleDeadline to idle callback 2025-12-21 18:26:54 +08:00
Karl Seguin
25ad3559f7 Add Document.gettype 2025-12-21 17:13:36 +08:00
Karl Seguin
8fbd64955f Dynamically added scripts default to async 2025-12-21 16:51:39 +08:00
Karl Seguin
32c83d166d implement html5ever createPI callback 2025-12-21 16:04:59 +08:00
Karl Seguin
d95b19d31b update CI zig-v8-fork version, improve XHR state management 2025-12-21 15:26:26 +08:00
Karl Seguin
9e62e72d1f Merge branch 'fix_ci' into zigdom 2025-12-19 21:36:15 +08:00
Karl Seguin
29259c23d7 update zig-v8-fork version 2025-12-19 21:36:09 +08:00
Karl Seguin
3d6af216dc 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.
2025-12-19 21:29:28 +08:00
Karl Seguin
f475aa09e8 backport https://github.com/lightpanda-io/browser/pull/1265 2025-12-19 16:06:25 +08:00
Pierre Tachoire
1278dc28cd cdp: add accessibility domain 2025-12-19 10:34:41 +08:00
Pierre Tachoire
33ee2fb1a0 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
2025-12-19 10:33:42 +08:00
Pierre Tachoire
2ac90262b7 ci: add nightly integration test 2025-12-19 10:32:39 +08:00
Karl Seguin
bb1ea39c54 backport a variety of smaller CDP changes 2025-12-19 10:31:07 +08:00
Pierre Tachoire
a087386af3 cdp: implement DOM.requestNode 2025-12-19 10:15:21 +08:00
Pierre Tachoire
fe96bc7895 cdp: use default value for grantUniveralAccess
In createIsolatedWorld, we set  a default value to false for optional
grantUniveralAccess parameter.
2025-12-19 10:10:41 +08:00
Pierre Tachoire
7a69e3fc9b cdp: add browser permissions noop 2025-12-19 10:07:04 +08:00
Karl Seguin
566fa72bcd various small backports from main 2025-12-19 10:05:42 +08:00
Karl Seguin
520e197e0e build html5ever in CI 2025-12-19 08:25:22 +08:00
Karl Seguin
c15ef590c2 build html5ever in CI 2025-12-19 08:16:36 +08:00
Karl Seguin
098eeea8f7 remove some mimalloc, netsurf and iconv references 2025-12-19 07:18:47 +08:00
Karl Seguin
c3f8f9de54 merge https://github.com/lightpanda-io/browser/pull/1275 2025-12-18 21:17:13 +08:00
Karl Seguin
ba4900b61f import template parsing test from 'legacy' 2025-12-18 21:14:41 +08:00
Karl Seguin
3e03f7559f Document log_filter_scope argument
Add fetch logging
2025-12-18 20:48:14 +08:00
Karl Seguin
46f8a11339 Merge pull request #1277 from lightpanda-io/zigdom-ui-events
`UIEvent`, `MouseEvent` and `KeyboardEvent`
2025-12-18 20:26:42 +08:00
Karl Seguin
b3a0aaaeea Enable v8 snapshots
There are two layers here. The first is that, on startup, a v8 SnapshotCreator
is created, and a snapshot-specific isolate/context is setup with our browser
environment. This contains most of what was in Env.init and a good chunk of
what was in ExecutionWorld.createContext. From this, we create a v8.StartupData
which is used for the creation of all subsequent contexts. The snapshot sits
at the application level, above the Env - it's re-used for all envs/isolates, so
this gives a nice performance boost for both 1 connection opening multiple pages
or multiple connections opening 1 page.

The second layer is that the Snapshot data can be embedded into the binary, so
that it doesn't have to be created on startup, but rather created at build-time.
This improves the startup time (though, I'm not really sure how to measure that
accurately...).

The first layer is the big win (and just works as-is without any build / usage
changes).

with snapshot
total runs 1000
total duration (ms) 7527
avg run duration (ms) 7
min run duration (ms) 5
max run duration (ms) 41

without snapshot
total runs 1000
total duration (ms) 9350
avg run duration (ms) 9
min run duration (ms) 8
max run duration (ms) 42

To embed a snapshot into the binary, we first need to create the snapshot file:

zig build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin

And then build using the new snapshot_path argument:

zig build -Dsnapshot_path=../../snapshot.bin -Doptimize=ReleaseFast

The paths are weird, I know...since it's embedded, it needs to be inside the
project path, hence we put it in src/snapshot.bin. And since it's embedded
relative to the embedder (src/browser/js/Snapshot.zig) the path has to be
relative to that, hence ../../snapshot.bin. I'm open to suggestions on
improving this.
2025-12-18 20:10:38 +08:00
Karl Seguin
aa5e71112e v8 symbol -> []const support 2025-12-18 11:59:30 +08:00
Pierre Tachoire
22303d2ae8 Merge pull request #1236 from lightpanda-io/v8-build-with-zig-gclient-ci
V8 build with zig gclient ci
2025-12-18 11:55:55 +08:00
Muki Kiboigo
9dbfac02b2 add KeyboardEvent 2025-12-17 14:45:36 -08:00
Muki Kiboigo
6f43d9979d add MouseEvent 2025-12-17 14:11:49 -08:00
Muki Kiboigo
d63a045534 proper UIEvent 2025-12-17 11:51:55 -08:00
Muki Kiboigo
fe2d309d33 begin UIEvent 2025-12-17 11:49:04 -08:00
Karl Seguin
94ca2c41e4 Element.slot, Element.assignedSlot and slotchange event 2025-12-17 07:42:29 +08:00
Karl Seguin
8873e613d2 improve domexception 2025-12-16 19:16:42 +08:00
Karl Seguin
761b35b199 zig fmt 2025-12-16 17:54:14 +08:00
Karl Seguin
8a2641d213 fetch/request/response improvement (legacy) 2025-12-16 17:54:05 +08:00
Karl Seguin
e47091f9a1 legacy for request/response/fetch 2025-12-16 16:24:49 +08:00
Karl Seguin
ea399390ef Improve DOMImplementation, DocumentType and DOMException 2025-12-16 14:58:36 +08:00
Karl Seguin
d26869278f dummy HTMLCanvasElement 2025-12-16 11:13:57 +08:00
Karl Seguin
1639ff1b98 improve XMLHTTPRequest. Legacy xhr.html pass 2025-12-15 17:56:23 +08:00
Karl Seguin
9b3107d4fe build FormData from optional form and optional submitter 2025-12-15 12:31:30 +08:00
Karl Seguin
4bebc4c142 simplify / standardized how HTMLFormControlsCollection 'inherits' from HTMLCollection 2025-12-15 10:35:41 +08:00
Karl Seguin
ac0601b141 add RadioNodeList 2025-12-15 10:31:44 +08:00
Karl Seguin
6040cd3338 improve Form, notably form.elements 2025-12-14 20:02:39 +08:00
Karl Seguin
f93403d3dc 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.
2025-12-14 16:16:54 +08:00
Karl Seguin
82cd5d4bab fix legacy intersection observer test 2025-12-13 21:23:16 +08:00
Karl Seguin
0d3055716e tweak timing of intersection observer and how it handles disconnected nodes 2025-12-13 20:33:43 +08:00
Karl Seguin
c9b4067686 Event listener can now be an object with a handleEvent function 2025-12-13 17:19:53 +08:00
Karl Seguin
52dcc6765a URLSearchParams from FormData 2025-12-13 12:47:54 +08:00
Karl Seguin
eab328e2b5 Tweak URL, refactor Anchor and URL to share more common code 2025-12-12 21:50:13 +08:00
Karl Seguin
23146f64ab Screen and ScreenOrientation (legacy) 2025-12-12 18:21:30 +08:00
Karl Seguin
a6d3a3d0ab Add properties to HTMLStyleelement 2025-12-12 18:01:12 +08:00
Karl Seguin
5eb54bbc95 Media/Audio/Video elements 2025-12-12 17:34:57 +08:00
Karl Seguin
a4fa40743a ErrorEvent error as undefined 2025-12-12 07:58:34 +08:00
Karl Seguin
6d8c6a947e Merge pull request #1271 from lightpanda-io/zigdom-event-opts-inherit
Inherit Prototype Event Options
2025-12-12 07:26:10 +08:00
Karl Seguin
13cf0096ad Merge pull request #1272 from lightpanda-io/zigdom-remove-mbedtls
Properly remove mbedtls from `zigdom`
2025-12-12 07:22:21 +08:00
Muki Kiboigo
bd0f1d2884 remove mbedtls stuff for build.zig 2025-12-11 12:25:34 -08:00
Muki Kiboigo
5671580c2d properly remove mbedtls submodule 2025-12-11 12:25:25 -08:00
Muki Kiboigo
669c934ae0 Event Options dont need to be pub 2025-12-11 12:17:07 -08:00
Muki Kiboigo
b568eb4e1e migrate events to use new inheritOptions 2025-12-11 12:14:00 -08:00
Muki Kiboigo
4d8d6c10c6 add option inheriting for Events 2025-12-11 12:13:01 -08:00
muki
3667fbc49e Merge pull request #1253 from lightpanda-io/zigdom-navigation
Backport Navigation (and friends)
2025-12-11 12:01:57 -08:00
Karl Seguin
269c880ee0 Merge pull request #1246 from lightpanda-io/nikneym/is-equal-node
Support `isEqualNode`
2025-12-11 21:04:23 +08:00
Halil Durak
fe89aad621 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
2025-12-11 15:55:33 +03:00
Karl Seguin
38fb5b101e add Document.elementFromPoint and elementsFromPoint 2025-12-11 19:49:51 +08:00
Karl Seguin
3d8b1abda4 More legacy tests
Largely around how URL attributes (a.href, img.href, link.href) handle empty
values.
2025-12-11 16:45:19 +08:00
Karl Seguin
0b141e44ae Merge pull request #1267 from lightpanda-io/blob
port remaining blob functionality
2025-12-11 15:31:11 +08:00
Karl Seguin
695ed817e4 port remaining blob functionality 2025-12-11 15:30:43 +08:00
Karl Seguin
f0d9d53588 Merge pull request #1268 from lightpanda-io/nikneym/zigdom-boringssl
backport: Prefer BoringSSL as TLS backend
2025-12-11 15:28:10 +08:00
Karl Seguin
471e94d58e Merge pull request #1269 from lightpanda-io/nikneym/zigdom-fix-kludge-hack
backport: Remove `_TYPED_ARRAY_ID_KLUDGE` hack
2025-12-11 15:27:57 +08:00
Halil Durak
7b6776345a backport: Remove _TYPED_ARRAY_ID_KLUDGE hack
Bonus: Add `ArrayBuffer`.
2025-12-11 10:26:13 +03:00
Karl Seguin
68763d9a30 speed up tests 2025-12-11 15:23:39 +08:00
Halil Durak
bead805680 backport: Prefer BoringSSL as TLS backend 2025-12-11 10:19:07 +03:00
Karl Seguin
34f0857b4f Element legacy test passing 2025-12-11 12:51:56 +08:00
Karl Seguin
b25e46de2e zig fmt 2025-12-11 11:48:09 +08:00
Karl Seguin
86ae004825 new Comment(?[]const u8) 2025-12-11 07:41:08 +08:00
Karl Seguin
a355d9e517 Handle infinitely recursive mutation observer
FireFox hangs in these cases, but we'd rather handle it gracefully.
2025-12-11 07:13:59 +08:00
Karl Seguin
61aca85632 Pass Headers legacy tests 2025-12-10 18:43:24 +08:00
Karl Seguin
159165490d Allow event listener to remove itself or other pending listeners 2025-12-10 17:56:49 +08:00
Karl Seguin
9c8299f13f 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.
2025-12-10 16:39:27 +08:00
Karl Seguin
27e58181fb Properly resolve inspector ObjectId back to a DOM Node
Tweak element boundingRect and "renderer" based on what puppeteer needs.
2025-12-10 15:44:08 +08:00
Muki Kiboigo
02a0727870 eqlDocument slicing at hash 2025-12-09 17:11:04 -08:00
Muki Kiboigo
7c9d7259e6 add NavigationActivation 2025-12-09 17:11:04 -08:00
Muki Kiboigo
ddb83cf9c5 add assert and note on getCurrentEntry 2025-12-09 17:11:04 -08:00
Muki Kiboigo
3662d1681e no need to run microtasks before onload 2025-12-09 17:11:04 -08:00
Muki Kiboigo
6534dc4c4f use Navigation ptr instead of fat copy 2025-12-09 17:11:04 -08:00
Muki Kiboigo
395f93240d minor Navigation style changes 2025-12-09 17:11:03 -08:00
Muki Kiboigo
ac85341cab add NavigationKind to navigate 2025-12-09 17:10:59 -08:00
Muki Kiboigo
01d71323fc complete History impl backed by Navigation 2025-12-09 16:51:05 -08:00
Muki Kiboigo
ee7852665e fix GPL headers 2025-12-09 16:51:05 -08:00
Muki Kiboigo
9d7b80c1ac backport Location getHash 2025-12-09 16:51:05 -08:00
Muki Kiboigo
907298c6b1 backport pageshow event 2025-12-09 16:51:04 -08:00
Muki Kiboigo
cc53fec08d backport run microtasks before firing onload 2025-12-09 16:51:04 -08:00
Muki Kiboigo
ab165d3f1f getNavigationType return string 2025-12-09 16:51:04 -08:00
Muki Kiboigo
7c34cb5852 fix getState on NavigationHistoryEntry 2025-12-09 16:51:04 -08:00
Muki Kiboigo
71d57c1e27 add Navigation to Window 2025-12-09 16:51:04 -08:00
Muki Kiboigo
6a5e088c52 update wpt to include Navigation 2025-12-09 16:51:04 -08:00
Muki Kiboigo
8ec9f634b4 backport URL eqlDocument tests 2025-12-09 16:51:04 -08:00
Muki Kiboigo
0e4cfbfe6b backport the resolve/stitch regression test 2025-12-09 16:51:03 -08:00
Muki Kiboigo
370c3a49a7 initial Navigation 2025-12-09 16:51:01 -08:00
Pierre Tachoire
a7e0110acb Merge pull request #1260 from lightpanda-io/cdp-lifecycle-backport
support url on createTarget and send lifecycle events
2025-12-09 13:35:45 +01:00
Pierre Tachoire
3769715582 Page.reset msut create context and window once 2025-12-09 12:24:01 +01:00
Pierre Tachoire
0d8dd84df5 support url on createTarget and send lifecycle events
Support url parameter on createTarget. we now navigate on createTarget
to dispatch events correctly, even in case of about:blank
2025-12-09 11:29:00 +01:00
Karl Seguin
e98bb16255 Merge pull request #1259 from lightpanda-io/cdp-security-ignore-cert-err-backport
cdp: implement Security.setIgnoreCertificateErrors
2025-12-09 17:58:14 +08:00
Pierre Tachoire
6a098665fa http: remove inflight conn check when enable/disable TLS 2025-12-09 10:47:34 +01:00
Karl Seguin
47b4b68e60 add parsed DocType to document (and handle dumping it) 2025-12-09 16:38:56 +08:00
Pierre Tachoire
53ccefc15c cdp: implement Security.setIgnoreCertificateErrors
ensure no inflight conns is running before set TLS verify
2025-12-09 08:50:58 +01:00
Karl Seguin
0e1b966dce re-enable CDP dom domain 2025-12-09 13:04:01 +08:00
Karl Seguin
9132bc2375 re-enable CDP node registry 2025-12-09 11:50:33 +08:00
Karl Seguin
49c0e95664 Merge pull request #1242 from lightpanda-io/nikneym/element-apis
add `insertAdjacentHTML` and other variants
2025-12-09 06:52:48 +08:00
Karl Seguin
97e920b68f Merge pull request #1252 from lightpanda-io/ignore-comment
improve a little bit Element.innerText
2025-12-09 06:49:55 +08:00
Pierre Tachoire
3538c77b78 innerText: ignore CDATA section 2025-12-08 17:45:53 +01:00
Halil Durak
4bc4b2aeac Merge pull request #1245 from lightpanda-io/nikneym/performance-api-changes
Performance API changes
2025-12-08 17:59:21 +03:00
Pierre Tachoire
38030c7d21 improve Element.innerText formatting 2025-12-08 15:56:18 +01:00
Pierre Tachoire
e74d45d6c2 implement Script.innerText 2025-12-08 15:54:19 +01:00
Karl Seguin
0479813494 add CDATASection 2025-12-08 22:09:15 +08:00
Karl Seguin
ef3ba13979 Add keys/values/entries/forEach/toString to DOMTokenList 2025-12-08 21:31:58 +08:00
Halil Durak
f82cfca2ee support overloads of Performance#measure
`init1` => `init`

support `navigationStart` in `getMarkTime`

Turns out this is just an alias to 0.

prefer `self` instead of `page.window._performance`
2025-12-08 16:26:49 +03:00
Karl Seguin
4b204265c9 handle error in normal scripts 2025-12-08 19:01:59 +08:00
Karl Seguin
fbb37633f0 Add start/cancel/fill/strategy support for ReadableStream 2025-12-08 18:24:15 +08:00
Karl Seguin
09328aeb5a Merge pull request #1248 from lightpanda-io/ignore-comment
textContent and innerText adjustements
2025-12-08 17:58:05 +08:00
Pierre Tachoire
5284d75cb7 use CData.render for innerText 2025-12-08 09:55:31 +01:00
Pierre Tachoire
aac35ae868 implement CData.render 2025-12-08 09:41:06 +01:00
Karl Seguin
65751a69ae document.cookie 2025-12-08 16:28:31 +08:00
Karl Seguin
121c49e9c3 Remove std.Uri from cookies
Everything now works on a [:0]const u8, with browser/URL.zig for parsing
2025-12-08 16:23:19 +08:00
Karl Seguin
0beae3b1a6 Various legacy document tests
document.embeds, document.plugins, document.anchor, document.getElementsByName

getElementsByClassName support for multiple class names

various document getters
2025-12-08 14:22:24 +08:00
Arjun Komath
57ce4e16a9 feat: support listening on ipv6 2025-12-08 09:08:57 +08:00
Karl Seguin
9370e298d2 improve HTMLOption and HTMLOptionCollection 2025-12-08 09:07:56 +08:00
Pierre Tachoire
240e8b3502 use a better comparison to detect comment 2025-12-07 09:52:59 +01:00
Karl Seguin
eecadb3962 Merge pull request #1249 from lightpanda-io/comment-format
fix comment formatting
2025-12-07 07:10:38 +08:00
Pierre Tachoire
08d7f544dd fix comment formatting 2025-12-06 19:15:24 +01:00
Pierre Tachoire
a673eb89b6 element: innerText which must return rendered text 2025-12-06 19:10:16 +01:00
Pierre Tachoire
f5d3dede6b node: textContent must ignore comments for elements 2025-12-06 19:10:16 +01:00
Karl Seguin
e41d53019f CompositionEvent 2025-12-05 18:18:54 +08:00
Karl Seguin
637a105e5d getRootNode composed support 2025-12-05 18:11:45 +08:00
Karl Seguin
8e16c587c8 encode property as u32 whenever possible 2025-12-05 18:02:27 +08:00
Karl Seguin
1cde0bb8b8 preserve casing (tags/attributes) for SVG/XML/MathML namespace 2025-12-05 17:53:40 +08:00
Karl Seguin
61a1a2564e Fix typos
Encode unicode nonbreaking space
2025-12-05 17:48:49 +08:00
Karl Seguin
dd3781a1ea 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.
2025-12-05 16:09:00 +08:00
Karl Seguin
ff9f9bae1d fetch with body 2025-12-04 16:10:56 +08:00
Karl Seguin
aa3a402f70 Link get/set rel
Include stack trace on console.error

Don't unnecessarily copy request header on fetch
2025-12-04 15:38:47 +08:00
Karl Seguin
c9882e10a4 Properly handle insertion of DocumentFragment
Add various CData methods

XHR and Fetch request headers

Animation mocks
2025-12-04 14:40:22 +08:00
Karl Seguin
7cb06f3e58 MediaError and :scope pseudoclass 2025-12-03 22:30:08 +08:00
Karl Seguin
60c1f19581 add TextTrackCue and VTTCue (for reddit) 2025-12-03 20:04:07 +08:00
Halil Durak
b6420f75e2 add insertAdjacentText test 2025-12-03 13:59:37 +03:00
Halil Durak
45e74d3336 add insertAdjacentElement and insertAdjacentHTML tests 2025-12-03 13:59:37 +03:00
Halil Durak
dc040dfc37 add insertAdjacentElement and insertAdjacentText 2025-12-03 13:59:37 +03:00
Halil Durak
9071d98cbe port insertAdjacentHTML 2025-12-03 13:59:33 +03:00
Karl Seguin
74ffc273ef Add stack & line number to script eval failure 2025-12-03 18:53:25 +08:00
Karl Seguin
2a4cbbe569 Performance.measure 2025-12-03 18:24:28 +08:00
Karl Seguin
63eeadad1d Fix comment dump, improve dump of shadowroot and slots 2025-12-03 16:10:11 +08:00
Karl Seguin
2de0d4bc48 Header case insensitive 2025-12-03 09:59:55 +08:00
Karl Seguin
c0da6994da Element.setInnerText 2025-12-03 08:52:51 +08:00
Karl Seguin
568a4428ba custom element registry 'whenDefine' function 2025-12-02 22:19:58 +08:00
Karl Seguin
4823e0b188 Merge branch 'unknown_property_ignore_list' into zigdom 2025-12-02 16:06:48 +08:00
Karl Seguin
0690dd9550 Merge branch 'performance_mark' into zigdom 2025-12-02 16:06:19 +08:00
Karl Seguin
b5eceb52fb try safer http cleanup on page deinit 2025-12-02 16:05:57 +08:00
Karl Seguin
c90e9c165b add Performance.Mark 2025-12-02 15:13:55 +08:00
Karl Seguin
a61e87c5dd Don't break wait on scheduler callback error
Allow recursive parsing
2025-12-02 13:25:48 +08:00
Karl Seguin
abd3ee9c5d Add ignore list for unkown global property
This is for often-seen globals which we _know_ come from client-side libraries,
e.g. litNonce.
2025-12-02 11:24:26 +08:00
Karl Seguin
3dd61aeb71 css.zig -> CSS.zig 2025-12-02 11:14:06 +08:00
Karl Seguin
6a46a9ba47 HTMLDataElement 2025-12-02 11:08:56 +08:00
Karl Seguin
fd39168106 Range 2025-12-02 10:57:20 +08:00
Karl Seguin
6a48f6df25 Element.hasAttributes 2025-12-02 07:01:14 +08:00
Karl Seguin
e807c9b6be Add XmlSerializer, add Response.type, tweak HTMLTemplate to redirect some calls to its Content (DocumentFragment) 2025-12-02 00:08:57 +08:00
muki
af8970bbb9 Merge pull request #1238 from lightpanda-io/zigdom-chain-allocation
Prototype Chain Allocations
2025-12-01 07:09:38 -08:00
Karl Seguin
07931dd75f Merge pull request #1240 from lightpanda-io/nikneym/html5ever-build-optimize
html5ever: prefer `dev` build only on `Debug` optimization
2025-12-01 16:21:56 +08:00
Karl Seguin
bfa2e6b4dd Merge pull request #1235 from lightpanda-io/fix-ci-install
Cleanup installation instructions
2025-12-01 16:21:22 +08:00
Halil Durak
129b59a43f html5ever: prefer dev build only on Debug optimization 2025-12-01 11:17:59 +03:00
Pierre Tachoire
4b60f56e5f ci: use releaseFast for hmtl5ever release mode 2025-12-01 09:06:52 +01:00
Pierre Tachoire
ee7c38045f zig fmt 2025-12-01 08:59:21 +01:00
Pierre Tachoire
d18253d50b fix import for rename CSS.zig insto css.zig 2025-12-01 08:59:21 +01:00
Pierre Tachoire
c9b9ef9934 ci: build html5ever typo 2025-12-01 08:59:21 +01:00
Pierre Tachoire
f968db63e9 ci: use setup-zig v2.0.5 2025-12-01 08:59:20 +01:00
Pierre Tachoire
92572c977b update zig-v8 version 2025-12-01 08:59:20 +01:00
Karl Seguin
493c5b41f8 Merge pull request #1232 from lightpanda-io/ns_prefix
element.prefix
2025-12-01 15:56:23 +08:00
Karl Seguin
92ae2c46b6 ReadableStream 2025-12-01 15:16:33 +08:00
Karl Seguin
613428c54c 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).
2025-11-30 12:48:15 +08:00
Pierre Tachoire
bde8b64ba3 update html5ever instructions 2025-11-29 15:20:28 +01:00
Pierre Tachoire
e74a286d70 ci: add install-html5ever-dev 2025-11-29 15:20:28 +01:00
Pierre Tachoire
1e090f9d30 add html5ever install method 2025-11-29 15:20:28 +01:00
Pierre Tachoire
a1064a54cc cleanup README 2025-11-29 15:20:27 +01:00
Pierre Tachoire
dbd500cab9 update docker file 2025-11-29 15:20:27 +01:00
Pierre Tachoire
0bc0a38704 ci: update installation workflow 2025-11-29 15:20:26 +01:00
Karl Seguin
9f587ab24b MessageChannel and MessagePort 2025-11-28 22:11:59 +08:00
Karl Seguin
8858f889b4 Window.scrollX/Y, postMessage, more custom element edge cases 2025-11-28 18:01:50 +08:00
Karl Seguin
833a33678c call AttributeChangedCallback on upgrade 2025-11-28 13:04:42 +08:00
Muki Kiboigo
34c10e1e48 fix svgElement + allow base tags 2025-11-27 13:10:35 -08:00
Muki Kiboigo
8ce8c7a0f3 use _prototype_root decl everywhere 2025-11-27 12:55:48 -08:00
Karl Seguin
94bcb30f11 fetch response headers 2025-11-27 18:54:11 +08:00
Karl Seguin
819424fd3b Support Image constructor (i.e. new Image(..)) 2025-11-27 18:16:03 +08:00
Karl Seguin
f25b8fc7b0 Event.composedPath and adjusted target when crossing shadowroot boundary 2025-11-27 16:57:33 +08:00
Karl Seguin
0d57356c11 Response constructor, window.CSS 2025-11-27 15:13:16 +08:00
Karl Seguin
8775564e04 merge module loading tweaks that were made to main 2025-11-27 10:53:31 +08:00
Muki Kiboigo
15dff342a6 shrink EventTarget back to 16 2025-11-26 12:09:09 -08:00
Muki Kiboigo
45c7184fde use nullable slice for tracking chain allocations 2025-11-26 11:14:30 -08:00
Muki Kiboigo
2ddaa351ab use stream for logging stats 2025-11-26 11:05:20 -08:00
Muki Kiboigo
afe9ee5367 fix freeing with new combined chains 2025-11-26 11:05:20 -08:00
Muki Kiboigo
8348f2dcc8 fix slot alignment in slab chunks 2025-11-26 11:05:20 -08:00
Muki Kiboigo
63f489d39f initial with full chain allocations 2025-11-26 11:05:17 -08:00
muki
8bbf57c199 Merge pull request #1233 from lightpanda-io/zigdom-factory-allocators
`zigdom` Factory Slab Allocator
2025-11-26 08:45:40 -08:00
Karl Seguin
67f63a6bb3 improve parsed (i.e. static) custom element callbacks 2025-11-26 19:43:22 +08:00
Karl Seguin
18b51de696 Merge pull request #1234 from lightpanda-io/nikneym/html5ever-build-changes
Add a build step for `html5ever` in `build.zig`
2025-11-26 19:29:52 +08:00
Halil Durak
d23eacbd37 update .gitignore
LSPs seem to generate the `target` directory when navigating these files through editor.
2025-11-26 14:28:18 +03:00
Halil Durak
444ae00129 mv vendor/html5ever src/html5ever 2025-11-26 14:27:28 +03:00
Halil Durak
6280232e91 add a build step for html5ever in build.zig 2025-11-26 14:20:39 +03:00
Halil Durak
23e3a1d012 move html5ever/ under vendor/ 2025-11-26 14:20:39 +03:00
Karl Seguin
71af78caea adoptNode and importNode 2025-11-26 07:46:24 +08:00
Karl Seguin
e1d9732a60 PerformanceObserver.supportedEntryTypes 2025-11-26 07:42:19 +08:00
Muki Kiboigo
058f86ec5f new exponential SlabAllocator 2025-11-25 13:40:51 -08:00
Muki Kiboigo
0da87e1d5e add slab statistics 2025-11-25 12:13:13 -08:00
Karl Seguin
be0a808f01 Add HTMLSlotElement, PerformanceObserver and Script get/set type 2025-11-25 19:50:53 +08:00
Pierre Tachoire
a0fa232a3a element: upper case only the suffix part of the tagname 2025-11-25 12:13:23 +01:00
Pierre Tachoire
4a4602137b element: add prefix and localName accessors 2025-11-25 11:46:54 +01:00
Karl Seguin
6d6f1340af window.screen 2025-11-25 15:58:34 +08:00
Karl Seguin
35a728e69f explicitly run microtasks 2025-11-25 15:54:25 +08:00
Karl Seguin
218d08b1f6 add some skeleton implementations for various CSS WebAPIs 2025-11-25 13:00:32 +08:00
Muki Kiboigo
219245be95 standardize slab testing names 2025-11-24 20:36:15 -08:00
Muki Kiboigo
aa1742db63 use SlabAllocator 2025-11-24 13:14:45 -08:00
Karl Seguin
e336c67857 various small api fixes/tweaks 2025-11-24 20:12:43 +08:00
Karl Seguin
871fd46c89 fix 0-size structs all having the same identity (the same pointer 2025-11-24 15:11:16 +08:00
Karl Seguin
f536f16926 Correct exception on custom element re-definition 2025-11-22 23:04:17 +08:00
Karl Seguin
d3c00cdd52 Link get/set href 2025-11-22 22:56:58 +08:00
Karl Seguin
6b990f8f12 CustomEvent and document.createEvent 2025-11-22 12:33:35 +08:00
Karl Seguin
3c010f0e73 tweak custom element callbacks 2025-11-22 12:25:12 +08:00
Karl Seguin
357df22fab more pseudoclass support 2025-11-21 22:23:34 +08:00
Karl Seguin
470f5b5029 Headers and improved Request 2025-11-21 20:25:57 +08:00
Karl Seguin
216b1664bd :checked pseudoclass 2025-11-21 20:25:57 +08:00
Karl Seguin
cbe2124387 make crypto callable from the window 2025-11-21 20:25:56 +08:00
Karl Seguin
11934233a0 Merge pull request #1229 from lightpanda-io/nikneym/blob-zigdom
Port `Blob` to zigdom
2025-11-21 20:09:11 +08:00
Halil Durak
de9a0c0166 bring back import for ResizeObserver
No idea how I removed this single line while rebasing...
2025-11-21 14:59:29 +03:00
Halil Durak
5c9ff9d1a2 fix Blob#slice return type 2025-11-21 14:55:05 +03:00
Halil Durak
0142520bb8 change how Blob and File initialized 2025-11-21 14:55:05 +03:00
Halil Durak
b4f9f968f6 add Blob ancestor initializer to Factory 2025-11-21 14:55:05 +03:00
Halil Durak
9a7bafb02c Blob is in another castle 2025-11-21 14:55:05 +03:00
Halil Durak
3e44d5bfdf move Blob out of files/ + provide subclasses of Blob in _type 2025-11-21 14:54:36 +03:00
Halil Durak
f4d58c8823 prefer get prefix in getter accessors 2025-11-21 14:54:36 +03:00
Halil Durak
4d192f5930 port File API tests 2025-11-21 14:54:36 +03:00
Halil Durak
20cbf99cdf port Blob functions 2025-11-21 14:54:35 +03:00
Halil Durak
6784388a42 initial Blob support on zigdom 2025-11-21 14:54:35 +03:00
Karl Seguin
b504a79bf7 dummy ResizeObserver 2025-11-21 19:03:11 +08:00
Karl Seguin
1b9b49f045 customElements.upgrade 2025-11-21 15:38:41 +08:00
Karl Seguin
095413c6c5 Element.toggleAttribute and InteresectionObserver init param overload 2025-11-21 14:35:52 +08:00
Karl Seguin
b9486e8935 add HTMLDialogElement 2025-11-21 08:55:39 +08:00
Karl Seguin
302b9f9dd7 micro-optimize pseudoclass parsing, add :modal 2025-11-20 22:01:14 +08:00
Karl Seguin
57aa267f24 iframe.contentWindow 2025-11-20 21:46:28 +08:00
Karl Seguin
ce351afb9a fix build 2025-11-20 21:32:50 +08:00
Karl Seguin
7b513bd29d support long svg element types 2025-11-20 20:00:20 +08:00
Karl Seguin
0e65bfc78b Merge pull request #1225 from lightpanda-io/nikneym/rework-types-zigdom
JS API table changes for zigdom
2025-11-20 19:42:17 +08:00
Karl Seguin
afaf105cb0 ShadowRoot 2025-11-20 19:41:14 +08:00
Halil Durak
629297e0c2 uncomment the assertion
Forgot to revert this...
2025-11-20 14:22:32 +03:00
Halil Durak
1aca22f219 JS API table changes for zigdom
Applies the `rework-types` changes to zigdom branch.
2025-11-20 13:49:36 +03:00
Karl Seguin
bd3da38fc8 add native custom elements 2025-11-19 22:50:52 +08:00
Karl Seguin
991c2c18de More MutationObserver options, Performance API 2025-11-18 20:46:17 +08:00
Karl Seguin
54a2e7650a MutationObserver and IntersectionObserver 2025-11-18 11:54:14 +08:00
Karl Seguin
5819cfb438 Merge pull request #1211 from lightpanda-io/zigdom-nix
`zigdom` building on NixOS
2025-11-17 23:30:48 +08:00
Muki Kiboigo
38ca58d71e use llvm on tests 2025-11-17 07:25:44 -08:00
Muki Kiboigo
c1c0edab9f fix curl dependency issue with zlib 2025-11-17 07:22:40 -08:00
Muki Kiboigo
8670938397 add rust support into nix flake 2025-11-17 07:22:37 -08:00
Karl Seguin
83b552780e enhance Anchor API 2025-11-17 23:19:32 +08:00
Karl Seguin
b8cc74f377 allow filtering legacy tests, fix location tests, improve URLSearchParams 2025-11-17 21:41:24 +08:00
Karl Seguin
c3ba39c80f add reparent_children html5ever callback 2025-11-16 07:53:55 +08:00
Karl Seguin
ff3a9c51f3 add remove_from_parent html5ever callback 2025-11-16 07:40:07 +08:00
Karl Seguin
19dfea7762 Work on HTMLTemplateElement
Implement Html5ever get_template_contents and add_attrs_if_missing callbacks
2025-11-15 22:04:39 +08:00
Karl Seguin
c311828217 add add_attrs_if_missing callback 2025-11-14 23:33:31 +08:00
Karl Seguin
5ae74d6924 improve form element support 2025-11-14 17:56:09 +08:00
Karl Seguin
04f719c33c wpt runner 2025-11-14 16:14:12 +08:00
Karl Seguin
7ab88e9a71 add legacy tests, optimize empty types 2025-11-14 15:55:02 +08:00
Karl Seguin
1164da5e7a copyright notices 2025-11-14 10:52:43 +08:00
Karl Seguin
6742646e89 DOMParser 2025-11-13 20:57:17 +08:00
Karl Seguin
6cf01631ad Document.activeElement, focus and blur 2025-11-13 20:37:00 +08:00
Karl Seguin
7a5cade510 remove 16 bytes from Element 2025-11-13 20:30:02 +08:00
Karl Seguin
c5a1d8a8bd Element.checkVisibility and Element.checkVisibility 2025-11-13 20:18:34 +08:00
Karl Seguin
32bad5f8bb Element.matches, Element.hasAttributes and DOMStringMap (Element.dataset) 2025-11-13 20:09:38 +08:00
Karl Seguin
5ec5647395 Merge ScriptManager/Module loading changes
Get tests passing.
2025-11-13 17:21:08 +08:00
Karl Seguin
4e9f7c729d Intl and IFrame skeleton 2025-11-03 08:15:15 +08:00
Karl Seguin
4c0437b3fb History placeholder 2025-11-02 11:49:35 +08:00
Karl Seguin
de71b97b1f optional listener, or object listener 2025-11-02 00:17:45 +08:00
Karl Seguin
21d008c6c2 class_index => class_id 2025-11-01 20:37:45 +08:00
Karl Seguin
9138a3c881 MediaQueryLis dummy 2025-11-01 20:35:06 +08:00
Karl Seguin
8b3f36c1f8 url/location host getter 2025-11-01 20:25:41 +08:00
Karl Seguin
d397d75aca DOMImplementation and DocumentType skeletons 2025-11-01 14:02:18 +08:00
Karl Seguin
618b28a292 add FormData and base KeyValueList 2025-10-31 22:25:19 +08:00
Karl Seguin
c966211481 HTMLAllCollection 2025-10-30 11:30:06 +08:00
Karl Seguin
5ae1190ddd HTMLDocument 2025-10-29 22:23:05 +08:00
Karl Seguin
fb9cce747d Scripts now properly block rendering
Re-enabled CDP tests

Fixed more tests
2025-10-29 16:37:11 +08:00
Karl Seguin
1a04ebce35 fix Node.contains 2025-10-28 19:12:47 +08:00
Karl Seguin
59bbfc4e06 fix casing 2025-10-28 19:07:58 +08:00
Karl Seguin
d3973172e8 re-enable minimum viable CDP server 2025-10-28 18:56:03 +08:00
Karl Seguin
cdd31353c5 get fetch campire working 2025-10-28 11:24:29 +08:00
Karl Seguin
b047cb6dc1 remove libdom 2025-10-27 22:14:59 +08:00
Karl Seguin
c52dce1c48 Merge pull request #1154 from lightpanda-io/module_evalute_error_handling
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Handle (log) module evaluation errors directly
2025-10-16 19:26:14 +08:00
Karl Seguin
0b4a1b4a1b Handle (log) module evaluation errors directly
Some module evluation errors aren't handled by the normal TryCatch mechanism.
Instead, the exception needs to be retrieved directly from the module.
2025-10-16 15:10:30 +08:00
Karl Seguin
cc0c1bcf3a Merge pull request #1153 from lightpanda-io/normalized_specifier_lifetime
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix a potential segfault on log message for failing to load module
2025-10-16 15:01:50 +08:00
Karl Seguin
55746f1a1d log the normalized specifier now that we've extended its lifetime to the page.arena 2025-10-16 14:34:07 +08:00
Karl Seguin
7bb8581a95 Fix referrer in log (was printing using the src instead :/) 2025-10-16 14:31:09 +08:00
Karl Seguin
521c0f8460 Fix a potential segfault on log message for failing to load module
Using the `call_arena` here is unsafe in the case of a failure. It's possible
for the call_arena to be reset during module processing, making the log crash.

The issue is that the lifetime of a URL is often conditional. If the stitched
URL has already been seen (i.e. is in the module_cache), then it can be short-
lived. EXCEPT, URL.stitch might require an allocation..and then you start to
think, well, if URL.stitch is going to allocate anyways...If we stitch with
the `page.arena`, and end up not needing a long lifetime, we've wasted memory.
If we stitch with `page.call_arena` and end up needing a long lifetime, we need
to dupe.

It's a bit messy, and I'd like to take a stab at improving it after:
https://github.com/lightpanda-io/browser/pull/1127.

I'm thinking that we need a URL intern pool. HashMap with a composite key of
base + path -> resolved. Then all URLs are resolved using the page.arena, but
we don't have any duplicates, so it isn't wasteful.
2025-10-16 14:15:38 +08:00
Karl Seguin
4bfe3b6fe1 Merge pull request #1151 from lightpanda-io/unicode_nbsp_encoding
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Encode UTF8 non breaking space (194, 160) as &nbsp; - same as chrome
2025-10-15 18:28:45 +08:00
Karl Seguin
b610aa1c0c Encode UTF8 non breakingspace (194, 160) as &nbsp; - same as chrome 2025-10-15 17:34:23 +08:00
Karl Seguin
73da04bea2 Merge pull request #1150 from lightpanda-io/isdone-async
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
isDone must be run after script's deinit
2025-10-15 15:58:42 +08:00
Karl Seguin
18c851e53f Merge pull request #1149 from lightpanda-io/iterators_and_walker_fix
Improve correctness of NodeIterator and Treewalker
2025-10-15 15:58:12 +08:00
Pierre Tachoire
41f4533bc0 isDone must be run after script's deinit 2025-10-15 09:50:17 +02:00
Karl Seguin
4db8a967b6 update netsurf deps 2025-10-15 14:35:58 +08:00
Karl Seguin
ff70f4e79f Merge pull request #1147 from lightpanda-io/svg_tag_name_test
Add tests for svg tag names
2025-10-15 09:47:07 +08:00
Karl Seguin
c9517aff7d Add tests for svg tag names
Depends on: https://github.com/lightpanda-io/libdom/pull/46
2025-10-15 09:37:56 +08:00
Karl Seguin
3657a49a2c Improve correctness of NodeIterator and Treewalker
In their current implementation, both the NodeIterator and TreeWalker would
skip over ignored nodes. However, while the node itself should have been ignored
its children should still be iterated.

For example, when going over:

```
<div id="container">
  <!-- comment1 -->
  <span>
    <!-- comment2 -->
  </span>
</div>
```

With `SHOW_COMMENT`, the previous version would completely skip over `container`
and its children. Now the code still won't emit the `container` div itself,
it will still iterate through its children (and thus emit the two comments).

This change relates to ongoing react compatibility.
2025-10-15 09:23:54 +08:00
Karl Seguin
71e7aa5262 Merge pull request #1146 from lightpanda-io/test_normalized_text_nodes
add a test for the changes to parsing adjascent text ndoes
2025-10-15 08:13:52 +08:00
Karl Seguin
2e435f5d4e Merge pull request #1145 from lightpanda-io/page_events
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Fire page lifecycle events when all scripts are either inline or async
2025-10-14 19:48:59 +08:00
Karl Seguin
859b03c4a6 update libdom and libhubbub 2025-10-14 19:46:21 +08:00
Karl Seguin
ee8786444f add another test 2025-10-14 13:48:23 +08:00
Karl Seguin
d87d782fd5 Merge pull request #1137 from lightpanda-io/profiler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Expose v8 CpuProfiler + add fast properties for some window properties
2025-10-14 05:45:31 +08:00
Karl Seguin
afac4fc37f add a test for the changes to parsing adjascent text ndoes 2025-10-14 00:23:35 +08:00
Karl Seguin
de83521e08 Fire page lifecycle events when all scripts are either inline or async
This is how, for example, scripts on lightpanda.io are. Though fixing this
doesn't really change anything, it's good to make sure these events are firing
correctly.
2025-10-13 21:53:58 +08:00
Karl Seguin
99f8fe1592 Merge pull request #1139 from lightpanda-io/inspector-deinit
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
cdp: drain microtasks before inspector deinit
2025-10-11 08:14:37 +08:00
Pierre Tachoire
02c092a122 Merge pull request #1140 from lightpanda-io/invalid-errdefer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
remove invalid errdefer
2025-10-10 18:54:16 +02:00
Pierre Tachoire
70ca74747f remove invalid errdefer 2025-10-10 18:09:57 +02:00
Pierre Tachoire
594d754022 cdp: drain microtasks before inspector deinit 2025-10-10 17:43:08 +02:00
Karl Seguin
c381e4153d Expose v8 CpuProfiler + add fast properties for some window properties
First, this exposes the v8 Profiler. Right now it's just a commented-out block
in `fetch` and meant for internal debugging.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/105

Use postAttach on Window to attach "static" properties. This comes from
profiling (lightpanda.io) and seeing window.get_self called tens of thousands
of times.
2025-10-10 19:51:29 +08:00
Halil Durak
e761c7e8f4 Merge pull request #1115 from lightpanda-io/nikneym/url-changes
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Small URL & Location changes
2025-10-10 10:54:47 +03:00
Halil Durak
b8d4e3ac50 change after rebase 2025-10-10 10:43:04 +03:00
Halil Durak
4c2b95d00b always prefer navigateFromWebAPI when navigating from a web API 2025-10-10 10:40:11 +03:00
nikneym
cea4f052ba location: add href setter
* `page.navigateFromWebAPI` seem to be not working while testing; `page.navigate` is preferred instead.
2025-10-10 10:40:11 +03:00
nikneym
9b4ea7a040 add an invalid url test
* this test should not pass; it's related to implementation of `std.Uri` though.
2025-10-10 10:40:10 +03:00
nikneym
26c2b258b4 get_protocol: don't allocate for protocol string 2025-10-10 10:40:04 +03:00
Halil Durak
27c9e18535 Merge pull request #1134 from lightpanda-io/nikneym/default-location
Location: prefer `about:blank` when not navigated yet
2025-10-10 10:33:36 +03:00
Pierre Tachoire
b53c2bfa0c Merge pull request #1135 from lightpanda-io/importmap
Importmap support
2025-10-10 09:33:23 +02:00
Pierre Tachoire
80605633c4 update wpt 2025-10-10 08:46:06 +02:00
Pierre Tachoire
acf06fdd8f Resolve importmap against page's url
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2025-10-10 08:34:56 +02:00
Pierre Tachoire
58cc5b4684 typo fix 2025-10-10 08:02:45 +02:00
Karl Seguin
c502bd901e Merge pull request #1136 from lightpanda-io/update_libdom
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Update libdom
2025-10-10 13:15:11 +08:00
Karl Seguin
55027747fd Update libdom
Apply case-preservation on all SVG elements.
2025-10-10 12:46:27 +08:00
Karl Seguin
f6d77afe2e Merge pull request #1130 from lightpanda-io/intersection_observer
Rework IntersectionObserver
2025-10-10 11:10:08 +08:00
Pierre Tachoire
cd9466dafa free importmap on reset and don't retain capacity 2025-10-09 16:30:29 +02:00
Pierre Tachoire
4bf79e4bc9 add importmap support 2025-10-09 16:09:25 +02:00
Pierre Tachoire
7afecf0f85 move mod specifier resolution js/context => script manager 2025-10-09 16:09:24 +02:00
Halil Durak
0b38b7d473 location: prefer about:blank when not navigated yet 2025-10-09 16:55:05 +03:00
Karl Seguin
1b462da4aa Merge pull request #1133 from lightpanda-io/nikneym/cookie-validation
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add a fast path for validating cookie strings
2025-10-09 20:25:52 +08:00
Halil Durak
07948304b2 fix misleading comment
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2025-10-09 14:00:39 +03:00
Halil Durak
0634acdac4 add a fast path for validating cookie strings
This prefers `suggestVectorLength` in order to pick a vector size; for cookie strings shorter than, say 64, this might cause it to fallback to slow path on architectures that support larger vector sizes (like AVX-512). We may also add checks for smaller vector sizes if desired in the future.
2025-10-09 12:03:14 +03:00
Karl Seguin
75e0637d2d Ensure page background tasks are re-registered on reset 2025-10-09 16:29:09 +08:00
Karl Seguin
852c30b2e5 Rework IntersectionObserver
1 - Always fire the callback on the next tick. This is probably the most
important change, as frameworks like React don't react well if the callback is
fired immediately (they expect to continue processing the page in its current
state, not in the mutated state from the callback)

2 - Always fire the callback for observed elements with a parent, whether or
not those intersect or are connected. From MDN, the callback is fired
"The first time the observer is initially asked to watch a target element."

3 - Add a mutation observer so that if a node is added to the root (or removed)
the callback is fired. This, I think, is the best we can currently do for
"intersection".
2025-10-09 14:17:03 +08:00
Karl Seguin
dc85c6552a Merge pull request #1132 from lightpanda-io/reduce_http_tick_blocking
Remove potential processing blocking with CDP
2025-10-09 14:14:05 +08:00
Karl Seguin
76e8506022 Remove potential processing blocking with CDP
When using CDP, we poll the HTTP clients along with the CDP socket. Because this
polling can be long, we first process any pending message. This can end up
processing _all_ messages, in which case the poll will block for a long time.

This change makes it so that when the initial processing processes 1+ message,
we do not poll, but rather return. This allows the page lifecycle to be
processed normally (and not just blocking on poll, waiting for the CDP client
to send data).
2025-10-09 13:18:47 +08:00
Karl Seguin
2d6e2551f6 Merge pull request #1131 from lightpanda-io/microtask-queue-drain
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
drain micro task queue before reset ExecutionWorld and page
2025-10-09 08:36:26 +08:00
Pierre Tachoire
080b1d9a7c drain micro task queue before reset ExecutionWorld and page 2025-10-08 13:55:17 +02:00
Karl Seguin
fe008b0966 Merge pull request #1128 from lightpanda-io/console_trace_svg_test
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add console.trace and svg attribute test
2025-10-08 00:25:20 +08:00
Karl Seguin
4ad10d057b Add console.trace and svg attribute test
update to latest libdom
2025-10-07 22:26:38 +08:00
Karl Seguin
a65aa9f312 Merge pull request #1126 from lightpanda-io/add_debug_context
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Attempt to add more context to debug logs.
2025-10-06 17:48:41 +08:00
Karl Seguin
5b43c16f35 Merge pull request #1125 from lightpanda-io/call_arena
Move the call_arena to the page.
2025-10-06 17:22:41 +08:00
Karl Seguin
9cb37dc011 Attempt to add more context to debug logs.
1 - On `unkown global property`, include the stack trace (this might be WAY too
verbose)

2 - On script get, include stack trace (when available)

3 - On script get, include referrer

4 - Stack traces will now include the script name/src when available
2025-10-06 16:56:54 +08:00
Karl Seguin
2ba6737c41 Merge pull request #1119 from lightpanda-io/cdp_log_entry
Emit Log.addEntry
2025-10-06 16:45:48 +08:00
Karl Seguin
33d737f957 Merge pull request #1123 from lightpanda-io/blocking_scripts
Remove the single-blocking-import restrictions
2025-10-06 15:57:29 +08:00
Karl Seguin
381a18a40e Move the call_arena to the page.
The call_arena was previously owned by the js.Context, but it has to exist on
the page, and the page is created before the context, so it's set to undefined
on the page. While this has never caused an issue, there's no reason for the
page not to own this, and the context to simply reference it.

Also, renamed the js.Context.context_arena to simply `arena`, which is more
consistent with other arena names (e.g. page.arena).
2025-10-06 15:52:56 +08:00
Karl Seguin
207f0655dd Merge pull request #1117 from lightpanda-io/cleanup_js
This is the last of the big changes to the js code
2025-10-06 15:33:21 +08:00
Karl Seguin
88d64da257 Merge pull request #1124 from lightpanda-io/brotli
Supports brotli compression
2025-10-06 14:33:25 +08:00
Karl Seguin
cf378dfd6d add brotli include path 2025-10-06 12:39:30 +08:00
Karl Seguin
a3939d9a66 Supports brotli compression
Adds bortli as a submodules, and compiles the decoder, making it available to
libcurl.

Some websites appear to sent brotli encoded responses even though we don't
advertise support for it (e.g. https://movie.douban.com).
2025-10-06 12:30:06 +08:00
Karl Seguin
ef363209a4 Remove the single-blocking-import restrictions
Remove the is_blocking variable (and checks) from the ScriptManager. This is a
holdover to when blocking imports had a dedicated connections, and thus couldn't
be loaded concurrently.

This changes two obvious things (and probably a few subtle ones). The first is
that plain async scripts are now free to be executed as soon as they are
completed. As far as I can tell, this is a safe behavior, is simpler and should
be a bit faster.

More importantly, it allows for chains of imports. normal import (A) -> dynamic
import (B) -> normal import (C). This would previously fail an assertion. The
superficial issue was that dynamic import handling didn't respect the
`is_blocking` flag. But if we did respect it, than module B would never be able
to load module C, which would block module A from ever completing. By removing
the flag, module C will now be properly evaluated, which unblocks module B which
allows module A to unblock.

(1) I believe this is the issue seen here:
    https://github.com/lightpanda-io/browser/issues/1121
2025-10-06 09:48:57 +08:00
Karl Seguin
fe9a10c617 Emit Log.addEntry
Currently, this hooks a single log.Interceptor into the logging framework, but
changing it to take a list shouldn't be too hard. Biggest issue is who will own
it, as we'd need an allocator to maintain a list / lookup (which log doesn't
currently have).

Uses logFmt format, and, for now, always filters out debug messages and a few
particularly verbose scopes.
2025-10-03 17:29:01 +08:00
Karl Seguin
2e734fae57 This is the last of the big changes to the js code
This Pr largely tightens up a lot of the code. 'v8' is no longer imported
outside of js. A number of helper functions have been moved to the js.Context.
For example, js.Function.getName used to call:

```zig
return js.valueToString(allocator, name, self.context.isolate, self.context.v8_context);
```

It now calls:

```zig
return self.context.valueToString(name, .{ .allocator = allocator });
```

Page.main_context has been renamed to `Page.js`. This, in combination with new
promise helpers, turns:

```zig
const resolver = page.main_context.createPromiseResolver();
try resolver.resolve({});
return resolver.promise();
```

into:

```zig
return page.js.resolvePromise({});
```
2025-10-03 15:06:16 +08:00
Karl Seguin
432e3c3a5e Merge pull request #1118 from lightpanda-io/inspector_linking
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Make sure inspector implementation is always exported
2025-10-03 14:10:47 +08:00
Karl Seguin
a4b13a80ce fix sloppiness 2025-10-03 13:50:53 +08:00
Karl Seguin
a6997a7e85 Make sure inspector implementation is always exported 2025-10-03 13:32:03 +08:00
Karl Seguin
a60d06af6b Merge pull request #1114 from lightpanda-io/extract_js_structs_to_files
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Start extract JS structs into their own files
2025-10-03 09:54:21 +08:00
Karl Seguin
dab8012b6a Start extract JS structs into their own files
Renames JsContext -> js.Context, JsObject -> js.Object and JsThis -> js.This
which is more consistent with the other types. The JsObject -> js.Object is
the reason so many files were touched.

This is still a [messy] transition, with more refactoring planned to clean it
up.
2025-10-02 12:48:50 +08:00
Karl Seguin
66f82fd9cc Merge pull request #1109 from lightpanda-io/remove_generic_js
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
Remove the generic nature of Env and most of the JS classes
2025-10-02 10:58:34 +08:00
Karl Seguin
0bff8ba632 Merge pull request #1113 from lightpanda-io/url-stitch-fix
Fix URL `stitch` Issue with parent traversal
2025-10-02 10:21:23 +08:00
Karl Seguin
32226297ab Remove the generic nature of Env and most of the JS classes
Back in the zig-js-runtime days, globals were used for the state and webapi
declarations. This caused problems largely because it was done across
compilation units (using @import("root")...).

The generic Env(S, WebApi) was used to solve these problems, while still making
it work for different States and WebApis.

This change removes the generics and hard-codes the *Page as the state and
only supports our WebApis for the class declarations.

To accommodate this change, the runtime/*tests* have been removed. I don't
consider this a huge loss - whatever behavior these were testing, already
exists in the browser/**/*.zig web api.

As we write more complex/complete WebApis, we're seeing more and more cases
that need to rely on js objects directly (JsObject, Function, Promises, etc...).
The goal is to make these easier to use. Rather than using Env.JsObject, you
now import "js.zig" and use js.JsObject (TODO: rename JsObject to Object).
Everything is just a plain Zig struct, rather than being nested in a generic.

After this change, I plan on:

1 - Renaming the js objects, JsObject -> Object. These should be referenced in
    the webapi as js.Object, js.This, ...

2 - Splitting the code across multiple files (Env.zig, Context.zig,
    Caller.zig, ...)
2025-10-02 10:16:58 +08:00
Karl Seguin
ab18c90b36 Merge pull request #1112 from lightpanda-io/window_scroll
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Improve window scroll
2025-10-02 09:22:39 +08:00
Karl Seguin
27b6fd561a Merge pull request #1104 from lightpanda-io/fetch_wait
Add Session.fetchWait so that 'fetch' mode will follow navigation
2025-10-02 09:22:29 +08:00
Karl Seguin
15b64d5a25 Improve window scroll
scroll alias for scrollTo

add get_scrollX and get_scrollY, along with their aliases: pageXOffset and
pageYOffset. These always return 0, unless scroll or scrollTo are called.
2025-10-01 18:41:56 +08:00
Karl Seguin
08a50a8ada Merge pull request #1110 from lightpanda-io/telemetry_leak
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fixes a 'leak' with telemetry
2025-10-01 17:29:59 +08:00
Karl Seguin
9d172bb29d Fixes a 'leak' with telemetry
This is just something that isn't cleaned up on exit, so it isn't a "leak",
but better to be explicit with the free.
2025-10-01 16:41:20 +08:00
Karl Seguin
c891322129 Merge pull request #1108 from lightpanda-io/wpt_panic_handler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add custom panic handler to printt which file caused a panic
2025-10-01 15:04:24 +08:00
Muki Kiboigo
77434850f7 url traverse down to the root 2025-09-30 22:13:25 -07:00
Karl Seguin
69b65dbd41 Add custom panic handler to printt which file caused a panic 2025-10-01 11:24:41 +08:00
Karl Seguin
c335a545a3 Merge pull request #1107 from lightpanda-io/mutation_observer_improvement
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Use correct 'this' on MutationObserver callback
2025-10-01 08:44:07 +08:00
Karl Seguin
5bcccec610 Merge pull request #1103 from lightpanda-io/text_decode_view
Text decode view
2025-10-01 08:42:54 +08:00
Karl Seguin
20ae9c3a53 fix dep link 2025-09-30 21:41:08 +08:00
Karl Seguin
92ca7c5a4b update zig-v8-form 2025-09-30 19:47:41 +08:00
Karl Seguin
37fa41b4a2 fix buffer ranges 2025-09-30 19:47:41 +08:00
Karl Seguin
298f959e13 Add broken TextDecoder test that should pass 2025-09-30 19:47:26 +08:00
Karl Seguin
1cb431f204 Better support for Uint8Array in ReadableStream
There's always going to be ambiguity between a string and a Uint8Array. We
already had TypedArray(u8) as a discriminator when _returning_ values. But now
the type is also used by mapping JS values to Zig. To support this efficiently
when probing the union, the typed array mapping logic was extracted into its
own function (so that it can be used by the probe).
2025-09-30 19:47:22 +08:00
Karl Seguin
74dc7b278b Merge pull request #1105 from lightpanda-io/fix_bad_window_test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix typo and wrong API in window test
2025-09-30 19:44:10 +08:00
Karl Seguin
b47d8a794c Use correct 'this' on MutationObserver callback
Add support for MutationObserver.disconnect
2025-09-30 19:36:06 +08:00
Halil Durak
eaf845959c Merge pull request #1106 from lightpanda-io/nikneym/window-onload-fix
Don't allow object to be set on `window.onload`
2025-09-30 14:12:01 +03:00
Karl Seguin
651521d346 Merge pull request #1102 from lightpanda-io/readable_stream_uint8array
Better support for Uint8Array in ReadableStream
2025-09-30 19:03:46 +08:00
nikneym
fb37b29671 don't allow object to be set on window.onload 2025-09-30 12:38:08 +03:00
Karl Seguin
2ecf9016ba Better support for Uint8Array in ReadableStream
There's always going to be ambiguity between a string and a Uint8Array. We
already had TypedArray(u8) as a discriminator when _returning_ values. But now
the type is also used by mapping JS values to Zig. To support this efficiently
when probing the union, the typed array mapping logic was extracted into its
own function (so that it can be used by the probe).
2025-09-30 16:32:55 +08:00
Karl Seguin
444b08be32 fix typo and wrong API in window test 2025-09-30 16:28:47 +08:00
Karl Seguin
2b84712eee Add Session.fetchWait so that 'fetch' mode will follow navigation 2025-09-30 13:36:05 +08:00
Karl Seguin
20cb6cdd8b Merge pull request #1091 from lightpanda-io/concurrent_blocking_imports
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Concurrent blocking imports
2025-09-30 12:30:42 +08:00
Karl Seguin
477a5e5338 Merge pull request #1088 from lightpanda-io/nonblocking_dynamic_imports
nonblocking dynamic imports
2025-09-30 12:30:31 +08:00
Karl Seguin
2a151229cb Merge pull request #1101 from lightpanda-io/nikneym/window-onload
Add `window.onload` getter and setter
2025-09-30 09:15:40 +08:00
nikneym
1d50e091c7 add window.onload test 2025-09-29 14:45:47 +03:00
nikneym
c587e380a0 add window.onload getter and setter 2025-09-29 14:45:35 +03:00
Karl Seguin
54f9bfba84 Merge pull request #1099 from lightpanda-io/nikneym/qol-changes
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Small changes
2025-09-29 17:39:32 +08:00
Karl Seguin
489ba131c5 Merge pull request #1097 from lightpanda-io/check_visibility_opts
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
add missing checkVisibility options
2025-09-29 15:18:10 +08:00
Karl Seguin
5eac1a146f Merge pull request #1098 from lightpanda-io/html_collection_indexed_accessor
Replace HTMLCollection postAttach's with indexed/named getter
2025-09-29 15:17:57 +08:00
Karl Seguin
d7ce6bdeff Replace HTMLCollection postAttach's with indexed/named getter
This solves two issues. First, it's more correct, the indexers should be live.
Second, it makes sure that anything with an HTMLCollection prototype, like
HTMLOptionsCollection, also gets access to the index getters.

We could solve the 2nd issue by making `postAttach` work up the prototype
chain, but since postAttach is wrong (not live), I prefer this solution.
2025-09-29 14:03:59 +08:00
Karl Seguin
e88473d090 add missing checkVisibility options 2025-09-29 12:04:11 +08:00
nikneym
b9024ab032 set_innerHTML: simpler iteration 2025-09-26 15:38:23 +03:00
nikneym
98906be0f6 parseData: remove iterator variant 2025-09-26 15:38:22 +03:00
Pierre Tachoire
220775715d Merge pull request #1094 from lightpanda-io/wpt-debug
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
ci: use debug mode for WPT tests
2025-09-26 13:54:17 +02:00
Pierre Tachoire
ecbf52157b ci: use debug mode for WPT tests 2025-09-26 13:33:13 +02:00
Pierre Tachoire
a579977f66 Merge pull request #1086 from lightpanda-io/history
Implement `History` WebAPI.
2025-09-26 12:15:07 +02:00
Karl Seguin
418dc6fdc2 Start downloading all synchronous imports ASAP
This changes how non-async module loading works. In general, module loading
is triggered by a v8 callback. We ask it to process a module (a <script type=
module>) and then for every module that it depends on, we get a callback. This
callback expects the nested v8.Module instance, so we need to load it then and
there (as opposed to dynamic imports, where we only have to return a promise).

Previously, we solved this by issuing a blocking HTTP get in each callback. The
HTTP loop was able to continuing downloading already-queued resources, but if
a module depended on 20 nested modules, we'd issue 20 blocking gets one after
the other.

Once a module is compiled, we can ask v8 for a list of its dependent module. We
can them immediately start to download all of those modules. We then evaluate
the original module, which will trigger our callback. At this point, we still
need to block and wait for the response, but we've already started the download
and it's much faster. Sure, for the first module, we might need to wait the same
amount of time, but for the other 19, chances are by the time the callback
executes, we already have it downloaded and ready.
2025-09-26 15:38:50 +08:00
Karl Seguin
2aa4b03673 try to cleanup persisted references 2025-09-26 15:34:32 +08:00
Karl Seguin
f236a65a79 Merge pull request #1092 from lightpanda-io/nikneym/insert-adjacent-html
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Support `Element#insertAdjacentHTML`
2025-09-26 14:51:08 +08:00
nikneym
f7b08a1160 prefer orelse return instead of orelse unreachable 2025-09-26 09:43:30 +03:00
Karl Seguin
eed10dd1bb Apply suggestions from code review
fix typos

Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-09-26 10:37:31 +08:00
Muki Kiboigo
9992bd0999 clean up history api 2025-09-25 12:33:30 -07:00
nikneym
6912175e7e prefer $ instead of document.querySelector 2025-09-25 19:30:10 +03:00
nikneym
a59c32757e assert that nodes exist 2025-09-25 19:29:44 +03:00
nikneym
2438a0e60b fix comment 2025-09-25 19:17:08 +03:00
nikneym
a850a902ce make sure parent is not Document in beforebegin and afterend 2025-09-25 15:04:26 +03:00
nikneym
b7ba993ba6 improve insertAdjacentHTML test 2025-09-25 14:42:58 +03:00
nikneym
3eb0d57d5b correct element insertation in insertAdjacentHTML
* also DRY since the loop is repeated multiple times.
2025-09-25 14:41:50 +03:00
Karl Seguin
6bf2ff9168 Protect against context changing during module resolution. 2025-09-25 13:39:02 +08:00
Karl Seguin
92226a8d06 Merge pull request #1090 from lightpanda-io/script_data_url_test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add script dataurl test
2025-09-25 10:15:47 +08:00
Karl Seguin
134424dfdc add script dataurl test 2025-09-25 08:18:59 +08:00
Karl Seguin
58ceb66452 Merge pull request #1089 from lightpanda-io/fix-datauri
fix data uri scripts
2025-09-25 08:15:36 +08:00
nikneym
902b8fc789 add insertAdjacentHTML test 2025-09-24 20:26:05 +03:00
nikneym
923491a510 make ref_node of nodeInsertBefore nullable 2025-09-24 20:21:48 +03:00
nikneym
255b45d07b initial insertAdjacentHTML attempt 2025-09-24 20:21:08 +03:00
Pierre Tachoire
8f68b5b289 fix data uri scripts 2025-09-24 17:29:23 +02:00
Karl Seguin
252fd78473 remove duplicate put, add more assertions 2025-09-24 22:44:46 +08:00
Karl Seguin
b692c5db60 nonblocking dynamic imports
Allows dynamic imports to be loading asynchronously. I know reddit isnt the
best example, since it doesn't fully load, but this reduced the load time from
~7.2s to ~4.8s.
2025-09-24 22:28:22 +08:00
Pierre Tachoire
eff7d58f4b Merge pull request #1087 from lightpanda-io/fix-beyboardevent
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
fix pointer parameter into KeyboardEvent contructor
2025-09-24 09:56:47 +02:00
Pierre Tachoire
17e9bdf8e8 fix pointer parameter into MouseEvent contructor 2025-09-24 09:40:24 +02:00
Pierre Tachoire
22d2694b71 fix pointer parameter into KeyboardEvent contructor 2025-09-24 09:29:37 +02:00
Muki Kiboigo
e74d7fa454 add popstate event for History 2025-09-24 00:22:20 -07:00
Muki Kiboigo
464f42a121 add history tests 2025-09-24 00:21:16 -07:00
Muki Kiboigo
05e7079178 functional history WebAPI 2025-09-24 00:21:16 -07:00
Muki Kiboigo
f03fcc9a31 support for returning Env.Value 2025-09-24 00:21:16 -07:00
Muki Kiboigo
c3ad054bb3 add toJson object and fromJson value 2025-09-24 00:21:16 -07:00
Karl Seguin
202e137d77 Merge pull request #1084 from lightpanda-io/slotchange
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Dispatch slotchange event
2025-09-24 09:23:28 +08:00
Karl Seguin
6b35664e37 Merge pull request #1079 from lightpanda-io/dynamic_import_caching
Dynamic import caching
2025-09-24 09:23:16 +08:00
Karl Seguin
1a7dbd56ac Dispatch slotchange event
The first time a `slotchange` event is registered, we setup a SlotChangeMonitor
on the page. This uses a global (ugh) MutationEvent to detect slot changes.

We could improve the perfomance of this by installing a MutationEvent per
custom element, but a global is obviously a lot easier.

Our MutationEvent currently fired _during_ the changes. This is problematic
(in general, but specifically for slotchange). You can image something like:

```
slot.addEventListener('slotchange', () => {
   // do something with slot.assignedNodes()
});
```

But, if we dispatch the `slotchange` during the MutationEvent, assignedNodes
will return old nodes. So, our SlotChangeMonitor uses the page scheduler to
schedule dispatches on the next tick.
2025-09-23 17:41:05 +08:00
Karl Seguin
1a40853aae Merge pull request #1082 from lightpanda-io/response_type
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Set Response.type to basic on same-origin requests
2025-09-23 14:23:16 +08:00
Karl Seguin
6bad2b16e4 Set Response.type to basic on same-origin requests 2025-09-23 11:35:51 +08:00
Karl Seguin
db166b4633 Merge pull request #1081 from lightpanda-io/nikneym/link-rel
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add `rel` property to `HTMLLinkElement`
2025-09-22 22:35:34 +08:00
nikneym
71bc624a74 add a link element test 2025-09-22 16:35:06 +03:00
nikneym
907a941795 add rel setter to HTMLLinkElement 2025-09-22 16:34:37 +03:00
Pierre Tachoire
559783eed7 Merge pull request #1080 from lightpanda-io/bump-netsurf
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
update libdom version
2025-09-22 14:26:24 +02:00
nikneym
68585c8837 add rel getter to HTMLLinkElement 2025-09-22 15:08:07 +03:00
Pierre Tachoire
eccbc9d9b3 update libdom version 2025-09-22 11:19:28 +02:00
Karl Seguin
e7d1d55170 update zig-v8-fork 2025-09-22 15:19:28 +08:00
Karl Seguin
f04754c254 Correct dynamic module loading/caching
Refactors some of the module loading logic. Both normal modules import and
dynamic module import now share more of the same code - they both go through
the slightly modified `module` function.

Dynamic modules now check the cache first, before loading, and when cached,
resolve the correct promise. This can now happen regardless of the module
loading state.

Also tried to replace some page arenas with call arenas and added some basic
tests for both normal and dynamic module loading.
2025-09-22 15:15:00 +08:00
Karl Seguin
a8e5a48b87 Merge pull request #1074 from lightpanda-io/cdp-nodeid
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
cdp: start nodeId from 1 instead of 0
2025-09-20 07:21:20 +08:00
Pierre Tachoire
283a9af406 cdp: start nodeId from 1 instead of 0
chromedp expects the nodeId starts to 1.
A start to 0 make it enter in infinite loop b/c it expects the Go's
default int, ie 0, to be nil from a map to stop the loop.
If the 0 index is set, it will loop...
2025-09-19 17:58:37 +02:00
Karl Seguin
e3896455db Merge pull request #1073 from lightpanda-io/increase_mimalloc_get_rss_buffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Seems 4K isn't always enough
2025-09-19 19:38:28 +08:00
Karl Seguin
5e6d2700a2 Merge pull request #1070 from lightpanda-io/dump_strip_mode
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Replace --noscript with more advanced --strip_mode
2025-09-19 19:25:06 +08:00
Karl Seguin
dfd0dfe0f6 Seems 4K isn't always enough 2025-09-19 19:22:02 +08:00
Pierre Tachoire
e6b9be5020 Merge pull request #1072 from lightpanda-io/assert_corretly_set_exit_when_done
Ensure extra_socket can't happen when exit_when_done == true
2025-09-19 12:20:33 +02:00
Pierre Tachoire
6f7c87516f Merge pull request #1067 from lightpanda-io/more_testing_metrics
Add libdom RSS and v8 total_physical_size to testing --json output
2025-09-19 12:16:47 +02:00
Pierre Tachoire
516a78326d Merge pull request #1066 from lightpanda-io/nikneym/relaxed-post-message
Relaxed `MessagePort.postMessage`
2025-09-19 11:14:35 +02:00
Karl Seguin
853b7f84ef Ensure extra_socket can't happen when exit_when_done == true
exit_when_done is pretty much a sneaky way to get CDP knowledge into the page.
exit_when_done == true means "this isn't a CDP session".

extra_socket is another sneaky weay to get CDP knowledge into the page. When
we get an `extra_socket` message it means "Return control to the CDP server".

Therefore it should be impossible to get an `extra_socket` message (return to
CDP) when `exit_when_done == true` (this isn't a CDP session).
2025-09-19 16:59:36 +08:00
Karl Seguin
b248a2515e Merge pull request #1071 from lightpanda-io/nikneym/element-dir
Add `element.dir` getter & setter
2025-09-19 16:51:32 +08:00
nikneym
6826c42c65 check for correct dir in HTML elements 2025-09-19 11:30:15 +03:00
nikneym
4f041e48a3 make sure dir attribute is parsed if provided 2025-09-19 11:26:53 +03:00
nikneym
ec6800500b add a test for element.dir 2025-09-19 11:11:58 +03:00
nikneym
856d65a8e9 add element.dir getter & setter 2025-09-19 10:48:37 +03:00
Karl Seguin
8a2efde365 Merge pull request #1069 from lightpanda-io/response-gettype
Adds `Response.type`
2025-09-19 15:12:10 +08:00
Karl Seguin
2ddcc6d9e6 Replace --noscript with more advanced --strip_mode
--noscript is deprecated (warning) and automatically maps to --strip_mode js

--strip_mode takes a comma separated list of values. From the help:

- "js" script and link[as=script, rel=preload]
- "ui" includes img, picture, video, css and svg
- "css" includes style and link[rel=stylesheet]
- "full" includes js, ui and css

Maybe this is overkill, but i sometimes find myself looking --dump outputs over
and over again, and removing noise (like HUGE svgs) seems like a small
improvement.
2025-09-19 14:27:53 +08:00
Muki Kiboigo
25962326d2 add support for Response.type 2025-09-18 22:27:51 -07:00
Karl Seguin
bbc2fbf984 Merge pull request #1068 from lightpanda-io/fix_wpt_runner_user_agent
git wpt runner a (not required) user_agent
2025-09-19 13:07:14 +08:00
Karl Seguin
edc53d6de3 git wpt runner a (not required) user_agent 2025-09-19 12:38:40 +08:00
Karl Seguin
47710210bd Add libdom RSS and v8 total_physical_size to testing --json output
https://github.com/lightpanda-io/browser/issues/1057
2025-09-19 10:21:39 +08:00
Pierre Tachoire
823b7f0670 Merge pull request #1064 from lightpanda-io/testing_metrics
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Re-enable test metrics
2025-09-18 18:03:57 +02:00
Pierre Tachoire
f5130ce48f Merge pull request #1061 from lightpanda-io/remove_inline
Remove all inlines
2025-09-18 17:59:35 +02:00
Halil Durak
347524a5b3 Add setImmediate, clearImmediate (#1065) 2025-09-18 17:56:09 +02:00
nikneym
51830f5907 relaxed MessagePort.postMessage 2025-09-18 17:07:12 +03:00
Karl Seguin
346f538c3b Re-enable test metrics
Both the durations and allocations will be _much_ higher with the new htmlRunner
which, for example, does 2 HTTP requests per test (html, testing.js).

https://github.com/lightpanda-io/browser/issues/1057
2025-09-18 19:55:37 +08:00
Karl Seguin
9d2948ff50 Remove all inlines
Following Zig recommendation not to inline except in specific cases, none of
which I think applies to use.

Also, mimalloc.create can't fail (it used to be possible, but that changed a
while ago), so removed its error return.
2025-09-18 19:10:22 +08:00
Karl Seguin
36ce227bf6 Merge pull request #1055 from lightpanda-io/env_string
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Introduces an Env.String for persistent strings
2025-09-18 19:06:46 +08:00
Karl Seguin
024f7ad9ef Merge pull request #1056 from lightpanda-io/DOM_NO_ERR
Convert more DOM_NO_ERR cases to assertions
2025-09-18 19:06:32 +08:00
Karl Seguin
f8425fe614 Merge pull request #1063 from lightpanda-io/remove_jsrunner
Remove JSRunner
2025-09-18 18:46:59 +08:00
Karl Seguin
7802a1b5a4 Merge pull request #1062 from lightpanda-io/fetch_newHeaders
use client.newHeaders
2025-09-18 15:56:35 +08:00
Karl Seguin
17549d8a43 Remove JSRunner
It only had a few fetch tests still using it. But now everything is migrated
to the htmlRunner
2025-09-18 15:50:19 +08:00
Karl Seguin
f6ed706855 use client.newHeaders 2025-09-18 15:46:23 +08:00
Pierre Tachoire
89ef25501b Merge pull request #1060 from lightpanda-io/fetch-ua
fetch: init headers w page's client UA
2025-09-18 09:44:00 +02:00
Pierre Tachoire
4870125e64 fetch: init headers w page's client UA 2025-09-18 09:34:55 +02:00
Pierre Tachoire
2d24e3c7f7 Merge pull request #972 from lightpanda-io/fetch
Fetch + ReadableStream
2025-09-18 09:29:05 +02:00
Karl Seguin
cdb3f46506 Merge pull request #1059 from lightpanda-io/user_agent_suffix
Add --user_agent_suffix argument
2025-09-18 15:06:21 +08:00
Karl Seguin
e225ed9f19 fix for telemetry and one-off requests 2025-09-18 11:40:25 +08:00
Karl Seguin
17bebf4f3a Merge pull request #1058 from lightpanda-io/test_doctype
Give tests <!DOCTYPE html> so they work correct in browser
2025-09-18 11:29:31 +08:00
Karl Seguin
26550129ea Add --user_agent_suffix argument
Allows appending a value (separated by a space) to the existing Lightpanda/X.Y
user agent.
2025-09-18 11:28:27 +08:00
Karl Seguin
66362c2762 Give tests <!DOCTYPE html> so they work correct in browser 2025-09-18 10:53:29 +08:00
Muki Kiboigo
f6f0e141a1 PeristentPromiseResolver with page lifetime 2025-09-17 12:12:10 -07:00
Muki Kiboigo
f22ee54bd8 use fetch logging scope, clean some comments 2025-09-17 08:46:35 -07:00
Muki Kiboigo
2a969f911e stop using destructor callback for fetch 2025-09-17 08:46:29 -07:00
Muki Kiboigo
2a0964f66b htmlRunner for ReadableStream tests, fix ReadableStream enqueue 2025-09-17 08:46:25 -07:00
Muki Kiboigo
c553a2cd38 use Env.PersistentPromiseResolver 2025-09-17 08:46:20 -07:00
Karl Seguin
24330a7491 remove meaningless text from test 2025-09-17 08:46:16 -07:00
Karl Seguin
cd763a7a35 fix arena, add fetch test 2025-09-17 08:46:03 -07:00
Muki Kiboigo
ed11eab0a7 use content length to reserve body size 2025-09-17 08:45:53 -07:00
Muki Kiboigo
a875ce4d68 copy our Request headers into the HTTP client 2025-09-17 08:45:46 -07:00
Muki Kiboigo
969bfb4e53 migrate fetch tests to htmlRunner 2025-09-17 08:45:42 -07:00
Muki Kiboigo
76dae43103 properly handle closed for ReadableStream 2025-09-17 08:45:37 -07:00
Muki Kiboigo
af75ce79ac deinit persistent promise resolver 2025-09-17 08:45:30 -07:00
Muki Kiboigo
fe89c2ff9b simplify cloning of Req/Resp 2025-09-17 08:45:25 -07:00
Muki Kiboigo
bb2595eca5 use call arena for json in Req/Resp 2025-09-17 08:45:20 -07:00
Muki Kiboigo
618fff0191 simplify Headers 2025-09-17 08:45:14 -07:00
Muki Kiboigo
9bbd06ce76 headers iterators should not allocate 2025-09-17 08:45:05 -07:00
Muki Kiboigo
20463a662b use destructor callback for FetchContext 2025-09-17 08:45:00 -07:00
Muki Kiboigo
9251180501 support object as HeadersInit 2025-09-17 08:44:54 -07:00
Muki Kiboigo
2659043afd add logging on fetch error callback 2025-09-17 08:44:47 -07:00
sjorsdonkers
7766892ad2 retain value, avoid str alloc 2025-09-17 08:44:36 -07:00
sjorsdonkers
a7848f43cd avoid explicit memcpy 2025-09-17 08:44:31 -07:00
sjorsdonkers
cf8f76b454 remove length check of fixed size 2025-09-17 08:44:26 -07:00
sjorsdonkers
f68f184c68 jsValueToZig for fixed sized arrays 2025-09-17 08:44:12 -07:00
Muki Kiboigo
463440bce4 implement remaining ReadableStream functionality 2025-09-17 08:43:42 -07:00
Muki Kiboigo
51ee313910 working Header iterators 2025-09-17 08:43:36 -07:00
Muki Kiboigo
744b0bfff7 TypeError when Stream is locked 2025-09-17 08:43:31 -07:00
Muki Kiboigo
949479aa81 cleaning up various Headers routines 2025-09-17 08:43:22 -07:00
Muki Kiboigo
8743841145 use proper Headers in fetch() 2025-09-17 08:43:16 -07:00
Muki Kiboigo
6225cb38ae expand Request/Response interfaces 2025-09-17 08:43:05 -07:00
Muki Kiboigo
8dcba37672 expand Headers interface 2025-09-17 08:42:59 -07:00
Muki Kiboigo
38b922df75 remove debug logging in ReadableStream 2025-09-17 08:42:50 -07:00
Muki Kiboigo
6d884382a1 move fetch() into fetch.zig 2025-09-17 08:42:41 -07:00
Muki Kiboigo
752e75e94b add bodyUsed checks on Request and Response 2025-09-17 08:42:36 -07:00
Muki Kiboigo
5ca41b5e13 more Headers compatibility 2025-09-17 08:42:30 -07:00
Muki Kiboigo
1b3707ad33 add fetch to cdp domain 2025-09-17 08:42:20 -07:00
Muki Kiboigo
c6e82d5af6 add json response method 2025-09-17 08:42:12 -07:00
Muki Kiboigo
814e41122a basic readable stream working 2025-09-17 08:42:07 -07:00
Muki Kiboigo
a133a71eb9 proper fetch method and body setting 2025-09-17 08:41:22 -07:00
Muki Kiboigo
dc2addb0ed fetch callback logging 2025-09-17 08:41:16 -07:00
Muki Kiboigo
f9014bb90c request url as null terminated 2025-09-17 08:41:11 -07:00
Muki Kiboigo
df0b6d5b07 initial fetch in zig 2025-09-17 08:40:32 -07:00
Muki Kiboigo
56c6e8be06 remove polyfill and add req/resp 2025-09-17 08:40:10 -07:00
Pierre Tachoire
b47b8297d6 Merge pull request #1021 from lightpanda-io/patchright
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Patchright compatibility
2025-09-17 16:14:00 +02:00
Pierre Tachoire
5d1e17c598 cdp: use for...else instead of found bool 2025-09-17 14:42:08 +02:00
Pierre Tachoire
94fe34bd10 cdp: multiple isolated worlds 2025-09-17 14:42:08 +02:00
Pierre Tachoire
e68ff62723 cdp: use depth param on DOM.describeNode 2025-09-17 14:42:08 +02:00
Pierre Tachoire
04487b6b91 cdp: allow double isolated world with same world name
In this case we reuse the existing isolated world and isolated context
and we log a warning
2025-09-17 14:42:07 +02:00
Pierre Tachoire
49a27a67bc cdp: send a warning for pierce parameter 2025-09-17 14:42:07 +02:00
Pierre Tachoire
745de2ede2 cdp: add Runtime.getProperties 2025-09-17 14:42:07 +02:00
Pierre Tachoire
82e5698f1d cdp: accept neg depth in describeNode 2025-09-17 14:42:06 +02:00
Pierre Tachoire
c4090851c5 css: accept digit as name start 2025-09-17 14:42:06 +02:00
Pierre Tachoire
9cb4431e89 cdp: add initiator on request will be send 2025-09-17 14:42:06 +02:00
Pierre Tachoire
2221d0cb6f cdp: send the chrome's error on missing node 2025-09-17 14:42:05 +02:00
Pierre Tachoire
5ea97c4910 cdp: add send error options with session id by default 2025-09-17 14:42:05 +02:00
Pierre Tachoire
a40590b4bf cdp: add DOM.getFrameOwner 2025-09-17 14:42:00 +02:00
Karl Seguin
58acb2b821 Convert more DOM_NO_ERR cases to assertions
There is some risk to this change. The first is that I made a mistake. The
other is that one of the APIs that doesn't currently return an error changes
in the future.
2025-09-17 13:37:48 +08:00
Karl Seguin
6b9dc90639 Introduces an Env.String for persistent strings
If a webapi has a []const u8 parameter, then the page.call_arena is used. This
is our favorite arena to use, but if the string value has a lifetime beyond the
call, it then needs to be duped again (using page.arena).

When a webapi has a Env.String parameter, the page.arena will be used directly
to get the value from V8, removing the need for an intermediary dupe in the
call_arena.

This allows HTMLCollections to be streamlined. They no longer need to dupe the
filter (tag name, class name, ...), which means they can no longer fail. It also
means that we no longer need to needlessly dupe the string literals.
2025-09-17 12:12:42 +08:00
Karl Seguin
b7d26cf0d5 Merge pull request #1053 from lightpanda-io/nikneym/create-event-html-events
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
allow `HTMLEvents` in `createEvent`
2025-09-17 00:31:36 +08:00
Pierre Tachoire
59b4033ab2 Merge pull request #1052 from lightpanda-io/fix-auth-interception-overflow
Fix auth interception integer overflow
2025-09-16 16:31:07 +02:00
nikneym
13a7219dbd allow HTMLEvents in createEvent 2025-09-16 17:24:50 +03:00
Pierre Tachoire
eae8a90a89 ci: add request interception through proxy test 2025-09-16 16:24:19 +02:00
Karl Seguin
a87f4abd5f Merge pull request #1050 from lightpanda-io/event_window_bubble
Event window bubble
2025-09-16 18:44:22 +08:00
Karl Seguin
1b73691c69 update libdom dep 2025-09-16 18:21:16 +08:00
Pierre Tachoire
e00066466b http: decrement intercepted on auth abortion 2025-09-16 12:18:49 +02:00
Pierre Tachoire
b87a8ba97d http: increment intercepted counter on auth interception 2025-09-16 12:18:49 +02:00
Karl Seguin
57aa270032 Merge pull request #1048 from lightpanda-io/nikneym/mime-changes
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Mime: charset identification changes
2025-09-16 16:13:46 +08:00
nikneym
90a96fd8a7 set a zero char right after attrib value instead of memset 2025-09-16 10:41:49 +03:00
nikneym
c05470515f double quotes must be first and last argument of slice if provided 2025-09-16 10:40:38 +03:00
Pierre Tachoire
81ed4f3699 Merge pull request #1051 from lightpanda-io/explicit_microtask
Set Isolate Microtask to Explicit
2025-09-16 09:38:33 +02:00
Karl Seguin
c9ac1eab11 Set Isolate Microtask to Explicit
This defaults to Auto, which means it runs when the call stack reaches 0.
It appears that both Node and Deno set this to explicit.

I don't really understand why Auto doesn't work. It says the call stack is the
C++/C callstack, and I don't see what would block the current code from reaching
a depth of 0. Still, we already have explicit calls to performMicrotasksCheckpoint
which ties it holistically with our scheduler, so having it be explicit like
this should...well make it more explicit

This broke a test, but since the tests are being redone in the [fetch PR](https://github.com/lightpanda-io/browser/pull/972) I simply removed the offending one.
2025-09-16 14:52:31 +08:00
Karl Seguin
1ba542fb3b use redispatch, check for stopped 2025-09-16 10:31:37 +08:00
Karl Seguin
4f127c9de3 Bubble events to the Window 2025-09-15 22:24:35 +08:00
Karl Seguin
16656f6c13 Merge pull request #1049 from lightpanda-io/netsurf_event_errors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Remove unnecessary error handling from non-erroring netsurf event fun…
2025-09-15 21:55:18 +08:00
Karl Seguin
0f13e062fe Remove unnecessary error handling from non-erroring netsurf event functions 2025-09-15 21:37:53 +08:00
nikneym
2e68407fbe update Mime tests 2025-09-15 15:15:29 +03:00
nikneym
974f350f27 store charset value directly in Mime 2025-09-15 15:15:08 +03:00
nikneym
27ffea9052 add vectorized parseCharset impl 2025-09-15 11:15:09 +03:00
Pierre Tachoire
9b2b35e8a2 Merge pull request #1047 from lightpanda-io/ci-cache-libiconv
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: cache libiconv
2025-09-15 09:19:46 +02:00
Pierre Tachoire
3b51ca3947 make: download libiconv from GH fork 2025-09-15 08:59:30 +02:00
Pierre Tachoire
62a2d08b53 ci: cache libiconv 2025-09-15 08:43:48 +02:00
Pierre Tachoire
e790bde717 Merge pull request #1046 from lightpanda-io/fork-netsurf
submodule: use GH fork of netsurf buildsystem
2025-09-15 08:23:39 +02:00
Pierre Tachoire
0ab6b15292 submodule: use GH fork of netsurf buildsystem 2025-09-15 08:18:22 +02:00
Karl Seguin
2aeeb14c21 Merge pull request #1043 from lightpanda-io/html_slot_assigned_elements
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add assignedElements to HTMLSlotElement
2025-09-13 10:12:20 +08:00
Karl Seguin
e5e57ab3bd Merge pull request #1044 from lightpanda-io/script_nonce_and_df_host
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add Script get/set nonce
2025-09-12 20:45:50 +08:00
Karl Seguin
f3ce5dcfbd Add Script get/set nonce 2025-09-12 19:19:36 +08:00
Halil Durak
bc341e98fc Merge pull request #1041 from lightpanda-io/nikneym/keyboard-event
KeyboardEvent support
2025-09-12 13:57:39 +03:00
nikneym
80851f4861 don't inline keyboardEventKeyIsSet 2025-09-12 13:39:15 +03:00
nikneym
22b4456bce correct indentation in tests 2025-09-12 13:39:05 +03:00
nikneym
8d67502997 don't expose DOMErr function 2025-09-12 13:38:58 +03:00
nikneym
8f31fd778b add KeyboardEvent tests 2025-09-12 13:38:47 +03:00
nikneym
f79f25bcf4 implement KeyboardEvent properties and methods 2025-09-12 13:38:41 +03:00
nikneym
68e237eec5 add license 2025-09-12 13:38:31 +03:00
nikneym
8895c70c7f make toInterface be aware of KeyboardEvent 2025-09-12 13:38:04 +03:00
nikneym
3964f8649d initial keyboard event 2025-09-12 13:33:30 +03:00
Karl Seguin
b7fb0ef1d3 add assignedElements to HTMLSlotElement 2025-09-12 17:40:29 +08:00
Karl Seguin
66e403c5b4 Merge pull request #1042 from lightpanda-io/textdecoder_decode
Improve TextDecoder.decode
2025-09-12 14:34:14 +08:00
Karl Seguin
0913abe806 Improve TextDecoder.decode
1 - Optional input (why? I don't know, but it's part of the spec and happens)
2 - Optional stream parameter
3 - More test cases
2025-09-12 12:31:28 +08:00
Karl Seguin
6d3065c4c6 Merge pull request #1037 from lightpanda-io/upgrade_v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Upgrade v8
2025-09-12 08:29:20 +08:00
Karl Seguin
9092d1f8eb update v8 deps 2025-09-12 07:55:27 +08:00
Karl Seguin
1bd1f123a3 Upgrade v8
Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/93
2025-09-12 07:53:22 +08:00
Karl Seguin
44c072dcbb Merge pull request #1040 from lightpanda-io/event_timestamp_resolution
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Increase event timeStamp resolution
2025-09-11 21:08:09 +08:00
Karl Seguin
45c59e2990 update libdom 2025-09-11 20:43:32 +08:00
Karl Seguin
75f0cd6e62 fix test 2025-09-11 16:10:06 +08:00
Karl Seguin
80f758018c Increase event timeStamp resolution
Depends on https://github.com/lightpanda-io/libdom/pull/36

The spec says this should be a High Definition timestamp. But browsers avoid
that in order to avoid fingerprinting. By default, FireFox rounds to 2ms (which
is what this PR does).

Previously, the timestamp was seconds, so you'd think: isn't that better? Well,
it's pretty far off the spec and what browsers do, but more importantly, it
crashes our WPT test. If you look at `Event-timestamp-safe-resolution.html`
you'll see that it's trying to find the delta between two timestamps, in an
endless loop (without a loop of many iterations). With second-resolution, it
just takes too long (and crashes..memory).
2025-09-11 15:53:34 +08:00
Karl Seguin
b5e2c62fdd Merge pull request #1039 from lightpanda-io/migrate_some_tests_11
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
migrate more tests to htmlRunner
2025-09-11 14:12:39 +08:00
Karl Seguin
ede35718ae migrate more tests to htmlRunner 2025-09-11 12:07:17 +08:00
Karl Seguin
31fe2807aa Merge pull request #1038 from lightpanda-io/migrate_some_tests_10
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
migrate more tests to htmlRunner
2025-09-11 00:24:17 +08:00
Karl Seguin
f77693d768 migrate more tests to htmlRunner 2025-09-10 20:32:15 +08:00
Pierre Tachoire
96e3c16cca Merge pull request #1036 from lightpanda-io/css-contains
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
CSS: move tests + implement :containsOwn
2025-09-10 13:59:27 +02:00
Karl Seguin
edd41b37f0 Merge pull request #1033 from lightpanda-io/nikneym/custom-event
Support for CustomEvent in document.createEvent
2025-09-10 15:23:34 +08:00
Karl Seguin
139d0038f2 Merge pull request #1035 from lightpanda-io/migrate_some_tests_9
migrate more tests to htmlRunner
2025-09-10 15:21:27 +08:00
Pierre Tachoire
d25fc64d7a css: implement :containsOwn pseudo-selector
:containsOwn is implemented with case sensitive comparison.
2025-09-10 08:55:44 +02:00
nikneym
9c83b268b9 persist the detail if provided 2025-09-10 09:49:31 +03:00
Pierre Tachoire
42092ac16a css: move match_test into selector 2025-09-10 08:41:15 +02:00
Pierre Tachoire
e4860d5bae css: move libdom_test into libdom
To be added by the test_runner as part of used files.
2025-09-10 08:41:14 +02:00
Karl Seguin
a5d9b658fb migrate more tests to htmlRunner 2025-09-10 11:54:03 +08:00
Karl Seguin
f464e89415 Merge pull request #1034 from lightpanda-io/persistent-promise
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Clean Up Persistent Promises in Dynamic Import
2025-09-10 08:39:50 +08:00
Karl Seguin
cdc439c4ef Merge pull request #1026 from lightpanda-io/network_idle_500ms_delay
Send NetworkIdle and NetworkAlmostIdle notifications after 500ms delay
2025-09-10 08:22:34 +08:00
Karl Seguin
746168f9ed Merge pull request #1031 from lightpanda-io/migrate_some_tests_8
migrate more tests to htmlRunner
2025-09-10 08:22:21 +08:00
Karl Seguin
5ad4885102 Merge pull request #1028 from lightpanda-io/wpt_runner_tweak
Try to address WPT running OOM
2025-09-10 08:22:08 +08:00
Muki Kiboigo
7eb53ca2bc deinit persistent in dynamic import 2025-09-09 15:02:26 -07:00
nikneym
10fc056184 createEvent should increase tag count by 1 2025-09-09 21:56:10 +03:00
nikneym
7517937155 add createEvent tests 2025-09-09 21:45:09 +03:00
nikneym
a86fa8cc37 add support for CustomEvent#initCustomEvent 2025-09-09 21:44:51 +03:00
nikneym
e1c765e78a support CustomEvent in createEvent 2025-09-09 21:44:09 +03:00
Karl Seguin
56b08bddd8 migrate more tests to htmlRunner 2025-09-09 20:40:19 +08:00
Karl Seguin
2ed8a1c0ec Merge pull request #1030 from lightpanda-io/update_libdom
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
this was already updated, but subsequent PR (by me) accidentally reve…
2025-09-09 19:53:54 +08:00
Pierre Tachoire
389dff7031 Merge pull request #1029 from lightpanda-io/remove_telemetry_debug_output
Remove a std.debug.print
2025-09-09 13:48:20 +02:00
Karl Seguin
123d69e595 this was already updated, but subsequent PR (by me) accidentally reverted it 2025-09-09 19:44:54 +08:00
Karl Seguin
4ab7fe26fc Merge pull request #1025 from lightpanda-io/migrate_some_tests_7
migrate more tests to htmlRunner
2025-09-09 19:41:56 +08:00
Karl Seguin
0aa1e0200f Merge branch 'wpt' into wpt_runner_tweak 2025-09-09 19:24:36 +08:00
Karl Seguin
575f827958 disable telemetry when running WPT action 2025-09-09 19:23:31 +08:00
Karl Seguin
7136851893 Remove a std.debug.print
Probably added in the Zig 0.15 migration. Sorry.
2025-09-09 19:19:36 +08:00
Karl Seguin
67935b11c9 Try to address WPT running OOM
- Continue to reuse the Browser/Env/Isolate, but no start a new session per test.
- Test http server now properly closes the sendFile fd
- Run WPT in ReleaseMode
- Add --quiet option to WPT and some commented out debug code for dumping v8
  memory stats
2025-09-09 19:15:35 +08:00
Pierre Tachoire
85f60cbc7b Merge pull request #1027 from lightpanda-io/libcurl-readme
add libcurl in the readme
2025-09-09 11:24:55 +02:00
Pierre Tachoire
9c35f8a24e add libcurl in the readme 2025-09-09 11:22:56 +02:00
Karl Seguin
9971de2ccd Send NetworkIdle and NetworkAlmostIdle notifications after 500ms delay
Like Chrome, the NetworkIdle and NetworkAlmostIdle will only be sent if the
condition (no network requests / <= 2 network requests) holds for at least 500ms

Also merged runHighPriority and runLowPriority as they are now always run
together (but we still only block/wait for high priority tasks).
2025-09-09 14:06:03 +08:00
Karl Seguin
1ca8dc0ac0 Merge pull request #1022 from lightpanda-io/slot
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Start working on HTMLSlotElement
2025-09-09 11:52:04 +08:00
Karl Seguin
85d148822e migrate more tests to htmlRunner 2025-09-09 11:48:08 +08:00
Karl Seguin
1e738dcf79 Merge pull request #1023 from lightpanda-io/migrate_some_tests_6
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
migrate more tests to htmlRunner
2025-09-08 20:58:41 +08:00
Karl Seguin
b5ffd8d046 Merge pull request #1024 from lightpanda-io/run_distant_tasks
Ability to run tasks even in the "distant" future.
2025-09-08 20:58:30 +08:00
Karl Seguin
21e354d252 Ability to run tasks even in the "distant" future.
We previously ignored tasks scheduled more than 5 seconds away. These tasks are
now scheduled on the low priority queue. This means that they won't stop a
page.wait for returning, but they'll still [eventually] be run if page.wait is
called multiple times.

Practically, this means that they'll never be run in `fetch` mode, but they
might be run from CDP if the driver waits.

Make queue names consistent, primary => high_priority, secondary => low_priority
(the same names used by the page)
2025-09-08 18:55:48 +08:00
Karl Seguin
15628d9b07 migrate more tests to htmlRunner 2025-09-08 18:40:59 +08:00
Karl Seguin
950182986a Start working on HTMLSlotElement 2025-09-08 17:36:45 +08:00
Pierre Tachoire
bc82023878 Merge pull request #1020 from lightpanda-io/inline_script_ignore_defer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Inline script tags ignore defer/async
2025-09-05 17:44:45 +02:00
Pierre Tachoire
d5363e5993 Merge pull request #1018 from lightpanda-io/fix_screen_event_target_prototype
Fix the Screen and ScreenOrientation prototype
2025-09-05 17:44:09 +02:00
Pierre Tachoire
80adee8558 Merge pull request #1017 from lightpanda-io/fix_async_script_processing
Fix blockingGet during blockingGet
2025-09-05 17:43:40 +02:00
Pierre Tachoire
37fe6a661b Merge pull request #1013 from lightpanda-io/reset_request_method
Reset CURLOPT_CUSTOMREQUEST for each request
2025-09-05 17:43:30 +02:00
Karl Seguin
eb453f471b Inline script tags ignore defer/async
According to MDN, inline script tags should not have defer/async attributes. But
some do. This ignores those attributes for inline script tags.

(Previously, we were only half ignoring them. We were treating them as inline,
but flagging them as deferred or async, which was causing issues with cleanup)

Fixes: https://github.com/lightpanda-io/browser/issues/1014
2025-09-05 23:23:31 +08:00
Karl Seguin
afd278ca4e Fix the Screen and ScreenOrientation prototype 2025-09-05 19:08:07 +08:00
Karl Seguin
ca8877da2d Fix blockingGet during blockingGet
ScriptManager should only ever has one in-flight blockingGet. The is_blocking
flag is used to assert this, as well as enforce it in evaluate(). If is_blocking
is true, evaluate() exits.

This doesn't work for async scripts however, as they aren't executed via
evaluate(), but rather execute directly once complete.

This PR changes the execution behavior of async scripts. They are now only
executed in evaluate() (and thus won't execute when is_blocking == true).
However, unlike normal/deferred scripts, async scripts continue to execute in
their completion order (not their declared order).

Fixes https://github.com/lightpanda-io/browser/issues/1016
2025-09-05 18:17:55 +08:00
Pierre Tachoire
42828c64fb Merge pull request #1012 from lightpanda-io/cdp_detached
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Don't assume that page events means the BrowserContext has a page
2025-09-05 10:19:18 +02:00
Karl Seguin
6600626f4f Reset CURLOPT_CUSTOMREQUEST for each request 2025-09-05 15:45:28 +08:00
Karl Seguin
ac10d5b2a3 Don't assume that page events means the BrowserContext has a page
CDP currently assumes that if we get a page-related notification (like a
request interception, or page lifecycle event), then we must have a session
and page.

But, Target.detachFromTarget can remove the session from the BrowserContext
while still having the page run (I wonder if we should stop the page at this
point??). So, remove these assumptions and make sure we have a page/session
in the handling of page events.
2025-09-05 15:07:30 +08:00
Pierre Tachoire
9f040025e7 Merge pull request #1010 from lightpanda-io/update_transfer_uri_on_redirect
Update the transfer.uri on redirect
2025-09-05 08:35:13 +02:00
Karl Seguin
2522e7fe9c Merge pull request #1011 from lightpanda-io/migrate_some_tests_5
migrate to htmlRunne (plus zig fmt)
2025-09-05 14:16:10 +08:00
Karl Seguin
dd22c55d23 migrate to htmlRunne (plus zig fmt) 2025-09-05 13:52:08 +08:00
Karl Seguin
a6efa9e9b2 Update the transfer.uri on redirect
Ensures that cookies set on the redirect page use the correct host and we don't
incorrectly reject cookies.

https://github.com/lightpanda-io/browser/issues/947
2025-09-05 08:55:36 +08:00
Karl Seguin
5087b8004a Merge pull request #1009 from lightpanda-io/migrate_some_tests_4
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
migrate to htmlRunner
2025-09-04 18:32:17 +08:00
Karl Seguin
d4c40242d0 Merge pull request #1008 from lightpanda-io/network_idle_page_lifecycle
Emit networkIdle and networkAlmostIdle Page.lifecycleEvent
2025-09-04 17:48:02 +08:00
Karl Seguin
5af55f1d5d migrate to htmlRunner 2025-09-04 17:46:42 +08:00
Karl Seguin
55ef0a5e9e fix some spelling in comments 2025-09-04 16:44:00 +08:00
Karl Seguin
5dda86bf4a Emit networkIdle and networkAlmostIdle Page.lifecycleEvent
Most CDP drivers have a mechanism to wait for idle network, or an almost idle
network (sometimes called networkIdle2). These are events the browser must emit.

The page will now emit `networkIdle` when we are reasonably sure there's no more
network activity (this requires some slight changes to request interception,
since, I believe, intercepted requests should be considered).

`networkAlmostIdle` is currently _always_ emitted prior to emitting
`networkIdle`. We should tweak this but I can't, at a glance, think of a great
heuristic for when this should be emitted.
2025-09-04 16:36:29 +08:00
Karl Seguin
d81377b10d Merge pull request #1007 from lightpanda-io/timeout_limit
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Limit serve timeout to 1 week
2025-09-04 16:02:39 +08:00
Karl Seguin
da128f5d49 remove unecessary @intCast 2025-09-04 15:52:08 +08:00
Karl Seguin
6e5fe8e4a2 Add timeout limit to --help text 2025-09-04 15:48:01 +08:00
Karl Seguin
b3d350d41e Limit serve timeout to 1 week 2025-09-04 15:27:03 +08:00
Karl Seguin
7c6870f8eb Merge pull request #1006 from lightpanda-io/migrate_some_tests_3
migrate to htmlRunner
2025-09-04 13:18:44 +08:00
Karl Seguin
327b4e4e37 migrate to htmlRunner 2025-09-04 13:11:15 +08:00
Karl Seguin
7fdc857326 Merge pull request #1004 from lightpanda-io/migrate_some_tests_2
Migrate some tests 2
2025-09-04 12:19:36 +08:00
Karl Seguin
0382c2775e Migrate more tests to html runner
Implement LocalStorage named get/set (i.e. localStorage["hi"])
2025-09-03 22:54:41 +08:00
Karl Seguin
a0374133cd migrate tests to new html runner 2025-09-03 22:54:40 +08:00
Karl Seguin
055f697340 Merge pull request #1005 from lightpanda-io/e2e-output
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
ci: remove go runner verbose mode
2025-09-03 22:44:35 +08:00
Pierre Tachoire
ec8a9862c7 ci: remove go runner verbose mode 2025-09-03 15:42:35 +02:00
Karl Seguin
f393eb7b7d Merge pull request #1003 from lightpanda-io/http_always_monitor_cdp
Http always monitor cdp
2025-09-03 20:35:49 +08:00
Karl Seguin
78285d7b1e fix tests 2025-09-03 20:23:59 +08:00
Karl Seguin
b6137b03cd Rework page wait again
Further reducing bouncing between page and server for loop polling. If there is
a page, the page polls. If there isn't a page, the server polls. Simpler.
2025-09-03 19:38:01 +08:00
Karl Seguin
e237e709b6 Change loader id on navigation
This appears to be what chrome is doing. I don't know why we weren't before.
2025-09-03 08:17:14 +08:00
Karl Seguin
2ac9b2088a Always monitor the CDP client socket, even on page.wait 2025-09-03 08:17:13 +08:00
Karl Seguin
a791212d89 Merge pull request #1002 from lightpanda-io/nix-0.15.1
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Update Nix Flake (Zig 0.15.1)
2025-09-03 08:07:34 +08:00
Muki Kiboigo
5cc5f45ef3 update zig-v8-fork 2025-09-02 09:25:33 -07:00
Muki Kiboigo
a11e50c087 nix flake for zig 0.15.1 2025-09-02 08:58:31 -07:00
Pierre Tachoire
4dc09360a1 Merge pull request #1001 from lightpanda-io/fix_abort_crash
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix segfault on abort if there are queued transfers
2025-09-02 15:45:51 +02:00
Karl Seguin
3a5528cc4d Merge pull request #1000 from lightpanda-io/log_unhandled_promise_rejections
Log unhandled promise rejection
2025-09-02 21:18:28 +08:00
Karl Seguin
de533755e5 fix segfault on abort if there are queued transfers 2025-09-02 21:18:02 +08:00
Karl Seguin
024b69ee3e update v8 dep 2025-09-02 19:48:56 +08:00
Karl Seguin
d7e7832e9f Log unhandled promise rejection 2025-09-02 18:19:28 +08:00
Karl Seguin
8d4d72bf15 Merge pull request #998 from lightpanda-io/migrate_some_tests_1
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Migrate some tests to the new htmlRunner
2025-09-02 16:11:08 +08:00
Pierre Tachoire
86a82d55fa Merge pull request #999 from lightpanda-io/handle_no_certs
Don't panic if no certs are available
2025-09-02 08:03:44 +02:00
Karl Seguin
5a15066da3 Don't panic if no certs are available
https://github.com/lightpanda-io/browser/issues/982
2025-09-02 13:50:53 +08:00
Karl Seguin
81766c8517 Migrate some tests to the new htmlRunner
Fix events.get_timeStamp (was events.get_timestamp, wrong casing).

Rename `newRunner` to `htmlRunner`.

move tests to src/tests (from src/browser/tests). src/runtime and possibly other
parts might want to have html tests too.
2025-09-02 10:40:04 +08:00
Karl Seguin
e486f28a41 Merge pull request #995 from lightpanda-io/improved_test_runner
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Looking for feedback on new test runner
2025-09-02 07:45:41 +08:00
Karl Seguin
8a9cbaf413 explicitly load testing.js 2025-09-02 07:38:03 +08:00
Karl Seguin
3a0a930b79 don't log 'long timeout ignored' during testing 2025-09-02 07:38:03 +08:00
Karl Seguin
c40704d2f3 Prototype new test runner
Follows up on https://github.com/lightpanda-io/browser/pull/994 and replaces
the jsRunner with a new page.navigation-based test runner.

Currently only implemented for the Window tests, looking for feedback and
converting every existing test will take time - so for a while, newRunner (to be
renamed) will sit side-by-side with jsRunner.

In addition to the benefits outlined in 994, largely around code simplicity and
putting more of the actual code under tests, I think our WebAPI tests
particularly benefit from:
1 - No need to recompile when modifying the html tests
2 - Much better assertions, e.g. you can assert that something is actually an
    array, not just a string representation of an array
3 - Ability to test some edge cases (e.g. dynamic script loading)

I've put some effort into testing.js to make sure that, if the encapsulating
zig test passes, it's because it actually passed, not because it didn't run.

For the time being, console tests are removed. I think it's more useful to have
access to the console within tests, than it is to test the console (which is
just a wrapper around log, which is both tested and heavily used).
2025-09-02 07:38:02 +08:00
Karl Seguin
c0f0630e17 Merge pull request #997 from lightpanda-io/fix_build
fix build
2025-09-02 07:24:02 +08:00
Karl Seguin
19dbb3a778 fix build 2025-09-02 07:06:57 +08:00
Karl Seguin
d4fc6f1b35 Merge pull request #996 from lightpanda-io/revert-document-element
Revert "document.documentElement returns a *parser.Element"
2025-09-02 06:52:16 +08:00
Karl Seguin
7c82942912 Merge pull request #994 from lightpanda-io/test_http_server
Test http server
2025-09-02 06:51:52 +08:00
Karl Seguin
87d48b028b Merge pull request #992 from lightpanda-io/http_buffer_presize
Pre-size the destination buffer when we know the response content length
2025-09-02 06:51:15 +08:00
Pierre Tachoire
d6640f4d15 Revert "document.documentElement returns a *parser.Element"
This reverts commit c1752ae5eb.
2025-09-01 15:46:16 +02:00
Karl Seguin
785a8da623 remove content-length limit 2025-09-01 18:53:00 +08:00
Karl Seguin
57dc303d90 Make getContentLength work on fulfilled responses 2025-09-01 18:40:50 +08:00
Pierre Tachoire
ce08cc9659 Merge pull request #993 from lightpanda-io/remove_unsafe_undefine
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove [some] unsafe undefines from netsurf wrapper
2025-09-01 08:26:41 +02:00
Pierre Tachoire
866393743c Merge pull request #991 from lightpanda-io/mimalloc_assertions
Switch mimalloc guards to assertions
2025-09-01 08:12:21 +02:00
Pierre Tachoire
ba255aa653 Merge pull request #989 from lightpanda-io/clocks
Improve clocks
2025-09-01 08:11:05 +02:00
Karl Seguin
7d46e8fe80 Start unifying test and code
Depends on https://github.com/lightpanda-io/browser/pull/993

There's currently 3 ways to execute a page:
1 - page.navigate (as used in both the 'fetch' and 'serve' commands)
2 - jsRunner as used in unit tests
3 - main_wpt as used in the WPT runner

Both jsRunner and main_wpt replicate the page.navigate code, but in their own
hack-ish way. main_wpt re-implements the DOM walking in order to extract and
execute <script> tags, as well as the needed page lifecycle events.

This PR replaces the existing main_wpt loader with a call to page.navigate. To
support this, a test HTTP server was added. (The test HTTP server is extracted
from the existing unit test test server, and re-used between the two).

There are benefits to this approach:
1 - The code is simpler
2 - More of the actual code and flow is tested
3 - There's 1 way to do things (page.navigate)
4 - Having an HTTP server might unlock some WPT tests

Technically, we're replacing file IO with network IO i.e. http requests). This
has potential downsides:
1 - The tests might be more brittle
2 - The tests might be slower

I think we need to run it for a while to see if we get flaky behavior.

The goal for following PRs is to bring this unification to the jsRunner.
2025-09-01 13:01:08 +08:00
Karl Seguin
6c41245c73 Remove [some] unsafe undefines from netsurf wrapper
Code like this:

```
var body: ?*ElementHTML = undefined;
const err = documentHTMLVtable(doc_html).get_body.?(doc_html, &body);
try DOMErr(err);
if (body == null) return null;
return @as(*Body, @ptrCast(body.?));
```

Isn't safe. It assumes that libdom will either return an error, or set body
to null or a value. However, there are cases (specifically for this API) where
libdom returns DOM_NO_ERR without ever setting body. In such cases, we return
a `body` initialized to a random (invalid) value.

This PR replaces the initial value of optional types from undefined to null
(within the libdom wrapper).
2025-09-01 11:41:37 +08:00
Karl Seguin
2a8e51c2d2 Pre-size the destination buffer when we know the response content length 2025-08-31 20:14:55 +08:00
Karl Seguin
b2cf5df612 Switch mimalloc guards to assertions
The thin mimalloc API is currently defensive around incorrect setup/teardown by
guarding against using/destroying the arena when the heap is null, or creating
an arena when it already exists.

The only time these checks will fail is when the code is wrong, e.g. trying
to use libdom before or after freeing the arena. The current behavior can mask
these errors, plus add runtime overhead.
2025-08-31 19:35:53 +08:00
Karl Seguin
ada9ddd5b8 Improve clocks
There's a flaky performance test that I wanted to fix (1). This led to a couple
changes.

1 - Add timestamp() and milliTimestamp() to datetime.zig. Reduce some code
    duplication and use better clock_ids where available

2 - Change Performance API to use milliTimestamp and store a u64 instead of a
    f64. While the spec says a float, Firefox deals with u64 and implicit
    conversion is always available. Makes our APIs simpler.

(1) - https://github.com/lightpanda-io/browser/actions/runs/17313296490/job/49151366798#step:4:131
2025-08-30 13:45:12 +08:00
Karl Seguin
f66f4d9aeb Merge pull request #987 from lightpanda-io/improve_server_shutdown
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Ignore ConnectionClosed error on server shutdown
2025-08-30 12:35:12 +08:00
Pierre Tachoire
d02ba777f2 Merge pull request #984 from lightpanda-io/zig.0.15.1
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Zig 0.15.1
2025-08-29 10:33:00 +02:00
Karl Seguin
aef614823b Ignore ConnectionClosed error on server shutdown
Our shutdown could be cleaner, but this at least removes a meaningless (because
we're shutting down) log.err that was happening on every test run.
2025-08-29 16:21:26 +08:00
Karl Seguin
431db85ecb Merge pull request #978 from lightpanda-io/dynamic_cdp_read_buffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Make the CDP read buffer heap allocated & dynamic
2025-08-29 12:20:58 +08:00
Karl Seguin
1ebac06f4b add debug line on cdp buffer growth 2025-08-29 10:55:36 +08:00
Karl Seguin
c7c5af4708 zig fmt 2025-08-29 10:51:19 +08:00
Karl Seguin
0b6a9d3a0b use llvm. The new x86 backend crashes with v8. 2025-08-29 10:42:07 +08:00
Karl Seguin
23d6362058 fix telemetry, link libc and libcpp 2025-08-29 10:42:06 +08:00
Karl Seguin
1443f38e5f Zig 0.15.1
Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/89
2025-08-29 10:42:06 +08:00
Karl Seguin
94960cc842 Merge pull request #979 from lightpanda-io/app_owns_platform
App owns platform
2025-08-29 10:33:55 +08:00
Karl Seguin
efc983b009 Start with 16K buffer (down from 32K). Use array list growth algorithm 2025-08-29 10:33:27 +08:00
Karl Seguin
74d90f2892 fix tests 2025-08-29 10:14:59 +08:00
Karl Seguin
56f1b6cc19 Make the CDP read buffer heap allocated & dynamic
Rather than stack-allocating MAX_MESSAGE_SIZE upfront, we now allocate 32KB
and grow the buffer as needed for larger messages, up to MAX_MESSAGE_SIZE.

This will reduce memory usage for drivers that don't send huge payloads (like
playwright does).

While not implemented, this would also enable us to set the MAX_MESSAGE_SIZE
at runtime (e.g. via a command line option).
2025-08-29 10:14:58 +08:00
Karl Seguin
fa2cd9dfd9 Ability to start/stop CDP server.
Exists for cleaning up after tests.
2025-08-29 10:14:08 +08:00
Karl Seguin
687f09d952 Make the App own the Platform
Removes optional platform, which only existed for tests.

There is now a global `@import("testing.zig").test_app` available. This is setup
when the test runner starts, and cleaned up at the end of tests. Individual
tests don't have to worry about creating app, which I assume was the reason I
Platform optional, since that woul dhave been something else that needed to be
setup.
2025-08-29 10:14:06 +08:00
Karl Seguin
67b479b5c8 Merge pull request #983 from lightpanda-io/sigint
exit the browser on SIGINT signal
2025-08-29 10:10:49 +08:00
Pierre Tachoire
eac2140693 Merge pull request #986 from lightpanda-io/readme-interception
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
README: check request interception
2025-08-28 17:19:32 +02:00
Pierre Tachoire
7a3f5de9c2 Merge pull request #985 from lightpanda-io/fulfill-content-type-len
http: set content_type len on fulfill request
2025-08-28 17:19:23 +02:00
Pierre Tachoire
7005bf2481 README: check request interception 2025-08-28 17:18:42 +02:00
Pierre Tachoire
b80ee3342c http: set content_type len on fulfill request 2025-08-28 16:28:41 +02:00
Pierre Tachoire
4c7b7b1e60 handle graceful shutdown 2025-08-28 12:44:16 +02:00
Pierre Tachoire
1a4a3608c8 exit the browser on SIGINT signal 2025-08-28 12:44:12 +02:00
Pierre Tachoire
6800d50339 Merge pull request #981 from lightpanda-io/page-navigate-event
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
page: ensure page navigate events order
2025-08-27 18:23:22 +02:00
Pierre Tachoire
036f808ec6 page: ensure page navigate events order 2025-08-27 17:36:36 +02:00
Pierre Tachoire
7647ce9e6d Merge pull request #960 from lightpanda-io/auth-challenge
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
auth required interception
2025-08-27 15:34:51 +02:00
Karl Seguin
545d3f81ce Merge pull request #977 from lightpanda-io/selector_by_ref
Select is relatively large (64 bytes), pass it by ref
2025-08-27 19:37:36 +08:00
Pierre Tachoire
455615b9c1 Merge pull request #980 from lightpanda-io/update-docker-readme
Update docker readme
2025-08-27 09:32:41 +02:00
Pierre Tachoire
d0e2a03da5 README: proxy support is ready 2025-08-27 09:30:43 +02:00
Pierre Tachoire
fa408e644c cs fix 2025-08-27 09:26:10 +02:00
Pierre Tachoire
a22416584d README: --privileged is not needed anymore 2025-08-27 09:25:51 +02:00
Karl Seguin
b8fc60df45 Merge pull request #975 from lightpanda-io/dynamic_script
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Support dynamic scripts which are added to the DOM before src is set
2025-08-27 05:59:28 +08:00
Karl Seguin
c6455cf02e Select is relatively large (64 bytes), pass it by ref 2025-08-27 05:55:04 +08:00
Pierre Tachoire
2ac1d39367 Merge pull request #976 from lightpanda-io/webapi_file_placeholder
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
The most basic File implementation.
2025-08-26 18:20:53 +02:00
Pierre Tachoire
041e014d68 Merge pull request #970 from lightpanda-io/remove_loop
Remove the loop
2025-08-26 18:17:32 +02:00
Pierre Tachoire
5defb5c442 http: build headers when auth challenge fails 2025-08-26 18:05:45 +02:00
Pierre Tachoire
520a572bb4 http: add reset and tries for transfer 2025-08-26 18:05:45 +02:00
Pierre Tachoire
4c602256da http: remove useless field 2025-08-26 18:05:45 +02:00
Pierre Tachoire
5a40cbd655 cdp: use enum for AuthChallengeResponse 2025-08-26 18:05:45 +02:00
Pierre Tachoire
a75f9dd48d cdp: set default username/passwd for authChallengeResponse 2025-08-26 18:05:44 +02:00
Pierre Tachoire
6b47aa2446 cdp: add auth required interception process 2025-08-26 18:05:44 +02:00
Pierre Tachoire
a847a1faae http: replace _forbidden with _auth_challenge struct 2025-08-26 18:05:44 +02:00
Pierre Tachoire
bb381e522c http: add creds into request 2025-08-26 18:05:39 +02:00
Karl Seguin
6962cfb91a Merge pull request #973 from lightpanda-io/no-body-response
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Handle response without body
2025-08-26 18:44:22 +08:00
Pierre Tachoire
302c50a90e Merge pull request #964 from lightpanda-io/proxy-header
http: refacto headerCallback and get proxy CONNECT request details
2025-08-26 10:53:43 +02:00
sjorsdonkers
e2d47e1c86 fix merge conflict 2025-08-26 10:12:07 +02:00
Pierre Tachoire
7d51da1efb Merge pull request #974 from lightpanda-io/ignore_non_js_script_tags
Removes the log for unknown script tags
2025-08-26 08:53:29 +02:00
Karl Seguin
c7674926c3 The most basic File implementation.
Almost silly as-is, but handles this case:

```
if (input instanceof File) {
   throw Error('file not supported')
}
```

as seen on reddit.
2025-08-26 13:25:30 +08:00
Karl Seguin
f0ca9728ae Support dynamic scripts which are added to the DOM before src is set
This should load the "src.js":

```
const s = document.createElement('script');
document.getElementsByTagName('body')[0].appendChild(s);
s.src = "src.js"
```

Notice that src is set AFTER the element is added to the DOM. This PR enables
the above, by
1 - skipping dynamically added scripts which don't have a src
2 - trying to load a script whenever `set_src` is called.

(2) is safe because the ScriptManager already prevents scripts from being
processed multiple times.

Additionally, not only can the src be set after the script is added to the DOM,
but onload and onerror can be set after the src:

```
s.src = "src.js"
s.onload = ...;
s.onerror = ...;
```

This PR also delays reading the onload/onerror callbacks until the script is
done loading.

This behavior is seen on reddit.
2025-08-26 13:10:55 +08:00
Karl Seguin
5fa8567801 Removes the log for unknown script tags
Some sites have a lot of text/template or application/json, and it just adds
noise to the logs.
2025-08-26 08:48:29 +08:00
sjorsdonkers
e5b1acb6e1 Handle response without body 2025-08-25 18:07:02 +02:00
Karl Seguin
8fdbaef4c7 Use posix.TCP.NODELAY now that it's available in MacOS also 2025-08-25 22:03:58 +08:00
Pierre Tachoire
7869159657 add e2e test through proxy 2025-08-25 14:18:15 +02:00
Pierre Tachoire
7046e18d7e http: simplify header parsing 2025-08-25 14:18:14 +02:00
Pierre Tachoire
a7516061d0 http: move use_proxy from connection to client 2025-08-25 14:18:14 +02:00
Pierre Tachoire
e61d787ff0 http: move header done callback in its own func
And call it only after the headers are parsed, either from data callback
or end of the request.
2025-08-25 14:18:14 +02:00
Pierre Tachoire
25ad420f85 http: ajust header callback according to review 2025-08-25 14:18:14 +02:00
Pierre Tachoire
fcd49c000f page: avoid crash on empty body 2025-08-25 14:18:13 +02:00
Pierre Tachoire
e2320ebe66 http: handle proxy's request header callback 2025-08-25 14:18:13 +02:00
Pierre Tachoire
5e78a26e3d http: refacto http header parsing 2025-08-25 14:18:13 +02:00
Pierre Tachoire
159bd06a56 http: add use_proxy bool in connection 2025-08-25 14:18:12 +02:00
Pierre Tachoire
bc7e1e07f4 typo fix 2025-08-25 14:18:08 +02:00
Karl Seguin
ccc9618102 Merge pull request #971 from lightpanda-io/fix-send-error-json-format
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Fix sendError message's format
2025-08-25 19:05:47 +08:00
sjorsdonkers
0ad09cca9d Fix sendError message's format 2025-08-25 12:51:47 +02:00
Karl Seguin
0959eea677 Remove the loop
Previously, the IO loop was doing three things:
1 - Managing timeouts (either from scripts or for our own needs)
2 - Handling browser IO events (page/script/xhr)
3 - Handling CDP events (accept, read, write, timeout)

With the libcurl merge, 1 was moved to an in-process scheduler and 2 was moved
to libcurl's own event loop. That means the entire loop code, including
the dependency on tigerbeetle-io existed for handling a single TCP client.
Not only is that a lot of code, there was also friction between the two loops
(the libcurl one and our IO loop), which would result in latency - while one
loop is waiting for the events, any events on the other loop go un-processed.

This PR removes our IO loop. To accomplish this:

1 - The main accept loop is blocking. This is simpler and works perfectly well,
given we only allow 1 active connection.
2 - The client socket is passed to libcurl - yes, libcurl's loop can take
arbitrary FDs and poll them along with its own.

In addition to having one less dependency, the CDP code is quite a bit simpler,
especially around shutdowns and writes. This also removes _some_ of the latency
caused by the friction between page process and CDP processing. Specifically,
when CDP now blocks for input, http page events (script loading, xhr, ...) will
still be processed.

There's still friction. For one, the reverse isn't true: when the page is
waiting for events, CDP events aren't going to be processed. But the page.wait
already have some sensitivity to this (e.g. the page.request_intercepted flag).
Also, when CDP waits, while we will process network events, page timeouts are
still not processed. Because of both these remaining issues, we still need to
jump between the two loops - but being able to block on CDP (even for a short
time) WITHOUT stopping the page's network I/O, should reduce some latency.
2025-08-25 17:27:28 +08:00
Pierre Tachoire
3316f2fcf4 Merge pull request #968 from lightpanda-io/normalize_cdp_response_headers
Normalize CDP response headers
2025-08-25 09:31:56 +02:00
Karl Seguin
390a21e4aa Merge pull request #969 from lightpanda-io/fix_wpt_runner
Handle all case status (not just Pass and Fail)
2025-08-25 10:49:46 +08:00
Karl Seguin
70ce54a5cd Handle all case status (not just Pass and Fail) 2025-08-25 10:40:23 +08:00
Karl Seguin
087e42a641 Normalize CDP response headers
chromedb doesn't support duplicate header names. Although servers _will_ send
this (e.g. Cache-Control: public\r\nCache-Control: max-age=60\r\n), Chrome
seems to join them with a "\n". So we do the same.

A note on curl_easy_nextheader, which this code ultimately uses to iterate
and collect the headers. The documentation says:

    Applications must copy the data if they want it to survive subsequent API
    calls or the life-time of the easy handle.

As-is, I'd understand this to mean that a given header name/value is only
valid until any API call, including another call to curl_easy_nextheader. So,
from this comment, we _should_ be duping the name/value. But we don't. Why?
Because, despite the note in the documentation, this doesn't appear to be how
it actually works, nor does it really make sense. If it's just a linked list,
there's no reason curl_easy_nextheader should invalidate previous results. I'm
guessing this is just a general lack of guarantee libcurl is willing to make re
lifetimes.

https://github.com/lightpanda-io/browser/issues/966
2025-08-25 09:25:15 +08:00
Karl Seguin
e26d4afce2 Merge pull request #963 from lightpanda-io/wpt_runner_fix_and_nodeiterator_tweak
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Improves correctness of NodeIterator
2025-08-22 15:29:42 +08:00
Karl Seguin
b9ae4c6077 Update src/runtime/js.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-08-22 15:17:59 +08:00
Pierre Tachoire
11485d24f5 Merge pull request #962 from lightpanda-io/compareBoundaryPoints
Add Range.compareBoundaryPoints
2025-08-22 09:08:22 +02:00
Karl Seguin
ce14f0b380 Improves correctness of NodeIterator
Minor improvement to correctness of TreeWalker.

Fun fact, this is the first time, that I've run into, where we have to default
null and undefined to different values.

Also, tweaked the WPT test runner. WPT test results use | as a field delimiter.
But a WPT test (and, I assume a message) can contain a |. So we had at least
a few tests that were being reported as failed, only because the result line
was weird / unexpected. No great robust way to parse this, but I changed it
to look explicitly for |Pass or |Fail and use those positions as anchor points.
2025-08-21 18:11:48 +08:00
Karl Seguin
8bb2158a2a Add Range.compareBoundaryPoints
Also rename start_container and end_container to start_node and end_node.
2025-08-21 16:47:33 +08:00
Karl Seguin
1a9d4af565 Merge pull request #961 from lightpanda-io/cdp_getResponseBody
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Implement Network.getResponseBody
2025-08-21 16:19:07 +08:00
Pierre Tachoire
a6f37633a1 Merge pull request #959 from lightpanda-io/html-pre
handle text content type with HTML
2025-08-21 09:38:36 +02:00
Pierre Tachoire
3182a47858 typo fix 2025-08-21 08:52:35 +02:00
Pierre Tachoire
7335b1d0a4 escape incoming plain text 2025-08-21 08:52:34 +02:00
Karl Seguin
cd33e9ad0e Implement Network.getResponseBody
Add response_data event, CDP now captures the full body so that it can respond
to the Network.getResponseBody. This isn't memory efficient, but I don't see
another way to do it. At least this way, it's only capturing/storing every
response body when (a) CDP is used and (b) Network.enabled is called. That is,
as opposed to baking this into Http/Client.zig, which would force the memory
consumption for all use-cases.

There's arguably some optimizations we could make for XHR requests, which also
dupe/own the response. As of now, the response is dupe'd separately for CDP
and XHR.
2025-08-21 10:33:53 +08:00
Karl Seguin
557f8444b2 Merge pull request #955 from lightpanda-io/replace_deprecated_build
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Makes build.zig Zig 0.15 ready
2025-08-21 10:09:59 +08:00
Karl Seguin
65088b8644 swap unnecessary addModule with createModule 2025-08-21 09:59:42 +08:00
Karl Seguin
7cc9521cbb Merge pull request #958 from lightpanda-io/http_request_done_notification
Emits a http_request_done internal notification.
2025-08-21 09:23:41 +08:00
Karl Seguin
4ad19fc4d8 use merged v8 commit 2025-08-21 09:23:11 +08:00
Pierre Tachoire
ec71f8e2d9 handle text content type with HTML
For text content type (and application/json) we create a pseudo HTML
tree with the text value in a <pre> tag.

It allows CDP clients to interact with text content easily.
2025-08-20 15:27:15 +02:00
Pierre Tachoire
ff8a847795 Merge pull request #957 from lightpanda-io/remove_header_callback
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Remove the http/Client.zig header_callback.
2025-08-20 14:35:06 +02:00
Karl Seguin
6b001c50a4 Emits a http_request_done internal notification.
With networking enabled, CDP listens to this event and emits a
`Network.loadingFinished` event. This is event is used by puppeteer to know that
details about the response (i.e. the body) can be queries.

Added dummy handling for the Network.getResponseBody message. Returns an
empty body. Needed because we emit the loadingFinished event which signals
to drivers that they can ask for the body.
2025-08-20 19:32:19 +08:00
Karl Seguin
5759c88932 Remove the http/Client.zig header_callback.
The callback which was called on a per-header basis is removed. Only XHR was
using this, and it was created before the HeaderIterator existed (because I
didn't know we could iterate through the response headers in curl after the fact).

The header_done_callback remains, but is now called header_callback (a bit
confusing in the short term).

The only difficulty was with fulfilled requests, which do not have an easy
handle for our HeaderIterator. The existing code would segfault if
transfer.responseHeaderIterator() was called on a fulfilled requests.
The HeaderIterator is now a tagged union that abstracts whether the source of
the response header is a curl easy, or just an injected list from the fulfilled
requests.
2025-08-20 17:49:37 +08:00
Karl Seguin
00c11d9bd4 Merge pull request #956 from lightpanda-io/typo-fix
typo fix
2025-08-20 17:06:16 +08:00
Pierre Tachoire
ed99acebfe typo fix 2025-08-20 09:25:47 +02:00
Pierre Tachoire
bade412d30 Merge pull request #953 from lightpanda-io/is_navigation_and_arena
Use Transfer.arena in a few more places, correctly set is_navigation …
2025-08-20 08:59:32 +02:00
Pierre Tachoire
191e9ba073 Merge pull request #954 from lightpanda-io/remove_managed_arraylist
Replace all std.ArrayList with std.ArrayListUnmanaged
2025-08-20 08:55:03 +02:00
Karl Seguin
b21688a0ac Makes build.zig Zig 0.15 ready
Our build.zig is using a number of deprecated features, which are removed in
0.15.

This updates build.zig so that it still works in 0.14 and [hopefully] will work
in 0.15.

Related: https://github.com/lightpanda-io/zig-v8-fork/pull/87
2025-08-20 14:54:27 +08:00
Karl Seguin
a4d4da4d96 Replace all std.ArrayList with std.ArrayListUnmanaged
Very minor, but the more we can do upfront to align the code for Zig 0.15, the
better.
2025-08-20 12:30:39 +08:00
Karl Seguin
16c85c5b8a Use Transfer.arena in a few more places, correctly set is_navigation on redirect
Following up to Request Interception PR (1) and Cookie Redirect PR (2) which
both introduced features that were useful to the other. This PR closes that
loop.

(1) https://github.com/lightpanda-io/browser/pull/946
(2) https://github.com/lightpanda-io/browser/pull/948
2025-08-20 11:39:38 +08:00
Karl Seguin
2c7b39927a Merge pull request #952 from lightpanda-io/fix_compilation_error
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix compilation error
2025-08-20 10:28:54 +08:00
Karl Seguin
7f47692ad4 Fix compilation error
bad auto merge?
2025-08-20 10:04:15 +08:00
Karl Seguin
af4066da87 Merge pull request #946 from lightpanda-io/request_interception
Request Interception
2025-08-20 07:53:08 +08:00
Pierre Tachoire
4de4e7504d Merge pull request #951 from lightpanda-io/wpt_range
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Improve correctness of Node.compareDocumentPosition and Range api.
2025-08-19 17:23:39 +02:00
Karl Seguin
b46c181b07 zig fmt 2025-08-19 22:01:14 +08:00
Karl Seguin
e4f89092b3 add Range.intersectsNode and cover a few more edge-cases 2025-08-19 22:00:59 +08:00
Pierre Tachoire
4fbedf5b20 Merge pull request #948 from lightpanda-io/redirect-cookies
handle cookies on redirection manually
2025-08-19 14:48:37 +02:00
Karl Seguin
d51a03f1b6 Improve correctness of Node.compareDocumentPosition and Range api.
Should fix a good chunk (~20K I think) of the recently broken WPT tests.
2025-08-19 18:23:54 +08:00
Pierre Tachoire
f7eee0d461 http: add an arena to Transfer 2025-08-19 11:10:52 +02:00
Pierre Tachoire
39178d8d2b http: remove uselesss Client.arena 2025-08-19 11:10:25 +02:00
Pierre Tachoire
7795916c08 apply review comments 2025-08-19 10:01:35 +02:00
Pierre Tachoire
0e2a3d8009 handle cookies on redirection manually 2025-08-19 10:01:11 +02:00
Karl Seguin
38a0b6905e Merge pull request #949 from lightpanda-io/network_path_reference_stitch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix network-path reference stitching
2025-08-19 15:38:43 +08:00
Karl Seguin
8797549369 Fix network-path reference stitching 2025-08-18 23:05:11 +08:00
Karl Seguin
f5ec74252d Add fulfillRequest and more complete continueRequest 2025-08-18 18:29:10 +08:00
Karl Seguin
211012d367 move intercept_state and extra_headers from CDP instance to BrowserContext 2025-08-18 13:23:17 +08:00
Karl Seguin
c1319d1f27 add proper resourceType 2025-08-18 12:42:18 +08:00
Pierre Tachoire
d4d8773fd1 Merge pull request #927 from lightpanda-io/window-frames
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Partial window.frames implementation
2025-08-15 14:54:29 +02:00
Karl Seguin
01223601f2 Reduce allocations made during request interception
Stream (to json) the Transfer as a request and response object in the various
network interception-related events (e.g. Network.responseReceived).

Add a page.request_intercepted boolean flag for CDP to signal the page that
requests have been intercepted, allowing Page.wait to prioritize intercept
handling (or, at least, not block it).
2025-08-15 14:01:57 +08:00
Karl Seguin
d9ed4cfca8 Merge pull request #940 from lightpanda-io/redirect-cookies
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
enable curl cookie engine
2025-08-15 08:50:25 +08:00
Pierre Tachoire
7d0e4b6270 use CURLOPT_COOKIE to set cookies 2025-08-14 15:33:02 +02:00
Pierre Tachoire
b2f645a5ce enable curl cookie engine
Enabling Curl cookie engine brings advantage:
* handle cookies during a redirection: when a srv redirects including
  cookies, curl sends back the cookies correctly during the next request
2025-08-14 15:32:56 +02:00
Karl Seguin
6a29d6711c Merge pull request #945 from lightpanda-io/remove_unecessary_content_type_parse
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Remove unecessary content type parse
2025-08-14 19:07:41 +08:00
Karl Seguin
5b2806a784 expose response header amount 2025-08-14 18:57:57 +08:00
Karl Seguin
a2f15ce0b2 Remove unecessary content type parse
getResponseHeader takes header index
2025-08-14 18:26:01 +08:00
Karl Seguin
68400f3bcf Merge pull request #944 from lightpanda-io/fix_memory_leak
fix memory leak
2025-08-14 18:20:53 +08:00
Karl Seguin
31f3c2771a fix build error...sorry 2025-08-14 18:07:14 +08:00
Karl Seguin
f9352e26cb same memory leak, different place 2025-08-14 18:00:56 +08:00
Karl Seguin
4fa542bc38 fix memory leak 2025-08-14 17:51:40 +08:00
Pierre Tachoire
a707e10af7 Merge pull request #922 from lightpanda-io/nonblocking_libcurl
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Nonblocking libcurl
2025-08-14 11:43:19 +02:00
Pierre Tachoire
1e095fede5 zig fmt build.zig 2025-08-14 11:29:56 +02:00
Karl Seguin
96b10f4b85 Optimize Network.responseReceived
Add a header iterator to the transfer. This removes the need for NetworkState,
duping header name/values, and the http_header_received event.
2025-08-14 15:50:56 +08:00
Karl Seguin
5100e06f38 fix header done callback 2025-08-14 14:51:02 +08:00
Karl Seguin
35e2fa5058 Merge pull request #943 from lightpanda-io/integer-overflow
fix integer overflow for sleeping delay
2025-08-14 05:43:49 +08:00
Pierre Tachoire
8d2d4ffdd2 fix integer overflow for sleeping delay 2025-08-13 19:44:06 +02:00
sjorsdonkers
7d05712f40 setExtraHTTPHeaders 2025-08-13 14:54:59 +02:00
sjorsdonkers
c0106a238b http_headers_done_receiving 2025-08-13 14:29:23 +02:00
Karl Seguin
f6c68e4580 fix release build (constness via telemetry, not seen in debug) 2025-08-13 20:16:14 +08:00
Karl Seguin
3c8065fdee fix fmt 2025-08-13 20:12:39 +08:00
Karl Seguin
9bd8b2fc43 fix wpt runner 2025-08-13 19:39:49 +08:00
Karl Seguin
5a3d5f5512 improve elapsed display for larger numbers 2025-08-13 18:17:59 +08:00
Karl Seguin
ca9e850ac7 Create Client.Transfer earlier.
On client.request(req) we now immediately wrap the request into a Transfer. This
results in less copying of the Request object. It also makes the transfer.uri
available, so CDP no longer needs to std.Uri(request.url) anymore.

The main advantage is that it's easier to manage resources. There was a use-
after free before due to the sensitive nature of the tranfer's lifetime. There
were also corner cases where some resources might not be freed. This is
hopefully fixed with the lifetime of Transfer being extended.
2025-08-13 18:05:00 +08:00
Karl Seguin
2dc09c799f Merge pull request #930 from lightpanda-io/request_interception
request interception
2025-08-13 14:44:26 +08:00
sjorsdonkers
a49154acf4 http_request_fail 2025-08-12 15:20:48 +02:00
sjorsdonkers
77eee7f087 Cookies 2025-08-12 14:40:23 +02:00
sjorsdonkers
03694b54f0 3# This is a combination of 3 commits.
intercept continue and abort

feedback

First version of headers, no cookies yet
2025-08-12 13:49:20 +02:00
Karl Seguin
bed320204d Merge pull request #939 from lightpanda-io/raw-done
finalize document loading with non-HTML pages
2025-08-12 19:09:31 +08:00
Pierre Tachoire
971524fa3b finalize document loading with non-HTML pages
Avoid infinite the loop of loading non-HTML documents with CDP.
2025-08-12 12:55:44 +02:00
Karl Seguin
4758456069 Merge pull request #938 from lightpanda-io/node_isConnected
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Fix Node.isConnected
2025-08-12 18:17:28 +08:00
Karl Seguin
3ef4ba6b8b Fix Node.isConnected
The previous implementation just checked if a node had a parent. But it should
check the node has a document ancestor:

```
const d1 = document.createElement('div');
document.createElement('div').appendChild(d1);
d1.isConnected
```

should be `false`.

In addition to this fix, also added support for DocumentFragments which are
part of the ShadowRoot. This, like events, is one of those apis that DOES break
the ShadowRoot isolation.
2025-08-12 17:23:38 +08:00
Karl Seguin
a504f051e7 Merge pull request #937 from lightpanda-io/event_composedPath
Add ShadowRoot get/set innerHTML
2025-08-12 17:22:25 +08:00
Karl Seguin
ea0bbaf332 Revert "Treat pending requests as active"
This reverts commit 19c908035b.
2025-08-12 11:27:28 +08:00
Karl Seguin
19c908035b Treat pending requests as active
This ensures that page.wait won't unblock too early. As-is, this isn't an issue
since active can only be 0 if there are no active OR pending requests. However,
with request interception (https://github.com/lightpanda-io/browser/pull/930)
it's possible to have no active requests and no pending requests - from the
http client's point of view - but still have pending-on-intercept requests.

An alternative to this would be to undo these changes, and instead change
Page.wait to be intercept-aware. That is, Page.wait would continue to block on
http activity and scheduled tasks, as well as intercepted requests. However,
since the Page doesn't know anything about CDP right now, and it does know
about the http client, maybe doing this in the client is fine.
2025-08-12 11:13:19 +08:00
Muki Kiboigo
05192b6850 update flake 2025-08-11 12:09:22 -07:00
Karl Seguin
079ce5e9de whitelist application/ld+json 2025-08-11 21:38:36 +08:00
Karl Seguin
ff742c0169 don't allow concurrent blocking calls 2025-08-11 21:38:36 +08:00
Karl Seguin
332e264437 remove unimportant todos 2025-08-11 21:38:34 +08:00
Karl Seguin
3554634c1c cleanup optional request headers 2025-08-11 21:37:03 +08:00
Karl Seguin
c96fb3c2f2 support CDP proxy override 2025-08-11 21:37:03 +08:00
Karl Seguin
1e612e4166 Add command line options to control HTTP client
http_timeout_ms
http_connect_timeout_ms
http_max_host_open
http_max_concurrent
2025-08-11 21:37:03 +08:00
Karl Seguin
06984ace21 fix overflow and debug units 2025-08-11 21:37:03 +08:00
Karl Seguin
cabd4fa718 re-enable datauris 2025-08-11 21:37:03 +08:00
Karl Seguin
ddb549cb45 cookie support 2025-08-11 21:37:02 +08:00
Karl Seguin
c7484c69c0 Increase max concurrent request to 10
Improve wait analysis dump.

De-prioritize secondary schedules.

Don't log warning for application/json scripts

Change pretty log timer to display time from start.
2025-08-11 21:37:02 +08:00
Karl Seguin
9876d79680 Add Accept-Encoding
This is necessary because of CloudFront which will send gzip content even if
we don't ask for it.

Properly handle scripts that are both async and defer.

Add a helper to print state of page wait. This can be helpful in identifying
what's causing the page to hang on page.wait.
2025-08-11 21:37:02 +08:00
Karl Seguin
32566ccc80 Set window location on load
Set SUPPRESS_CONNECT_HEADERS option.
2025-08-11 21:37:02 +08:00
Karl Seguin
7f9e309ae8 Shutdown clean async scripts
Set parent current script
2025-08-11 21:37:02 +08:00
Karl Seguin
7831aabe5a connect proxy 2025-08-11 21:37:02 +08:00
Karl Seguin
74b40b97ec fix ScriptManager wrong order execution 2025-08-11 21:37:02 +08:00
Karl Seguin
f45726d61f ScriptManager & HttpClient support for JS modules
Improve cleanup/shutdown (*cough* memory leaks *cough*)
2025-08-11 21:37:01 +08:00
Karl Seguin
3c0d027306 dynamic script support 2025-08-11 21:37:01 +08:00
Karl Seguin
dc83765808 fix build 2025-08-11 21:37:01 +08:00
Karl Seguin
4244b572d1 Improve page.wait
Allow page.wait to transition page mode.

Optimize initial page load. No point running scheduler until the initial
page is loaded.

Support ISO-8859-1 charset
2025-08-11 21:37:01 +08:00
Karl Seguin
77475ca5e4 Re-enable --insecure_disable_tls_host_verification
Better error logs on http callback error

Fix wait timing
2025-08-11 21:37:01 +08:00
Karl Seguin
3555680335 Working navigation events (clicks, form submission) 2025-08-11 21:37:01 +08:00
Karl Seguin
f65a39a3e3 Re-enable telemetry
Start work on supporting navigation events (clicks, form submission).
2025-08-11 21:37:00 +08:00
Karl Seguin
94e8964f69 add custom scheduler 2025-08-11 21:37:00 +08:00
Karl Seguin
254d22e2cc don't poll libcurl if we have no running transfers 2025-08-11 21:37:00 +08:00
Karl Seguin
54ab1326e5 Switch XHR to new http client
get puppeteer/cdp.js working again

make test are all passing
2025-08-11 21:37:00 +08:00
Karl Seguin
b0fe5d60ab Initial work on integrating libcurl and making all http nonblocking 2025-08-11 21:36:56 +08:00
Karl Seguin
4b1eb2794f Add ShadowRoot get/set innerHTML
Adds event.composedPath()

This depends on https://github.com/lightpanda-io/libdom/pull/34
2025-08-11 16:32:08 +08:00
Karl Seguin
6a2dd1111c Merge pull request #928 from lightpanda-io/lit_compat
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
lit compatibility
2025-08-11 08:30:34 +08:00
Karl Seguin
f5da89b50b lit compatibility
Aims to improve compatibility for the lit framework (e.g. what Reddit is using).

1 - Adds support for adoptedStyleSheets to the Document and ShadowRoot
2 - Adds mock support for replace and replaceSync to the CSSStyleSheet
3 - Optionally include shadowroot in dump
4 - Special-case setting innerHTML on a TemplateElement
2025-08-09 07:43:27 +08:00
Karl Seguin
bede244598 Merge pull request #934 from lightpanda-io/with-base
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add a --with_base option to fetch
2025-08-09 07:37:32 +08:00
Karl Seguin
4df48c9695 Merge pull request #935 from lightpanda-io/mouse-event-log
use internal logger instead of std.log
2025-08-09 07:36:35 +08:00
Karl Seguin
05ad77ffbe Merge pull request #936 from lightpanda-io/runtime-empty-array
Fix crashes with empty array
2025-08-09 07:36:09 +08:00
Pierre Tachoire
dc23a74e7b add <base> in the DOM tree 2025-08-08 18:34:14 +02:00
Pierre Tachoire
f463cb16da runtime: handle empty array parameter 2025-08-08 17:50:18 +02:00
Pierre Tachoire
b785884cd8 runtime: fix returning an empty array crash 2025-08-08 17:26:39 +02:00
Pierre Tachoire
f09caec09a use internal logger instead of std.log 2025-08-08 16:21:23 +02:00
Pierre Tachoire
5e30a3997e typo fix
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2025-08-08 16:17:52 +02:00
Karl Seguin
8552a5797c Merge pull request #933 from lightpanda-io/document_fragment_get_element_by_id
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add DocumentFragment getElementById
2025-08-08 22:06:02 +08:00
Karl Seguin
a0d528981e Merge pull request #932 from lightpanda-io/libdom_element_attributes
Updates libdom
2025-08-08 22:05:42 +08:00
Pierre Tachoire
7ffdee0d7f node: add baseURI getter 2025-08-08 15:21:20 +02:00
Pierre Tachoire
3d0928a449 add a --with_base option to fetch
with_base option adds a <base> tag to the dump for better offline preview.
2025-08-08 15:18:11 +02:00
Pierre Tachoire
ea1bca05c7 fix no-script default value 2025-08-08 14:30:41 +02:00
Karl Seguin
df292a2103 Add DocumentFragment getElementById 2025-08-08 17:05:22 +08:00
Karl Seguin
7f2c360f33 Updates libdom
libdom's parsing is now less strict with respect to attribute names. See:
https://github.com/lightpanda-io/libdom/pull/33

However, the attribute name in setAttribute has stricter validation applied to
it, which we now handle directly.
2025-08-08 16:22:25 +08:00
Karl Seguin
fbd40a6514 Merge pull request #931 from lightpanda-io/element_getAttributeNames
add element.getAttributeNames()
2025-08-08 15:13:03 +08:00
Karl Seguin
9dd02ec67d add element.getAttributeNames() 2025-08-08 10:23:45 +08:00
Karl Seguin
8e55082d4e Merge pull request #929 from lightpanda-io/fix-webcomponents
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
webcomponent must be cast as HTMLElement
2025-08-07 19:04:31 +08:00
Pierre Tachoire
29378c57ea node: cast the libdom document depending its type 2025-08-07 12:54:18 +02:00
Pierre Tachoire
16c74cf3b4 element: fix toInterface for webcomponents
The webcomponents tag can be anything. But we must return them as
HTMLElement for HTML documents.
2025-08-07 12:47:02 +02:00
Pierre Tachoire
b199925f91 iframe: move HTMLIFrameElement in its own file 2025-08-07 10:35:04 +02:00
Pierre Tachoire
28397bf9d0 window: frame is obsolete, ignore them from frames list 2025-08-07 10:04:42 +02:00
Pierre Tachoire
1b7abf9972 window: partial implementation for indexed_get 2025-08-06 18:29:26 +02:00
Pierre Tachoire
b98bdeaae7 window.length dynamically 2025-08-06 18:29:25 +02:00
Pierre Tachoire
221274b473 first change to start support frames 2025-08-06 16:19:52 +02:00
Karl Seguin
cc6d443113 Merge pull request #926 from lightpanda-io/noscript_exclude_preload
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
When --noscript is specified, also exclude <link rel=preload as=script>
2025-08-06 19:45:52 +08:00
Karl Seguin
b3c81c9e55 When --noscript is specified, also exclude <link rel=preload as=script> 2025-08-06 18:04:57 +08:00
Pierre Tachoire
84d07f3f18 Merge pull request #919 from lightpanda-io/html_element-and-element
Create HTMLElement instead of pure Element
2025-08-06 10:55:46 +02:00
Pierre Tachoire
0fee2bbf28 upgrade netsurf/libdom 2025-08-06 10:42:54 +02:00
Pierre Tachoire
ea38845622 detect HTML document 2025-08-06 10:42:54 +02:00
Pierre Tachoire
81a0e95916 netsurf: remove inline for documentCreateHTMLElement* 2025-08-06 10:42:54 +02:00
Pierre Tachoire
2a9feee476 init default HTML doc and Image w/ HTML Elements 2025-08-06 10:42:53 +02:00
Pierre Tachoire
c38c1fa93a remove netsurf.elementHTMLGetTagType 2025-08-06 10:42:53 +02:00
Pierre Tachoire
8d7c35d034 refacto and use Element.toInterface 2025-08-06 10:42:53 +02:00
Pierre Tachoire
425f62607b add Tag.fromString to get element tag from tagname 2025-08-06 10:42:52 +02:00
Pierre Tachoire
c1752ae5eb document.documentElement returns a *parser.Element
For XML documents, the documentElement could be another element than
HTMLElement. So we don't want to pass to through the toInterface.
2025-08-06 10:42:52 +02:00
Pierre Tachoire
d61e91b949 Merge pull request #924 from lightpanda-io/fix-null-owner
node: check owner null before using it
2025-08-06 10:38:59 +02:00
Pierre Tachoire
090c0f8857 node: check owner null before using it 2025-08-05 18:23:41 +02:00
Pierre Tachoire
c453dd2b3c Merge pull request #923 from lightpanda-io/doc-owner-next
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
node: don't call owner twice in _insertBefore
2025-08-05 15:59:16 +02:00
Pierre Tachoire
b2b2e97edc zig fmt 2025-08-05 14:47:25 +02:00
Pierre Tachoire
bd9e4dbc79 node: don't call owner twice in _insertBefore
When the ref_node_ is null, call directly _appendChild w/o fixing the
node's owner.
2025-08-05 14:45:25 +02:00
Pierre Tachoire
0c19070800 Merge pull request #920 from SrikanthKumarC/main
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix: Properly handle node ownership when using appendChild and insertBefore
2025-08-05 14:45:18 +02:00
Karl Seguin
07e37b257f Merge pull request #921 from lightpanda-io/cdp-agent-commt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
cdp: add comment for CDP_USER_AGENT
2025-08-05 07:38:43 +08:00
Srikanth
0a5f060d1b add tests and simplify walker traversal 2025-08-04 23:53:29 +05:30
muki
6fcfcb630d Merge pull request #916 from lightpanda-io/allow-nullable-listener
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
2025-08-04 06:15:39 -07:00
Pierre Tachoire
7aff90aec7 cdp: add comment for CDP_USER_AGENT 2025-08-04 14:40:44 +02:00
Srikanth
f1e513443b refactor: use walker to traverse the nodes 2025-08-04 14:27:39 +05:30
Srikanth
c533b10e19 fix: traverse all children correctly 2025-08-04 13:00:03 +05:30
Srikanth
b4014e8c24 Fix: Properly handle node ownership when using appendChild and insertBefore 2025-08-03 20:27:32 +05:30
sjorsdonkers
478f3a5308 simplify statusText
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
2025-07-29 09:53:54 +02:00
sjorsdonkers
b98edf3d76 CDP response statusText 2025-07-29 09:53:54 +02:00
Karl Seguin
02fe46de58 Merge pull request #915 from lightpanda-io/css_tweaks
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Tweak cssom
2025-07-24 19:28:21 +08:00
Karl Seguin
ab2fd0ad36 Merge pull request #911 from lightpanda-io/select_options
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Implement select.options
2025-07-24 07:48:12 +08:00
Muki Kiboigo
88655d877b handle null event listener 2025-07-23 06:53:44 -07:00
Karl Seguin
6e94affea6 Update src/browser/dom/html_collection.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-23 21:34:42 +08:00
Karl Seguin
f7f382275a Merge pull request #908 from lightpanda-io/guard_against_double_script_execution
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Prevent double-execution of script tags.
2025-07-23 21:33:52 +08:00
Karl Seguin
23f3bf43c2 Merge pull request #910 from lightpanda-io/performance_getEntriesByX
Add placeholder performance getEntriesByName and Type
2025-07-23 21:19:01 +08:00
Karl Seguin
8a0c4909b9 fix file casing 2025-07-23 16:06:07 +08:00
Karl Seguin
2aeaf02d05 Tweak cssom
The only functionality change is adding a `named_set` to the CSSStyleDeclaration
so that styles can be set (`named_get` was already defined)

Combine the StringHashMapUnmanaged + ArrayListUnmanaged into a single
StringArrayHashMapUnmanaged.

Use file structs, because @import("css_style_declaration.zig").CSSStyleDeclaration
is a bit tedious.

Various micro-optimization around parsing CSS, e.g. ascii.eqlIgnoreCase in loops
replaced by 1 lowercase + N*mem.eql.
2025-07-23 15:34:32 +08:00
Karl Seguin
f4a6e34713 update libdom 2025-07-23 07:51:33 +08:00
Karl Seguin
3eb85da02c Implement select.options
Add HTMLOptionsCollection and enhance HTMLOptionElement API.

Amazon.
2025-07-23 07:39:53 +08:00
Karl Seguin
6533456472 Add placeholder performance getEntriesByName and Type 2025-07-22 08:05:52 +08:00
Karl Seguin
7969e047c7 Merge pull request #909 from lightpanda-io/zig_fmt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig fmt
2025-07-22 08:05:50 +08:00
Karl Seguin
f0d6d9d177 zig fmt 2025-07-22 07:57:17 +08:00
Karl Seguin
ca574df3be Prevent double-execution of script tags.
Depends on https://github.com/lightpanda-io/libdom/pull/31
2025-07-22 07:54:39 +08:00
Karl Seguin
0b793d82fe Merge pull request #907 from lightpanda-io/array_buffer_as_u8_slice
Map ArrayBuffer and ArrayBufferView to u8.
2025-07-22 07:13:57 +08:00
Karl Seguin
f6d51462eb Merge pull request #906 from lightpanda-io/text_decoder
Add TextDecoder (utf8 support only)
2025-07-22 07:13:21 +08:00
Karl Seguin
5bdacbab61 Merge pull request #903 from lightpanda-io/MessageChannel
Add MessageChannel
2025-07-22 07:13:07 +08:00
Karl Seguin
e239cc962b Merge pull request #904 from lightpanda-io/minor-refactor-prep-for-tls
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Minor refactor prep for tls
2025-07-21 20:55:35 +08:00
sjorsdonkers
6ebd4fcf5b fix unencrypted keepalive 2025-07-21 14:28:53 +02:00
Karl Seguin
ef427fa966 Map ArrayBuffer and ArrayBufferView to u8.
Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/86

Built ontop of https://github.com/lightpanda-io/browser/pull/906 just because
this is the feature that uses it.
2025-07-21 19:46:57 +08:00
Karl Seguin
f4383a11d7 Merge pull request #905 from lightpanda-io/scheme_only_url
Allow scheme-only URLs
2025-07-21 19:36:24 +08:00
Karl Seguin
77b6377473 Add TextDecoder (utf8 support only) 2025-07-21 16:29:42 +08:00
Karl Seguin
7bf3cf999f Allow scheme-only URLs
new URL('sveltekit-internal://') is valid. Used by amazon.
2025-07-21 15:46:23 +08:00
sjorsdonkers
4ab611de0c minor refactor prep for tls 2025-07-21 09:30:22 +02:00
Karl Seguin
d7745a418f Merge pull request #902 from lightpanda-io/window_DOMContentLoaded
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Trigger the DOMContentLoaded on the Window
2025-07-19 08:51:12 +08:00
Karl Seguin
058a5a43ba Add MessageChannel 2025-07-18 16:47:04 +08:00
Karl Seguin
878dbd81b1 Merge pull request #901 from lightpanda-io/url_stitch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Rework URL.stitch, handle ../ (for yahoo)
2025-07-17 21:44:24 +08:00
Karl Seguin
3c64ed1eb2 Merge pull request #899 from lightpanda-io/element_remove
Add element.remove()  (needed by reddit)
2025-07-17 21:44:08 +08:00
Karl Seguin
ee50f1238c Trigger the DOMContentLoaded on the Window
This is hacky, but it's inspired by how NetSurf does it. While the Window isn't
the parent of the Document, many events should bubble from the Document to the
Window. libdom simply doesn't handle this (it has no concept of a Window, and
the Document has no parent).

We potentially need to do this for multiple event types (NetSurf only does it
for the 'load' event as far as I can tell). It would be nice to find a generic
way to do this...maybe intercept any addEventListener on the body and
registering special events on the Window? For now, `DOMContentLoaded` is the
blocking (for finance.yahoo.com) and we can see if this is really an issue for
other event types.
2025-07-17 21:38:54 +08:00
Karl Seguin
1af2513fc0 zig fmt 2025-07-17 20:52:15 +08:00
Karl Seguin
9c0d26bc84 add note about incomplete removal 2025-07-17 20:51:05 +08:00
Karl Seguin
4d9053a83b Update src/url.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-17 20:45:42 +08:00
Karl Seguin
3f7e98c277 Update src/url.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-17 20:45:35 +08:00
Karl Seguin
aebc877e7b Merge pull request #900 from lightpanda-io/getDocument_depth
support `depth` parameter for DOM.getDocument
2025-07-17 20:44:58 +08:00
Karl Seguin
eef5f3fec2 support null params to CDP DOM.getDocument 2025-07-17 19:05:17 +08:00
Karl Seguin
16a1677fde Rework URL.stitch, handle ../ (for yahoo)
Also handle ./ anywhere in the path.
2025-07-17 17:54:00 +08:00
Karl Seguin
f199816fcd support depth parameter for DOM.getDocument 2025-07-17 14:17:33 +08:00
Karl Seguin
5e74e17b41 Merge pull request #888 from lightpanda-io/cdp_dom_requestChildNodes
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add support for CDP's DOM.requestChildNodes
2025-07-17 10:48:24 +08:00
Karl Seguin
98b041e84a requestChildNode cannot have a depth of 0 2025-07-17 10:36:20 +08:00
Karl Seguin
bba9c8f652 Add element.remove() (needed by reddit) 2025-07-17 10:00:38 +08:00
Karl Seguin
1a05fe6ae1 Merge pull request #887 from lightpanda-io/go_rod
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Noop CDP methods that go-rod requires
2025-07-16 20:01:03 +08:00
sjorsdonkers
16fcbf66ee http_proxy_before ?? comment 2025-07-16 11:20:00 +02:00
Karl Seguin
b7fd4e90e2 Merge pull request #894 from lightpanda-io/HTMLStyleElement_get_sheet
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
add HTMLStyleElement.get_sheet
2025-07-16 10:34:37 +08:00
Karl Seguin
b6341c10cc Merge pull request #892 from lightpanda-io/set_timeout_params
Support params for setTimeout and setInterval
2025-07-16 08:17:11 +08:00
Karl Seguin
08487b0fcc Merge pull request #891 from lightpanda-io/reattach_shadowroot
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add childElementCount and children to DocumentFragment
2025-07-15 23:36:26 +08:00
muki
b084dde22a Merge pull request #872 from lightpanda-io/dynamic-import 2025-07-15 08:31:52 -07:00
Karl Seguin
5229a7c997 Merge pull request #897 from lightpanda-io/animate
Add Element.animate and Animation
2025-07-15 23:09:08 +08:00
Karl Seguin
e56c56e2fe Merge pull request #895 from lightpanda-io/performance_clear
dummy performance clearMarks and clearMeasures
2025-07-15 21:40:31 +08:00
Karl Seguin
7374f956cf Merge pull request #896 from lightpanda-io/dont_send_after_disconnect
Don't queue data to send after we've initiated a disconnect of the cl…
2025-07-15 21:29:01 +08:00
Muki Kiboigo
287df42994 log module specifier on dynamic import stages 2025-07-15 06:22:52 -07:00
Muki Kiboigo
06e514cc2e use resource_str for stitching url 2025-07-15 06:22:52 -07:00
Muki Kiboigo
dffd8b5fec use module() for dynamic imports 2025-07-15 06:22:52 -07:00
Muki Kiboigo
2a87337875 dynamicImportCallback in JsContext 2025-07-15 06:22:50 -07:00
Karl Seguin
a74f79118f Merge pull request #893 from lightpanda-io/dump_noscript
Add a --noscript option to "improve" --dump
2025-07-15 21:22:33 +08:00
Muki Kiboigo
a13ed0bec3 add dynamic import callback to isolate 2025-07-15 06:22:13 -07:00
Karl Seguin
f8ca45f0f2 Add Element.animate and Animation
These are dummy implementations, but they do expose the ready and finished
promise, and do resolve the finished promise, so it should unblock basic cases.
2025-07-15 18:58:58 +08:00
Karl Seguin
4bf92a34f6 Don't queue data to send after we've initiated a disconnect of the client 2025-07-15 17:58:57 +08:00
Karl Seguin
4f1c84004a dummy performance clearMarks and clearMeasures 2025-07-15 12:11:28 +08:00
Karl Seguin
1bd430598d add HTMLStyleElement.get_sheet 2025-07-15 10:59:59 +08:00
Karl Seguin
3bc654bf97 Merge pull request #890 from lightpanda-io/xhr_cant_block_sync_requests
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Always make sure we have 1 free http state available for synchronous …
2025-07-15 10:08:54 +08:00
Karl Seguin
3906acb83d fix test 2025-07-14 18:42:25 +08:00
Karl Seguin
cfd62ac137 Add a --noscript option to "improve" --dump
Currently, fetch --dump includes <script> tag (either inline or with src). I
don't know what use-case this is the desired behavior. Excluding them, via the
new --noscript option has benefit that if you --dump --noscript and open the
resulting page in the browser, you don't re-execute JavaScript, which is
likely to break the page.

For example, opening a --dump of github makes it look like the page is broken
because it re-executes JavaScript that isn't meant to be re-executed.

Similarly, opening a --dump in a browser might execute JavaScript that
lightpanda browser failed to execute, making it looks like it worked better
than it did.
2025-07-14 18:24:36 +08:00
Karl Seguin
cd540dfae9 Support params for setTimeout and setInterval 2025-07-14 17:42:53 +08:00
Karl Seguin
74ad9ec8bf Add childElementCount and children to DocumentFragment
Also, when shadowRoot is re-attached to an element, clear all existing children
(like we're supposed to)
2025-07-14 17:01:11 +08:00
Karl Seguin
4f8a3fe5b9 Always make sure we have 1 free http state available for synchronous requests
If it wasn't for the fact that the HTTP client is likely going to see a major
refactor, it would definitely be time to create a specific state instance for
synchronous requests.
2025-07-14 16:41:26 +08:00
Karl Seguin
09ca0e6ef0 Add support for CDP's DOM.requestChildNodes
https://github.com/lightpanda-io/browser/issues/866
2025-07-14 15:13:01 +08:00
Karl Seguin
fae2b5acfa Noop CDP methods that go-rod requires
go-rod appears to stop processing when it receives an error, such as
UnknownMethod. Added placeholder handlers for Network.setUserAgentOverride and
Page.stopLoading.

Setting a custom user agent is something still being discussed, so no-oping it
seems reasonable. And, due to the currently synchronous nature of the initial
page load, no-oping stopLoading also seems reasonable.

https://github.com/lightpanda-io/browser/issues/867
2025-07-14 11:21:02 +08:00
Karl Seguin
d35a3eab6c Merge pull request #880 from lightpanda-io/webcomponents
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add basic ShadowRoot implementation, polyfill webcomponents
2025-07-14 11:10:40 +08:00
Karl Seguin
7f7f47497a Merge pull request #886 from lightpanda-io/scriptcompiler-compile
use ScriptCompiler to compile script
2025-07-14 11:07:29 +08:00
Karl Seguin
eb14ac3741 update build.zig.zon v8 version 2025-07-14 11:00:01 +08:00
Karl Seguin
22334faba3 update zig-v8-fork lib version 2025-07-14 10:51:27 +08:00
Karl Seguin
d08fd297e8 Merge pull request #881 from lightpanda-io/window_queueMicrotask
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add window.queueMicrotask
2025-07-13 09:03:57 +08:00
Pierre Tachoire
0dd664bfbf use ScriptCompiler to compile script 2025-07-12 12:09:16 -07:00
Karl Seguin
1602932d72 Add a "pre" polyfill
This is always run, but only the full webcomponents polyfill, it's very
small and isn't intrusive. This introduces a layer of indirection so that,
if the full polyfill is loaded, its monkeypatched constructor will be called
2025-07-12 19:49:19 +08:00
Karl Seguin
98c8b7d2b0 Merge pull request #875 from lightpanda-io/async_forward_proxy_to_tls
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Fix async https requests over a http forward proxy
2025-07-11 17:53:53 +08:00
Karl Seguin
b9ae24c42d add window.queueMicrotask 2025-07-11 17:46:39 +08:00
Karl Seguin
b387fd2bd4 Update src/http/client.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-11 17:38:31 +08:00
Karl Seguin
818f4540fd Add basic ShadowRoot implementation, polyfill webcomponents 2025-07-11 17:32:01 +08:00
sjorsdonkers
49a97dbb66 fix callback crash with Node.Union
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
2025-07-11 10:05:44 +02:00
sjorsdonkers
a8b72c1d5f Separate NodeIterator impl, fix _filter 2025-07-11 10:05:44 +02:00
sjorsdonkers
765b8dc97b NodeIterator 2025-07-11 10:05:44 +02:00
sjorsdonkers
5123697afe EventTarget internal type for all 2025-07-11 09:55:16 +02:00
sjorsdonkers
2a2a9d7941 EventTarget InternalType 2025-07-11 09:55:16 +02:00
sjorsdonkers
2873aa5f81 EventTarget constructor 2025-07-11 09:55:16 +02:00
Karl Seguin
795c925ba1 Revert "Update src/http/client.zig"
This reverts commit 4a12d045e4.
2025-07-11 09:49:40 +08:00
Karl Seguin
d6ace3f695 Merge pull request #863 from lightpanda-io/innerHTML_head
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Setting innerHTML now captures head elements
2025-07-11 08:03:14 +08:00
Karl Seguin
dd04759de7 Merge pull request #869 from lightpanda-io/performance_observer
more PerformnaceObserver placeholders
2025-07-11 08:03:01 +08:00
Pierre Tachoire
10fbde84ba Merge pull request #879 from lightpanda-io/css-parser-error
Fix parser identifier with escaped string
2025-07-10 16:10:14 -07:00
Pierre Tachoire
2b5652e1e4 wip 2025-07-10 16:01:36 -07:00
Pierre Tachoire
18796ae44e css: allow escaped first char in identifier name 2025-07-10 15:44:04 -07:00
Pierre Tachoire
a67692dc29 Merge pull request #877 from lightpanda-io/visible-pseudoclass
Visible Psuedoclass
2025-07-10 14:18:10 -07:00
Muki Kiboigo
1efd756a55 add visible pseudoclass 2025-07-10 12:40:44 -07:00
Pierre Tachoire
29671acdb6 Merge pull request #847 from lightpanda-io/name-property-handler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
enable conditionnal loading for polyfill
2025-07-10 09:18:29 -07:00
Karl Seguin
e82240a60e Setting innerHTML now captures head elements
I couldn't find where the behavior is described. AND, browsers seem to behave
differently depending on the state of the page (blank document vs actual page).

Still, some sites use innerHTML to load <script> tags, and, in libdom at least,
these are created in the implicit head. We cannot just copy the body nodes. To
keep it simple, I now copy all head and body elements.
2025-07-10 22:19:53 +08:00
Karl Seguin
72083c8614 Merge pull request #868 from lightpanda-io/element_hasAttributes_fix
Fix element.hasAttributes
2025-07-10 21:46:33 +08:00
Karl Seguin
8c2c1e534c Merge pull request #865 from lightpanda-io/document_domain
Fix document.domain
2025-07-10 21:46:15 +08:00
Karl Seguin
bfc01d957b Merge pull request #874 from lightpanda-io/document_styleSheets
add dummy document.get_styleSheets
2025-07-10 21:46:00 +08:00
Karl Seguin
4a12d045e4 Update src/http/client.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-10 17:10:58 +08:00
Karl Seguin
2d78b2c219 add TODO note for dummy implementation 2025-07-10 17:03:51 +08:00
Karl Seguin
3049bb0b9f Fix async https requests over a http forward proxy
XHR requests to https (which is most XHR requests) currently don't work with
the implementation proxy because of this.
2025-07-10 16:27:09 +08:00
Karl Seguin
34ab8152fb add dummy document.get_styleSheets 2025-07-10 13:45:49 +08:00
Karl Seguin
fb58c50fb7 Merge pull request #870 from lightpanda-io/popover_open_pseudo_selector
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Accept popover-over pseudo selector
2025-07-10 08:27:43 +08:00
Pierre Tachoire
955f917015 Merge pull request #873 from lightpanda-io/macos-build
ci: fix macos version for building
2025-07-09 15:35:09 -07:00
Pierre Tachoire
12c7df98e4 ci: fix macos version for building 2025-07-09 15:26:07 -07:00
Pierre Tachoire
889c29a163 Merge pull request #871 from lightpanda-io/ws-http-max
ws: increase max http message from 2kb to 4kb
2025-07-09 15:13:50 -07:00
Pierre Tachoire
886c1370e7 ws: increase max http message from 2kb to 4kb 2025-07-09 15:02:40 -07:00
Karl Seguin
febcc0a673 Merge pull request #864 from lightpanda-io/link_href
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
add HTMLElementLink get/set href
2025-07-09 18:48:17 +08:00
Karl Seguin
98cad6bf8d Accept popover-over pseudo selector
Optimize pseudo-selector parsing. Make comparison case insensitive, bucket
comparisons by length, and process input as integers.
2025-07-09 18:45:28 +08:00
Karl Seguin
7e5daedc8c more PerformnaceObserver placeholders 2025-07-09 18:10:23 +08:00
Karl Seguin
da3fe6f7ea fix test 2025-07-09 17:41:05 +08:00
Karl Seguin
f612ce262f Update src/browser/html/elements.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-09 16:16:45 +08:00
Karl Seguin
24ccfca279 Fix element.hasAttributes
libdom's hasAttributes is based on the type. Elements, according to libdom,
always have attributes, thus hasAttributes always return true, even when the
element in question has no attribute. Change our _hasAttributes to only return
true if the attribute count > 0.
2025-07-09 16:14:53 +08:00
Karl Seguin
34b3c3982b Fix document.domain
Currently seems to always return null. Doesn't seem to be a way in libdom to
change this. The property is deprecated, and MDN recommends using location.host
instead, so change document.get_domain to wrap location.host.
2025-07-09 14:29:05 +08:00
Karl Seguin
7f732c94da add HTMLElementLink get/set href 2025-07-09 13:28:32 +08:00
Karl Seguin
bdc49a65aa Merge pull request #859 from lightpanda-io/document_fragment_query_selector
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add querySelect and querySelectorAll to DocumentFragment
2025-07-09 10:25:35 +08:00
Karl Seguin
73d82dd0ba I guess we can't use the call_arena for querySelectorAll 2025-07-09 10:19:16 +08:00
Karl Seguin
dfa4403c8a arena -> call_arena for querySelectorAll 2025-07-09 10:11:26 +08:00
Karl Seguin
b8f3b19499 Merge pull request #857 from lightpanda-io/improved_native_proto
Improve prototype resolution for native types
2025-07-09 10:01:38 +08:00
Karl Seguin
448718d112 Merge pull request #858 from lightpanda-io/callback_with_new_this
Allow JS Callback to be called with a previously-unseen this.
2025-07-09 09:34:14 +08:00
Pierre Tachoire
6de55df4bc Merge pull request #856 from lightpanda-io/resize_observer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add dummy ResizeObserver
2025-07-08 15:50:08 -07:00
Pierre Tachoire
189fe26667 Merge pull request #862 from lightpanda-io/macos-14
ci: use macos-14 for nightly builds
2025-07-08 15:49:47 -07:00
Pierre Tachoire
7230884116 ci: use macos-14 for nightly builds 2025-07-08 08:27:45 -07:00
Karl Seguin
d7fba81f8f Add querySelect and querySelectorAll to DocumentFragment 2025-07-08 19:24:35 +08:00
Karl Seguin
29ac13185c Allow JS Callback to be called with a previously-unseen this. 2025-07-08 19:17:59 +08:00
Karl Seguin
3a49ee83ce Improve prototype resolution for native types
Prototype resolution of Zig types previously had 2 limitations (bug?). The first
was that the Zig prototype chain could only be 1 deep. You couldn't do A->B->C
where each of those was a Zig type (but you could do A->B->C->D->E ... so long
as every other type was a C opaque value).

The other limitation was that Zig prototypes only worked when the nested field
was directly embedded in the struct (i.e. not a pointer). So you could do:

```zig
const X = struct {
   proto: XParent,
};
```

But not:

```zig
const X = struct {
   proto: *XParent,
};
```

This addresses both limitations. The first issue is solved by keeping track
of the cumulative offset
2025-07-08 18:37:24 +08:00
Karl Seguin
95cbbc3b45 add dummy ResizeObserver 2025-07-08 18:35:25 +08:00
Karl Seguin
2a5c7d139f Merge pull request #855 from lightpanda-io/zig_fmt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig fmt
2025-07-08 18:34:14 +08:00
Karl Seguin
b74863873b zig fmt 2025-07-08 18:28:21 +08:00
Karl Seguin
7b46fe9cc8 Merge pull request #848 from lightpanda-io/fix_insecure_forward_proxy
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix non-tls forward-proxy
2025-07-08 09:52:23 +08:00
Karl Seguin
afc8c69a82 Merge pull request #854 from lightpanda-io/remove_debug_log
remove std.debug.print
2025-07-08 09:39:19 +08:00
Karl Seguin
38bbad6e88 Revert "fix secure connection logic"
This reverts commit b6132f2497.
2025-07-08 09:33:53 +08:00
Karl Seguin
1df47fd415 remove std.debug.print 2025-07-08 09:33:19 +08:00
Pierre Tachoire
faf21c5fff Merge pull request #853 from lightpanda-io/typo-fix
typo fix
2025-07-07 17:24:28 -07:00
Karl Seguin
2aee580795 Merge pull request #849 from lightpanda-io/mutation_observer_loop
Rework MutationObserver callback.
2025-07-08 08:15:02 +08:00
Pierre Tachoire
404c027546 typo fix 2025-07-07 17:14:52 -07:00
Karl Seguin
04e59c6df2 Merge pull request #850 from lightpanda-io/set_attribute_value
Attribute.set_value uses element, if possible
2025-07-08 08:14:52 +08:00
Karl Seguin
835042b794 Merge pull request #851 from lightpanda-io/add_event_listener_signal
Add support for the signal option of addEventListener
2025-07-08 08:14:38 +08:00
Pierre Tachoire
907490e266 Merge pull request #852 from lightpanda-io/katie-lpd-patch-1
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Update README.md
2025-07-07 17:01:09 -07:00
Pierre Tachoire
80fe167646 Update README.md 2025-07-07 17:00:54 -07:00
katie-lpd
d30631f991 Apply suggestions from code review
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-07-07 16:59:07 -07:00
katie-lpd
8956ab85f9 Update README.md 2025-07-07 16:50:32 -07:00
Pierre Tachoire
2cdc9e9f5f cdp: use a polyfill loader per isolate 2025-07-07 16:31:54 -07:00
Pierre Tachoire
13c623755c js: remove existing unknown property debug
Because it will be displayed only if the property is non-native.
So if your property is set in pureJS, you will still have the log...
2025-07-07 16:31:54 -07:00
Pierre Tachoire
bdfceec520 refacto a bit the missing callback into polyfill
Add a debug global unknown property
2025-07-07 16:31:53 -07:00
Pierre Tachoire
941dace7f9 enable conditionnal loading for polyfill 2025-07-07 16:31:53 -07:00
Karl Seguin
07693e54af Add support for the signal option of addEventListener 2025-07-07 20:56:19 +08:00
Karl Seguin
b6132f2497 fix secure connection logic 2025-07-07 19:56:21 +08:00
Karl Seguin
b3fe3d02c9 Attribute.set_value uses element, if possible
Only when setAttribute is called directly on the element, does libdom raise
a `DOMAttrModified` event (which MutationObserver uses).

From what I can tell, libdom's element set attribute _does_ rely on the
underlying attribute set value, so the behavior should be pretty close, it just
does extra things on top of that.
2025-07-07 19:47:17 +08:00
Karl Seguin
e880b18bb1 Rework MutationObserver callback.
Previously, MutationObserver callbacks where called using the `jsCallScopeEnd`
mechanism. This was slow and resulted in records split in a way that callers
might not expect. `jsCallScopeEnd` has been removed.

The new approach uses the loop.timeout mechanism, much like a window.setTimeout
and only registers a timeout when events have been handled. It should perform
much better.

Exactly how MutationRecords are supposed to be grouped is still a mystery to me.
This new grouping is still wrong in many cases (according to WPT), but appears
slightly less wrong; I'm pretty hopeful clients don't really have hard-coded
expectations for this though.

Also implement the attributeFilter option of MutationObserver. (Github)
2025-07-07 19:29:10 +08:00
Karl Seguin
74a299eef7 Fix non-tls forward-proxy 2025-07-07 11:03:04 +08:00
Karl Seguin
300428ddfb Merge pull request #840 from lightpanda-io/xhr_readystatechange
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add readystate change event to XHR
2025-07-06 08:59:19 +08:00
Pierre Tachoire
1c27f8251e Merge pull request #846 from lightpanda-io/e2e-draft
ci: don't run 2e2 on draft
2025-07-05 16:46:04 -07:00
Pierre Tachoire
92badd3722 ci: don't run 2e2 on draft 2025-07-05 14:22:21 -07:00
Karl Seguin
8a80f0b3dd Merge pull request #843 from lightpanda-io/empty_anchor_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
An empty anchor should return empty strings for its getters
2025-07-04 23:43:10 +08:00
Karl Seguin
fcc74b63d3 correct comment 2025-07-04 23:17:48 +08:00
Karl Seguin
d7155e6662 An empty anchor should return empty strings for its getters
document.createElement('a').host  or .href or .. should return an empty string.

However, URL.constructor(document.createElement('a')) should fail.

Because HTMLAnchorElement uses URL.constructor, we have the wrong behavior.

This adds a guard for an empty anchor. This might not cover all of the cases
which are valid for an anchor but invalid for a URL.constructor, but it's
the most common.
2025-07-04 19:23:19 +08:00
Karl Seguin
42c3841639 Merge pull request #842 from lightpanda-io/fix_elementFromPoint_crash
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Rely on js.zig for float->int translation
2025-07-04 19:12:46 +08:00
Karl Seguin
c331713401 set correct state on xhr.abort and send correct events 2025-07-04 19:12:26 +08:00
Karl Seguin
002d9c1747 Merge pull request #841 from lightpanda-io/scroll_events
make window.scrollTo triggers scroll and scrollend events
2025-07-04 19:00:28 +08:00
Karl Seguin
2885ceceb1 document use of i32 2025-07-04 18:55:14 +08:00
sjorsdonkers
22a644ba01 rename tls_in_tls to tlsproxy 2025-07-04 10:00:22 +02:00
sjorsdonkers
bab120a75d secure changes 2025-07-04 10:00:22 +02:00
Francis Bouvier
7a07c82f06 https-proxy: update upstream tls.zig 2025-07-04 10:00:22 +02:00
sjorsdonkers
e881d2f6cf tls proxy tweaks 2025-07-04 10:00:22 +02:00
Francis Bouvier
c8d003a08f https-proxy: update tls.zig 2025-07-04 10:00:22 +02:00
Francis Bouvier
e2cc404571 Handle TLS proxy, both for HTTP and HTTPS (tls in tls) endpoints 2025-07-04 10:00:22 +02:00
sjorsdonkers
be71eaae47 TLS connect proxy WIP 2025-07-04 10:00:22 +02:00
Karl Seguin
ed31a452b2 Rely on js.zig for float->int translation
Not only does this ensure compatibility with browsers, it doesn't crash when
the value is NaN of Infinity.
2025-07-04 11:34:34 +08:00
Pierre Tachoire
f51ee7f3a0 Merge pull request #829 from lightpanda-io/pumpmessageloop
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add pump message loop calls
2025-07-03 10:11:26 -07:00
Pierre Tachoire
9d1dc97766 remove useless debug log 2025-07-03 09:49:01 -07:00
Pierre Tachoire
b78729f685 test: inject platform to the serveCDP app 2025-07-03 09:49:00 -07:00
Pierre Tachoire
44a76e59f9 run pumpmessageloop in its own loop 2025-07-03 09:49:00 -07:00
Pierre Tachoire
1504e36a68 use comptime test for platform existence 2025-07-03 09:49:00 -07:00
Pierre Tachoire
80348ef190 fix wpt tests with platform requirement 2025-07-03 09:48:59 -07:00
Pierre Tachoire
a3c14748d3 fix unit testing with platform deps requirement 2025-07-03 09:48:59 -07:00
Pierre Tachoire
3c0143af92 add runIdleTasks 2025-07-03 09:48:57 -07:00
Pierre Tachoire
22a93a9c39 add pump message loop calls 2025-07-03 09:47:50 -07:00
Karl Seguin
e8866a6431 Merge pull request #838 from lightpanda-io/improved_js_value_printing
Improve JS value printing
2025-07-04 00:30:35 +08:00
Karl Seguin
455ed79872 Remove HTTP client generic Loop parameter
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
I think we initially thought we might need different clients for different
parts of the system, each with a unique loop  (e.g. we thought telemetry might
need some isolation). But that never happened, so it's just needless now,
especially since the async connect uses the non-generic *Loop type directly.
2025-07-03 15:10:47 +02:00
Karl Seguin
3d17c531d7 make window.scrollTo triggers scroll and scrollend events 2025-07-03 19:37:07 +08:00
Karl Seguin
dfe90243d6 Add readystate change event to XHR
Deal with non-node current target crashing. Builds ontop of abort_signal, but
with the new event-target specific union, I think this will work in for all
future cases.
2025-07-03 19:32:24 +08:00
Karl Seguin
bf1db50667 Merge pull request #839 from lightpanda-io/build_time
improve build times (a little)
2025-07-03 15:34:53 +08:00
Pierre Tachoire
a2565a7c83 range: add detach function 2025-07-03 09:16:36 +02:00
Pierre Tachoire
947d01a3c0 range starts and ends with the global document by default 2025-07-03 09:16:36 +02:00
Karl Seguin
be11d82c9c improve build times (a little) 2025-07-03 13:56:01 +08:00
Karl Seguin
7a0e7fff13 Improve JS value printing
Don't error on JSON.stringify failure (likely caused by circular reference).

In debug mode, try to print [slightly] more meaningful value representation
when default serialization results in [object Object].
2025-07-03 10:35:09 +08:00
Karl Seguin
81fb71b7f7 Merge pull request #830 from lightpanda-io/SetHostInitializeImportMetaObjectCallback
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Implement ImportMeta callback
2025-07-03 09:17:47 +08:00
Karl Seguin
b10f5ec99f bump zig-v8-fork version 2025-07-02 13:38:01 +08:00
Karl Seguin
5abe7bdeef Merge pull request #831 from lightpanda-io/log_invalid_cookie_expiry
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
use logger for invalid cookie expiry
2025-07-02 10:11:20 +08:00
Karl Seguin
54be651415 Merge pull request #832 from lightpanda-io/range_selectNodeContents
range.selectNodeContents
2025-07-02 10:11:01 +08:00
Pierre Tachoire
cdbf6d7ae7 Merge pull request #834 from lightpanda-io/arm-generic
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
ARM generic
2025-07-01 12:08:30 -07:00
Pierre Tachoire
50349edf4d ci: use a generic target for arm build 2025-07-01 07:55:32 -07:00
sjorsdonkers
da307c1b40 range.selectNodeContents 2025-07-01 15:11:01 +02:00
Karl Seguin
b50b96bd1d Implement ImportMeta callback
The first time `import.meta` is called within a module, this callback is called
and we can populate it with whatever fields we want. For WebAPI, the important
field is `url`:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta

Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/80
2025-07-01 15:59:24 +08:00
Karl Seguin
92654fc5aa use logger for invalid cookie expiry 2025-07-01 12:25:25 +08:00
Pierre Tachoire
36b2de216b Merge pull request #828 from lightpanda-io/arm-compat
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Arm compat
2025-06-30 11:53:06 -07:00
Pierre Tachoire
8745c1016e ci: use cpu cortex_a72 for arm build 2025-06-30 10:58:15 -07:00
Pierre Tachoire
f5a58c1ff0 Merge pull request #826 from lightpanda-io/upgrade-v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Upgrade v8
2025-06-29 10:54:46 -07:00
Pierre Tachoire
d9e72049ae ci: use ubuntu 22.04 for arm64 build 2025-06-29 10:48:04 -07:00
Pierre Tachoire
927ca01161 upgrade zig v8 version 2025-06-29 10:47:03 -07:00
Pierre Tachoire
3ea8d0b01c Merge pull request #824 from lightpanda-io/dom-non-html
create a DOM tree for non-html files
2025-06-29 10:44:26 -07:00
Karl Seguin
c52d33e331 Merge pull request #822 from lightpanda-io/undefined_or
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add UndefinedOr(T) union
2025-06-28 09:09:45 +08:00
Karl Seguin
fd36606acc change field order 2025-06-28 09:02:12 +08:00
Karl Seguin
1c6f4a79e0 Merge pull request #821 from lightpanda-io/abort_controller
Abort controller
2025-06-28 09:00:07 +08:00
Pierre Tachoire
7896d274a3 create a DOM tree for non-html files too. 2025-06-27 12:17:03 -07:00
Pierre Tachoire
6937c8ecb4 Merge pull request #823 from lightpanda-io/atob_btoa
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add atob and btoa
2025-06-27 09:21:41 -07:00
Karl Seguin
f02b9566c5 add atob and btoa 2025-06-27 18:36:29 +08:00
Karl Seguin
c9936c2b7e Add UndefinedOr(T) union
Some apis want a value or undefined. For these, we can't use an Optional
return type, null maps to JS null. Adds an Env.UndefinedOr(T) generic
union for such return types.
2025-06-27 17:55:13 +08:00
Karl Seguin
bbd9e5e07c add AbortController API 2025-06-27 17:31:25 +08:00
sjorsdonkers
476fb7ec4e DOMException constructor
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
2025-06-27 08:13:43 +02:00
Karl Seguin
7435274be2 Merge pull request #819 from lightpanda-io/update_tls_lib
Upgrade tlz.zig to latest version
2025-06-27 13:45:28 +08:00
Karl Seguin
08d2ea6a10 abort controller 2025-06-27 13:14:35 +08:00
Karl Seguin
41b7ed6938 Upgrade tlz.zig to latest version
Was seeing pretty frequent TLS errors on reddit. I think I had the wrong max
TLS record size, but figured this was an opportunity to upgrade tls.zig, which
has seen quite a few changes since our last upgrade.

Specifically, the nonblocking TLS logic has been split into two structs: one
for handshaking, and then another to be used to encrypt/decrypt after the h
andshake is complete. The biggest impact here is with respect to keepalive,
since what we want to keepalive is the connection post-handshake, but we don't
have this object until much later.

There was also some general API changes, with respect to state and partially
encrypted/decrypted data which we must now maintain.
2025-06-27 13:14:12 +08:00
Pierre Tachoire
7a311a181b Merge pull request #820 from lightpanda-io/ci-e2e
ci: use hetzner for 2e2 regression perf
2025-06-26 22:12:34 -07:00
Pierre Tachoire
ddcb597710 ci: use hetzner for 2e2 regression perf 2025-06-26 17:37:35 -07:00
Pierre Tachoire
9c75f29875 ci: optimize 2e2 build 2025-06-26 17:04:05 -07:00
Pierre Tachoire
343f3885f7 Merge pull request #817 from lightpanda-io/script_tag_dump
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
dump script tag's text content as-is
2025-06-26 11:27:04 -07:00
Karl Seguin
ed7dfeab84 dump script tag's text content as-is 2025-06-26 12:41:22 +08:00
Karl Seguin
8de27b3674 Merge pull request #813 from lightpanda-io/crypto_get_random_values_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Crypto.getRandomValues consistency
2025-06-26 11:43:39 +08:00
Karl Seguin
f56b0a5f6d Merge branch 'main' into crypto_get_random_values_fix 2025-06-26 10:25:53 +08:00
Karl Seguin
0a27e1254f Merge pull request #814 from lightpanda-io/root_module_nested_modules
Allow root modules to imported modules
2025-06-26 10:25:10 +08:00
Karl Seguin
3f9b256fcb Merge pull request #812 from lightpanda-io/identity_map_collision
We cannot have empty Zig structs mapping to JS instances
2025-06-26 10:24:09 +08:00
Karl Seguin
9ea9859150 Merge pull request #809 from lightpanda-io/html_element_dataset
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add element.dataset API
2025-06-26 09:18:19 +08:00
Pierre Tachoire
03e3f95d2e Merge pull request #810 from lightpanda-io/proxy-authentication
basic/bearer proxy authentication
2025-06-25 17:31:47 -07:00
Pierre Tachoire
e721b0af92 Merge pull request #816 from lightpanda-io/connect_proxy
Connect proxy
2025-06-25 17:31:27 -07:00
Karl Seguin
e18c589de3 Allow root modules to imported modules
Root modules (non-cacheable) should register their module_id -> URL so that,
if they load a nested module, we can get the full URL of the nested module.
2025-06-25 18:20:55 +08:00
sjorsdonkers
aea34264a9 basic/bearer testing 2025-06-25 12:04:38 +02:00
Karl Seguin
8d3a04235d Crypto.getRandomValues consistency
Crypto.getRandomValues should mutate the given parameter as well as return
the value. This return value must be the same (JsObject) as the input parameter.

There might be more magical ways to solve this, but I opted for both the
simplest and most flexible: adding a `toZig` function to JsObject which does
what js.zig does internally when mapping js values to Zig (and, of course, it
uses the same code).

This allows a caller to receive a JsObject (not too common, but we already do
that in a few places) and return that same JsObject (again, not too common, but
we do have support for returning JsObject directly already). With the main
addition that the JsObjet can now be turned into a Zig type by the caller.
2025-06-25 18:03:26 +08:00
Karl Seguin
9c4088b24c We cannot have empty Zig structs mapping to JS instances
An empty struct will share the same address as its sibling (1) which will cause
an collision in the identity map.

(1) - This depends on Zig's non-guaranteed layout, so the collision might not
be with its sibling, but rather some other [seemingly random] field.
2025-06-25 14:58:09 +08:00
Karl Seguin
1e7ee4e0a1 proxy_type 'simple' renamed to 'forward' 2025-06-25 12:21:44 +08:00
Karl Seguin
ec92f110b3 Change dataset to work directly off DOM element 2025-06-25 12:16:08 +08:00
Karl Seguin
2aa5eb85ad Add element.dataset API
Uses the State to store the dataset, but, on first load, loads the data
attributes from the DOM.
2025-06-25 12:16:08 +08:00
Karl Seguin
2815f02382 Merge pull request #811 from lightpanda-io/crypto-getrandomvalues-return
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Return Random Array from crypto.GetRandomValues
2025-06-25 07:45:04 +08:00
Karl Seguin
8bd7c8dd41 Merge pull request #807 from lightpanda-io/css-util-iface
add CSS utility interface
2025-06-25 07:44:15 +08:00
Karl Seguin
269dcf071f Merge pull request #806 from lightpanda-io/document-range
add AbstractRange and Range
2025-06-25 07:41:52 +08:00
Karl Seguin
997ec7f0bc Merge pull request #805 from lightpanda-io/performance-mark
add PerformanceEntry and PerformanceMark
2025-06-25 07:41:19 +08:00
Muki Kiboigo
d9c26bb77f return array in crypto.getRandomValues 2025-06-24 15:01:32 -07:00
Muki Kiboigo
c0fc3a19c8 add CSS utility interface 2025-06-24 13:55:47 -07:00
Muki Kiboigo
ce638c39e3 add AbstractRange and Range 2025-06-24 12:06:50 -07:00
Muki Kiboigo
6b651cd5e4 add PerformanceEntry and PerformanceMark 2025-06-24 12:04:28 -07:00
sjorsdonkers
4560f31010 basic/bearer proxy authentication 2025-06-24 16:38:58 +02:00
Karl Seguin
c97a32e24b Initial work on CONNECT proxy.
Cannot currently connect to the proxy over TLS (though, once connected, it can
connect to the actual site over TLS). No support for authentication.
2025-06-24 15:10:20 +08:00
Karl Seguin
8a005bc5a1 Merge pull request #808 from lightpanda-io/accept-header
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
http: send an Accept: */* header
2025-06-24 09:42:14 +08:00
Pierre Tachoire
20aabee72e http: send an Accept: */* header 2025-06-23 18:18:04 -07:00
Karl Seguin
a00c2345ee Merge pull request #802 from lightpanda-io/endless_loop_fix_and_dot_slash_stitch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Don't allow timeouts to be registered when shutting down
2025-06-24 08:57:59 +08:00
Karl Seguin
cb35b3624a Merge pull request #803 from lightpanda-io/inline_module_no_cache
Fix module caching
2025-06-24 08:55:23 +08:00
Karl Seguin
c6f59a7aa6 Merge pull request #804 from lightpanda-io/add_error_event_web_api
add ErrorEvent webapi
2025-06-24 08:54:01 +08:00
Karl Seguin
bf296ad797 add ErrorEvent webapi 2025-06-23 19:04:59 +08:00
Karl Seguin
256540934b reject long timeouts as we're shutting down 2025-06-23 17:27:22 +08:00
Karl Seguin
3c07c0818d improve variable names 2025-06-23 17:19:30 +08:00
Karl Seguin
a01d18ace1 Fix module caching
In https://github.com/lightpanda-io/browser/pull/798 module caching was added.
This was necessary as the same module loaded multiple time should result in the
same v8 module instance.

To make this work, modules became cached by their full URL. The full URL of one
module was also used to determine the full URL of nested modules (full url +
specifier).

With inline scripts, the page URL was used as the full URL. While this is
correct when resolving nested modules, it's incorrect for caching the module
itself. Two inline modules on a page share the same URL, but they aren't the
same and should be cached.

To fix this, inline modules still inherit the page URL, in order to resolve the
correct URL for nested modules, but are themselves never cached.
2025-06-23 17:10:54 +08:00
Karl Seguin
55e02f01dc fix wpt runner 2025-06-23 16:47:31 +08:00
Karl Seguin
fe6ccad485 loop.run now takes a maximum wait time 2025-06-23 16:43:28 +08:00
Karl Seguin
11fe79312d Don't allow timeouts to be registered when shutting down
Currently, a timeout that sets a timeout can cause loop.run to block forever.

Also, cleanup URL stitch with leading './'. The resulting URL was valid, but
since we use the URL as the module cache key, it's important to resolve various
representations of the same URL in the same way.
2025-06-23 15:01:58 +08:00
Karl Seguin
bdb2338b5b Merge pull request #796 from lightpanda-io/docker
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
update dockerfile to multi-arch
2025-06-23 12:51:20 +08:00
Karl Seguin
bbafb048d0 Merge pull request #798 from lightpanda-io/module_loading
Fix module loading
2025-06-23 08:54:17 +08:00
Karl Seguin
9fc2fa51bd Merge pull request #797 from lightpanda-io/template_content
add HTML Template's content attribute
2025-06-23 08:54:00 +08:00
Karl Seguin
d8ec50345a Fix module loading
When V8 calls the ResolveModuleCallback that we give it, it passes the specifier
which is essentially the string given to `from`:

```
import {x} from './blah.js';
```

We were taking that specifier and giving it to the page. The page knew the
currently executing script, an thus could resolve the full URL. Given the full
URL, it could either return the JS content from its module cache or fetch
the source.

At best though, this isn't efficient. If two files import the same module, yes
we cache the src, but we still ask v8 to re-compile it. At worse, it crashes
due to resource exhaustion in the case of cyclical dependencies.

ResolveModuleCallback should instead detect that it has already loaded the
module and return the previously loaded module. Essentially, we shouldn't be
caching the JavaScript source, we should be caching the v8 module.

However, in order to do this, we need more than the specifier, which might only
be a relative path (and thus isn't unique). So, in addition to a module cache,
we now also maintain an module identifier lookup. Given a module, we can get
its full path. Thankfully ResolveModuleCallback gives us the referring module,
so we can look up that modules URL, stitch it to the specifier, and get the
full url (the unique identifier) within the JS runtime.

Need more real world testing, and a fully working example before I celebrate,
but for sites with many import, this appears to improve performance by many
orders of magnitude.
2025-06-20 19:17:55 +08:00
Karl Seguin
9f1cc09ca8 add HTML Template's content attribute 2025-06-20 14:36:56 +08:00
Karl Seguin
5dcc3db36b Merge pull request #795 from lightpanda-io/performance_observer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add dummy PerformanceObserver
2025-06-20 08:01:09 +08:00
Pierre Tachoire
898b73ffc8 update dockerfile to multi-arch 2025-06-19 10:10:14 -07:00
Karl Seguin
c5d49a9d34 Add dummy PerformanceObserver
Adds a dummy PerformanceObserver. Only the supportedEntryTypes static attribute
is supported, and it currently returns an empty array. This hopefully prevents
code from trying to use it. For example, before using it, reddit checks if
specific types are supported and, if not, doesn't use it.

This introduced complexity in the js runtime. Our current approach to
attributes only works with primitive types. Non-primitive types can't be
attached to a FunctionTemplate (v8 will crash saying only primitive types can
be set). Plus, all non primitive types require a context to create anyways.

We now detect "primitive" attributes and "complex" attributes. Primitive
attributes are setup as before. Complex attributes are setup per-context,
requiring another loop through our types to detect & setup on each context
creation.
2025-06-19 18:20:02 +08:00
Karl Seguin
ef9f828d35 Merge pull request #790 from lightpanda-io/css-stylesheet
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Minimal CSSStyleSheet
2025-06-19 10:32:13 +08:00
Karl Seguin
c691764205 Merge pull request #794 from lightpanda-io/window-screen
add Screen and ScreenOrientation
2025-06-19 10:29:52 +08:00
sjorsdonkers
2c940d4fd6 browser context proxyServer 2025-06-19 10:26:33 +08:00
Karl Seguin
54bd55d45d fix CSSStyleSheet prototype 2025-06-19 10:25:13 +08:00
Karl Seguin
0b846b15b1 Merge pull request #789 from lightpanda-io/browsercontext-proxyServer
browser context proxyServer
2025-06-19 10:22:17 +08:00
Muki Kiboigo
269eb7e154 add Screen and ScreenOrientation 2025-06-18 12:53:54 -07:00
Muki Kiboigo
97bc19e4ae clean up various imports in CSSOM 2025-06-18 11:32:28 -07:00
Muki Kiboigo
2656cc7842 Add basic tests for CSSStyleSheet 2025-06-18 11:32:28 -07:00
Muki Kiboigo
ba94818415 add CSSStyleSheet 2025-06-18 11:32:27 -07:00
Pierre Tachoire
ac759a6eed Merge pull request #793 from lightpanda-io/domrect-bottom
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add top, left, bottom, right to DOMRect
2025-06-18 09:54:08 -07:00
Pierre Tachoire
1839b346a6 Merge pull request #792 from lightpanda-io/fix_current_script_scope
Fixes the scoping of page.current_script
2025-06-18 09:51:41 -07:00
Pierre Tachoire
c1ffe7f8e6 Merge pull request #791 from lightpanda-io/zig_event_target_fix
Fix crash when event.currentTarget is used with EventTargetTBase
2025-06-18 08:26:25 -07:00
Pierre Tachoire
833b4d10bd add top, left, bottom, right to DOMRect 2025-06-18 08:21:33 -07:00
Pierre Tachoire
ce98c336c9 keep EventTargetTBase as the dom_event_target
Mimic a dom_node by adding the refcnt field right after the vtable
pointer.
2025-06-18 07:08:35 -07:00
Karl Seguin
d05619990a Fixes the scoping of page.current_script
This was previously being set back to null before it was actually needed.

Also, added a more logs / log details.
2025-06-18 18:36:00 +08:00
Karl Seguin
8033e41d4a Merge pull request #788 from lightpanda-io/dont_keepalive_unprocess_request
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Delay setting the requests' keepalive flag until the request is fully…
2025-06-18 16:46:45 +08:00
sjorsdonkers
60f4eab759 handle no params 2025-06-18 10:07:37 +02:00
sjorsdonkers
d7656ea985 expires dashes and f64 2025-06-18 10:07:37 +02:00
sjorsdonkers
e402998577 JS may not set/get HttpOnly cookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
073f75efa3 CDP Network cookie tests 2025-06-18 10:07:37 +02:00
sjorsdonkers
da414f7eb3 CDP.Storage cookies tests 2025-06-18 10:07:37 +02:00
sjorsdonkers
270b89830a Cleaning up crumbles 2025-06-18 10:07:37 +02:00
sjorsdonkers
74ce7ca416 refactor path / domain parsing 2025-06-18 10:07:37 +02:00
sjorsdonkers
3f4338cb51 wip 2025-06-18 10:07:37 +02:00
sjorsdonkers
30ee41fd0e Network.getCookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
4965fec55c storage cookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
18dff8455c lower case domain 2025-06-18 10:07:37 +02:00
sjorsdonkers
fe16f06aee clearRetainingCapacity 2025-06-18 10:07:37 +02:00
sjorsdonkers
48c1c05a93 setCookie 2025-06-18 10:07:37 +02:00
sjorsdonkers
38dee1166d setCookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
0c6fc68eae deleteCookies 2025-06-18 10:07:37 +02:00
Karl Seguin
223611d89e Fix crash when event.currentTarget is used with EventTargetTBase
When EventTargetTBase is used, we pass the container as the target to libdom.
This is not safe, as libdom is expecting an event_target. We see, for example
that when _dom_event_get_current_target is called, the refcnt is increased.
This works if the current_target is a valid event_target, but if it's a
Zig instance (like the Window) ... we're just altering some bits of the
window instance.

This attempts to add a dummy target to EventTargetTBase which can acts as a
real event_targt in place of the Zig instance.
2025-06-18 14:49:15 +08:00
sjorsdonkers
6f5141d5fb browser context proxyServer 2025-06-17 18:43:12 +02:00
Karl Seguin
a6ac7d9c4e Delay setting the requests' keepalive flag until the request is fully processed
We currently set request._keepalive prematurely. There are [error cases] where
the request could be abandoned before being fully drained. While we do try to
drain in some cases, it isn't always possible. For this reason,
request.keepalive is only set at the end of the request lifecycle, at which
point we know the connection is ready to be re-used.
2025-06-17 19:55:36 +08:00
751 changed files with 122485 additions and 47661 deletions

View File

@@ -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.14.1'
arch:
description: 'CPU arch used to select the v8 lib'
required: false
@@ -17,15 +13,19 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.1.24'
default: 'v0.3.1'
v8:
description: 'v8 version to install'
required: false
default: '13.6.233.8'
default: '14.0.365.4'
cache-dir:
description: 'cache dir to use'
required: false
default: '~/.cache'
debug:
description: 'enable v8 pre-built debug version, only available for linux x86_64'
required: false
default: 'false'
runs:
using: "composite"
@@ -36,11 +36,13 @@ runs:
shell: bash
run: |
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
sudo apt-get install -y wget xz-utils ca-certificates clang make git
# Zig version used from the `minimum_zig_version` field in build.zig.zon
- uses: mlugg/setup-zig@v2
with:
version: ${{ inputs.zig }}
# Rust Toolchain for html5ever
- uses: dtolnay/rust-toolchain@stable
- name: Cache v8
id: cache-v8
@@ -49,32 +51,17 @@ runs:
cache-name: cache-v8
with:
path: ${{ inputs.cache-dir }}/v8
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}.a
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}${{inputs.debug == 'true' && '_debug' || '' }}.a
- if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }}
shell: bash
run: |
mkdir -p ${{ inputs.cache-dir }}/v8
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
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 }}${{inputs.debug == 'true' && '_debug' || '' }}.a
- name: install v8
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: libiconv
shell: bash
run: make install-libiconv
- name: build mimalloc
shell: bash
run: make install-mimalloc
- name: build netsurf
shell: bash
run: make install-netsurf
mkdir -p v8
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8${{inputs.debug == 'true' && '_debug' || '' }}.a

View File

@@ -5,8 +5,12 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
on:
push:
tags:
- '*'
schedule:
- cron: "2 2 * * *"
@@ -23,10 +27,10 @@ jobs:
OS: linux
runs-on: ubuntu-22.04
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
@@ -37,8 +41,11 @@ jobs:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -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 }}
@@ -53,15 +60,16 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-linux-aarch64:
env:
ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
runs-on: ubuntu-22.04-arm
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -75,8 +83,11 @@ jobs:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -91,15 +102,18 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-aarch64:
env:
ARCH: aarch64
OS: macos
runs-on: macos-latest
timeout-minutes: 15
# macos-14 runs on arm CPU. see
# https://github.com/actions/runner-images?tab=readme-ov-file
runs-on: macos-14
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -113,8 +127,11 @@ jobs:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -129,15 +146,16 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-x86_64:
env:
ARCH: x86_64
OS: macos
runs-on: macos-13
timeout-minutes: 15
runs-on: macos-14-large
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -151,8 +169,11 @@ jobs:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -167,4 +188,5 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true

View File

@@ -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 --log_level error & echo $! > LPD.pid
go run integration/main.go
kill `cat LPD.pid`

View File

@@ -45,8 +45,11 @@ jobs:
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
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
@@ -55,7 +58,7 @@ jobs:
- uses: ./.github/actions/install
- 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
@@ -65,56 +68,6 @@ jobs:
zig-out/bin/lightpanda
retention-days: 1
puppeteer-perf:
name: puppeteer-perf
needs: zig-build-release
env:
MAX_MEMORY: 30000
MAX_AVG_DURATION: 24
LIGHTPANDA_DISABLE_TELEMETRY: true
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 puppeteer
run: |
python3 -m http.server 1234 -d ./public & echo $! > PYTHON.pid
./lightpanda serve & echo $! > LPD.pid
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
kill `cat LPD.pid` `cat PYTHON.pid`
- name: puppeteer result
run: cat puppeteer.out
- name: memory regression
run: |
export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_MEMORY"
- name: duration regression
run: |
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
demo-scripts:
name: demo-scripts
needs: zig-build-release
@@ -140,15 +93,47 @@ jobs:
- name: run end to end tests
run: |
./lightpanda serve & echo $! > LPD.pid
go run runner/main.go --verbose
go run runner/main.go
kill `cat LPD.pid`
- name: build proxy
run: |
cd proxy
go build
- name: run end to end tests through proxy
run: |
./proxy/proxy & echo $! > PROXY.id
./lightpanda serve --http_proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
go run runner/main.go
kill `cat LPD.pid` `cat PROXY.id`
- name: run request interception through proxy
run: |
export PROXY_USERNAME=username PROXY_PASSWORD=password
./proxy/proxy & echo $! > PROXY.id
./lightpanda serve & echo $! > LPD.pid
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id`
cdp-and-hyperfine-bench:
name: cdp-and-hyperfine-bench
needs: zig-build-release
# Don't execute on PR
if: github.event_name != 'pull_request'
env:
MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools
# $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
# $ sudo mkdir -p /sys/fs/cgroup/actions-runner
# $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
CG_ROOT: /sys/fs/cgroup
CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
# use a self host runner.
runs-on: lpd-bench-hetzner
@@ -174,23 +159,67 @@ jobs:
go run ws/main.go & echo $! > WS.pid
sleep 2
- name: run lightpanda in cgroup
run: |
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing"
exit 1
fi
mkdir -p $CG_ROOT/$CG
cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid
sleep 2
- name: run puppeteer
run: |
./lightpanda serve & echo $! > LPD.pid
sleep 2
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
kill `cat LPD.pid`
PID=$(cat LPD.pid)
while kill -0 $PID 2>/dev/null; do
sleep 1
done
if [ ! -f $CG_ROOT/$CG/memory.peak ]; then
echo "memory.peak not available in $CG"
exit 1
fi
cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak
- name: puppeteer result
run: cat puppeteer.out
- name: cgroup memory regression
run: |
PEAK_BYTES=$(cat LPD.cg_mem_peak)
PEAK_KB=$((PEAK_BYTES / 1024))
echo "memory.peak_bytes=$PEAK_BYTES"
echo "memory.peak_kb=$PEAK_KB"
test "$PEAK_KB" -le "$MAX_CG_PEAK"
- name: virtual memory regression
run: |
export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_VmHWM"
- name: cleanup cgroup
run: rmdir $CG_ROOT/$CG
- name: duration regression
run: |
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
- name: json output
run: |
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'`
export LPD_VmHWM=`cat LPD.VmHWM`
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM}}" > bench.json
export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 ))
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json
cat bench.json
- name: run hyperfine
@@ -242,3 +271,19 @@ jobs:
- name: format and send json result
run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json
browser-fetch:
name: browser fetch
needs: zig-build-release
runs-on: ubuntu-latest
steps:
- name: download artifact
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- run: ./lightpanda fetch https://demo-browser.lightpanda.io/campfire-commerce/

View File

@@ -5,6 +5,7 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on:
schedule:
@@ -14,14 +15,14 @@ on:
workflow_dispatch:
jobs:
wpt:
name: web platform tests json output
wpt-build-release:
name: zig build release
runs-on: ubuntu-latest
timeout-minutes: 90
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
@@ -29,8 +30,85 @@ jobs:
- uses: ./.github/actions/install
- name: json output
run: zig build wpt -- --json > wpt.json
- 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
wpt-build-runner:
name: build wpt runner
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptrunner
CGO_ENABLED=0 go build
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: wptrunner
path: |
wptrunner/wptrunner
retention-days: 1
run-wpt:
name: web platform tests json output
needs:
- wpt-build-release
- wpt-build-runner
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
with:
ref: fork
repository: 'lightpanda-io/wpt'
fetch-depth: 0
# The hosts are configured manually on the self host runner.
# - name: create custom hosts
# run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
- name: generate manifest
run: ./wpt manifest
- name: download lightpanda release
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
uses: actions/download-artifact@v4
with:
name: wptrunner
- run: chmod a+x ./wptrunner
- name: run test with json output
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json
kill `cat WPT.pid`
- name: write commit
run: |
@@ -47,7 +125,7 @@ jobs:
perf-fmt:
name: perf-fmt
needs: wpt
needs: run-wpt
runs-on: ubuntu-latest
timeout-minutes: 15

View File

@@ -1,8 +1,5 @@
name: zig-fmt
env:
ZIG_VERSION: 0.14.1
on:
pull_request:
@@ -32,14 +29,13 @@ jobs:
timeout-minutes: 15
steps:
- uses: mlugg/setup-zig@v2
with:
version: ${{ env.ZIG_VERSION }}
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Zig version used from the `minimum_zig_version` field in build.zig.zon
- uses: mlugg/setup-zig@v2
- name: Run zig fmt
id: fmt
run: |
@@ -58,6 +54,7 @@ jobs:
fi
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
- name: Fail the job
if: steps.fmt.outputs.zig_fmt_errs != ''
run: exit 1

View File

@@ -12,8 +12,7 @@ on:
- main
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
@@ -38,11 +37,9 @@ on:
workflow_dispatch:
jobs:
zig-build-dev:
name: zig build dev
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
zig-test-debug:
name: zig test using v8 in debug mode
timeout-minutes: 15
runs-on: ubuntu-latest
@@ -54,36 +51,11 @@ jobs:
submodules: recursive
- uses: ./.github/actions/install
- name: zig build debug
run: zig build
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: lightpanda-build-dev
path: |
zig-out/bin/lightpanda
retention-days: 1
debug: true
browser-fetch:
name: browser fetch
needs: zig-build-dev
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: download artifact
uses: actions/download-artifact@v4
with:
name: lightpanda-build-dev
- run: chmod a+x ./lightpanda
- run: ./lightpanda fetch https://httpbin.io/xhr/get
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test:
name: zig test
@@ -104,7 +76,7 @@ jobs:
- uses: ./.github/actions/install
- name: zig build test
run: zig build test -- --json > bench.json
run: METRICS=true zig build -Dprebuilt_v8_path=v8/libc_v8.a test > bench.json
- name: write commit
run: |

7
.gitignore vendored
View File

@@ -1,7 +1,6 @@
zig-cache
/.zig-cache/
/.lp-cache/
zig-out
/vendor/netsurf/out
/vendor/libiconv/
lightpanda.id
/v8/
/src/html5ever/target/
src/snapshot.bin

21
.gitmodules vendored
View File

@@ -1,21 +0,0 @@
[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://source.netsurf-browser.org/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/

View File

@@ -1,75 +1,82 @@
FROM ubuntu:24.04
FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG=0.14.1
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG ARCH=x86_64
ARG V8=13.6.233.8
ARG ZIG_V8=v0.1.24
ARG V8=14.0.365.4
ARG ZIG_V8=v0.3.1
ARG TARGETPLATFORM
RUN apt-get update -yq && \
apt-get install -yq xz-utils \
python3 ca-certificates git \
apt-get install -yq xz-utils ca-certificates \
pkg-config libglib2.0-dev \
gperf libexpat1-dev \
cmake clang \
curl git
clang make curl git
# Get Rust
RUN curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal -y
ENV PATH="/root/.cargo/bin:${PATH}"
# 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
# install zig
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
RUN minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG}
# clean minisg
RUN rm -fr minisign-0.11-linux.tar.gz minisign-linux
# install zig
RUN 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
# clean up zig install
RUN rm -fr zig-${ARCH}-linux-${ZIG}.tar.xz zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
# force use of http instead of ssh with github
RUN cat <<EOF > /root/.gitconfig
[url "https://github.com/"]
insteadOf="git@github.com:"
EOF
tar xvzf minisign-${MINISIG}-linux.tar.gz -C /
# clone lightpanda
RUN git clone git@github.com:lightpanda-io/browser.git
RUN git clone https://github.com/lightpanda-io/browser.git
WORKDIR /browser
# install zig
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} && \
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
# install deps
RUN git submodule init && \
git submodule update --recursive
RUN make install-libiconv && \
make install-netsurf && \
make install-mimalloc
# download and install v8
RUN 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
RUN case $TARGETPLATFORM in \
"linux/arm64") ARCH="aarch64" ;; \
*) 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/ && \
mv libc_v8.a v8/libc_v8.a
# build v8 snapshot
RUN zig build -Doptimize=ReleaseFast \
-Dprebuilt_v8_path=v8/libc_v8.a \
snapshot_creator -- src/snapshot.bin
# build release
RUN make build
RUN zig build -Doptimize=ReleaseFast \
-Dsnapshot_path=../../snapshot.bin \
-Dprebuilt_v8_path=v8/libc_v8.a \
-Dgit_commit=$(git rev-parse --short HEAD)
FROM ubuntu:24.04
FROM debian:stable-slim
RUN apt-get update -yq && \
apt-get install -yq tini
FROM debian:stable-slim
# copy ca certificates
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"]

View File

@@ -5,14 +5,6 @@ List](https://spdx.org/licenses/).
The default license for this project is [AGPL-3.0-only](LICENSE).
## MIT
The following files are licensed under MIT:
```
src/polyfill/fetch.js
```
The following directories and their subdirectories are licensed under their
original upstream licenses:

201
Makefile
View File

@@ -34,7 +34,7 @@ endif
## Display this help screen
help:
@printf "\e[36m%-35s %s\e[0m\n" "Command" "Usage"
@printf "\033[36m%-35s %s\033[0m\n" "Command" "Usage"
@sed -n -e '/^## /{'\
-e 's/## //g;'\
-e 'h;'\
@@ -47,200 +47,63 @@ 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: end2end
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2)
## Build v8 snapshot
build-v8-snapshot:
@printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Download the zig recommended version
download-zig:
$(eval url = "https://ziglang.org/download/$(zig_version)/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
$(eval dest = "/tmp/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
@printf "\e[36mDownload zig version $(zig_version)...\e[0m\n"
@curl -o "$(dest)" -L "$(url)" || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mDownloaded $(dest)\e[0m\n"
## Build in release-safe mode
build:
@printf "\e[36mBuilding (release safe)...\e[0m\n"
$(ZIG) build -Doptimize=ReleaseSafe -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mBuild OK\e[0m\n"
## Build in release-fast mode
build: build-v8-snapshot
@printf "\033[36mBuilding (release fast)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode
build-dev:
@printf "\e[36mBuilding (debug)...\e[0m\n"
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mBuild OK\e[0m\n"
@printf "\033[36mBuilding (debug)...\033[0m\n"
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Run the server in release mode
run: build
@printf "\e[36mRunning...\e[0m\n"
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
@printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
## Run the server in debug mode
run-debug: build-dev
@printf "\e[36mRunning...\e[0m\n"
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
@printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
## Run a JS shell in debug mode
shell:
@printf "\e[36mBuilding shell...\e[0m\n"
@$(ZIG) build shell || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\033[36mBuilding shell...\033[0m\n"
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Run WPT tests
wpt:
@printf "\e[36mBuilding wpt...\e[0m\n"
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
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' 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
else
test:
@script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
endif
## Run demo/runner end to end tests
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-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
## Install and build dependencies for release
install: install-submodule install-libiconv install-netsurf install-mimalloc
## 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 https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz | tar -xvzf -
endif
install-libiconv: download-libiconv clean-libiconv
@cd vendor/libiconv/libiconv-1.17 && \
./configure --prefix=$(ICONV) --enable-static && \
make && make install
clean-libiconv:
ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
@cd vendor/libiconv/libiconv-1.17 && \
make clean
endif
install: build
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 && \
git submodule update

229
README.md
View File

@@ -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 through CDP (WIP)
- 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:
@@ -41,7 +41,8 @@ Due to the nature of Playwright, a script that works with the current version of
## Quick start
### Install from the nightly builds
### Install
**Install from the nightly builds**
You can download the last binary from the [nightly
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
@@ -64,26 +65,62 @@ chmod a+x ./lightpanda
The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
It is recommended to install clients like Puppeteer on the Windows host.
**Install from Docker**
Lightpanda provides [official Docker
images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and
arm64 architectures.
The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`.
```console
docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
```
### Dump a URL
```console
./lightpanda fetch --dump https://lightpanda.io
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
```
```console
info(browser): GET https://lightpanda.io/ http.Status.ok
info(browser): fetch script https://api.website.lightpanda.io/js/script.js: http.Status.ok
info(browser): eval remote https://api.website.lightpanda.io/js/script.js: TypeError: Cannot read properties of undefined (reading 'pushState')
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms]
url = https://demo-browser.lightpanda.io/campfire-commerce/
method = GET
reason = address_bar
body = false
req_id = 1
INFO browser : executing script . . . . . . . . . . . . . . [+118ms]
src = https://demo-browser.lightpanda.io/campfire-commerce/script.js
kind = javascript
cacheable = true
INFO http : request complete . . . . . . . . . . . . . . . . [+140ms]
source = xhr
url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json
status = 200
len = 4770
INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
source = fetch
url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json
status = 200
len = 1615
<!DOCTYPE html>
```
### Start a CDP server
```console
./lightpanda serve --host 127.0.0.1 --port 9222
./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222
```
```console
info(websocket): starting blocking worker to listen on 127.0.0.1:9222
info(server): accepting new conn...
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
address = 127.0.0.1:9222
```
Once the CDP server started, you can run a Puppeteer script by configuring the
@@ -104,7 +141,7 @@ const context = await browser.createBrowserContext();
const page = await context.newPage();
// Dump all the links from the page.
await page.goto('https://wikipedia.com/');
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
const links = await page.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
@@ -124,21 +161,28 @@ By default, Lightpanda collects and sends usage telemetry. This can be disabled
## Status
Lightpanda is still a work in progress and is currently at a Beta stage.
:warning: You should expect most websites to fail or crash.
Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
You may still encounter errors or crashes. Please open an issue with specifics if so.
Here are the key features we have implemented:
- [x] HTTP loader
- [x] HTML parser and DOM tree (based on Netsurf libs)
- [x] Javascript support (v8)
- [x] Basic DOM APIs
- [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
- [x] DOM dump
- [x] Basic CDP/websockets server
- [x] CDP/websockets server
- [x] Click
- [x] Input form
- [x] Cookies
- [x] Custom HTTP headers
- [x] Proxy support
- [x] Network interception
- [x] Respect `robots.txt` with option `--obey_robots`
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
@@ -148,102 +192,57 @@ You can also follow the progress of our Javascript support in our dedicated [zig
### Prerequisites
Lightpanda is written with [Zig](https://ziglang.org/) `0.14.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
[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
[Netsurf libs](https://www.netsurf-browser.org/) and
[Mimalloc](https://microsoft.github.io/mimalloc).
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
For Debian/Ubuntu based Linux:
For **Debian/Ubuntu based Linux**:
```
sudo apt install xz-utils \
python3 ca-certificates git \
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
gperf libexpat1-dev unzip rsync \
cmake clang
clang make curl git
```
You also need to [install Rust](https://rust-lang.org/tools/install/).
For systems with [Nix](https://nixos.org/download/), you can use the devShell:
For systems with [**Nix**](https://nixos.org/download/), you can use the devShell:
```
nix develop
```
For MacOS, you only need cmake:
For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
```
brew install cmake
```
### Install and build dependencies
### Build and run
#### All in one build
You an build the entire browser with `make build` or `make build-dev` for debug
env.
You can run `make install` to install deps all in one (or `make install-dev` if you need the development versions).
But you can directly use the zig command: `zig build run`.
Be aware that the build task is very long and cpu consuming, as you will build from sources all dependencies, including the v8 Javascript engine.
#### Embed v8 snapshot
#### Step by step build dependency
The project uses git submodules for dependencies.
To init or update the submodules in the `vendor/` directory:
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
```
make install-submodule
zig build snapshot_creator -- src/snapshot.bin
```
**iconv**
libiconv is an internationalization library used by Netsurf.
Build using the snapshot binary.
```
make install-libiconv
zig build -Dsnapshot_path=../../snapshot.bin
```
**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:
```
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`.
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details.
## Test
@@ -270,35 +269,75 @@ make end2end
Lightpanda is tested against the standardized [Web Platform
Tests](https://web-platform-tests.org/).
The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command.
All the tests cases executed are located in the `tests/wpt` sub-directory.
We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom
[`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
For reference, you can easily execute a WPT test case with your browser via
[wpt.live](https://wpt.live).
#### Configure WPT HTTP server
To run the test, you must clone the repository, configure the custom hosts and generate the
`MANIFEST.json` file.
Clone the repository with the `fork` branch.
```
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
```
Enter into the `wpt/` dir.
Install custom domains in your `/etc/hosts`
```
./wpt make-hosts-file | sudo tee -a /etc/hosts
```
Generate `MANIFEST.json`
```
./wpt manifest
```
Use the [WPT's setup
guide](https://web-platform-tests.org/running-tests/from-local-system.html) for
details.
#### Run WPT test suite
To run all the tests:
An external [Go](https://go.dev) runner is provided by
[github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/)
repository, located into `wptrunner/` dir.
You need to clone the project first.
First start the WPT's HTTP server from your `wpt/` clone dir.
```
./wpt serve
```
Run a Lightpanda browser
```
make wpt
zig build run -- --insecure_disable_tls_host_verification
```
Then you can start the wptrunner from the Demo's clone dir:
```
cd wptrunner && go run .
```
Or one specific test:
```
make wpt Node-childNodes.html
cd wptrunner && go run . Node-childNodes.html
```
#### Add a new WPT test case
`wptrunner` command accepts `--summary` and `--json` options modifying output.
Also `--concurrency` define the concurrency limit.
We add new relevant tests cases files when we implemented changes in Lightpanda.
:warning: Running the whole test suite will take a long time. In this case,
it's useful to build in `releaseFast` mode to make tests faster.
To add a new test, copy the file you want from the [WPT
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory.
:warning: Please keep the original directory tree structure of `tests/wpt`.
```
zig build -Doptimize=ReleaseFast run
```
## Contributing

793
build.zig
View File

@@ -17,236 +17,693 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
/// Do not rename this constant. It is scanned by some scripts to determine
/// which zig version to install.
const recommended_zig_version = "0.14.1";
pub fn build(b: *std.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 },
);
},
}
var opts = b.addOptions();
opts.addOption(
[]const u8,
"git_commit",
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
);
const Build = std.Build;
pub fn build(b: *Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
{
// browser
// -------
const manifest = Manifest.init(b);
// compile and install
const exe = b.addExecutable(.{
.name = "lightpanda",
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");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
var opts = b.addOptions();
opts.addOption([]const u8, "version", manifest.version);
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false;
const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers");
const lightpanda_module = blk: {
const mod = b.addModule("lightpanda", .{
.root_source_file = b.path("src/lightpanda.zig"),
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/main.zig"),
.link_libc = true,
.link_libcpp = true,
.sanitize_c = enable_csan,
.sanitize_thread = enable_tsan,
});
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
mod.addImport("build_config", opts.createModule());
try common(b, opts, exe);
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
try linkCurl(b, mod);
try linkHtml5Ever(b, mod);
break :blk mod;
};
{
// browser
const exe = b.addExecutable(.{
.name = "lightpanda",
.use_llvm = true,
.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);
}
{
// 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);
// snapshot creator
const exe = b.addExecutable(.{
.name = "lightpanda-snapshot-creator",
.use_llvm = true,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main_snapshot_creator.zig"),
.target = target,
.optimize = optimize,
.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("snapshot_creator", "Generate a v8 snapshot");
run_step.dependOn(&run_cmd.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);
}
{
// tests
// ----
// compile
// test
const tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.root_module = lightpanda_module,
.use_llvm = true,
.test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
.target = target,
.optimize = optimize,
});
try common(b, opts, tests);
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
// -----
// compile and install
const wpt = b.addExecutable(.{
.name = "lightpanda-wpt",
.root_source_file = b.path("src/main_wpt.zig"),
// 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 },
},
}),
});
try common(b, opts, wpt);
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("legacy_test", "Run the app");
run_step.dependOn(&run_cmd.step);
}
}
fn common(b: *std.Build, opts: *std.Build.Step.Options, step: *std.Build.Step.Compile) !void {
const mod = step.root_module;
fn linkV8(
b: *Build,
mod: *Build.Module,
is_asan: bool,
is_tsan: bool,
prebuilt_v8_path: ?[]const u8,
) !void {
const target = mod.resolved_target.?;
const optimize = mod.optimize.?;
const dep_opts = .{ .target = target, .optimize = optimize };
try moduleNetSurf(b, step, target);
mod.addImport("tls", b.dependency("tls", dep_opts).module("tls"));
mod.addImport("tigerbeetle-io", b.dependency("tigerbeetle_io", .{}).module("tigerbeetle_io"));
{
// v8
const v8_opts = b.addOptions();
v8_opts.addOption(bool, "inspector_subtype", false);
const v8_mod = b.dependency("v8", dep_opts).module("v8");
v8_mod.addOptions("default_exports", v8_opts);
mod.addImport("v8", v8_mod);
const dep = b.dependency("v8", .{
.target = target,
.optimize = mod.optimize.?,
.is_asan = is_asan,
.is_tsan = is_tsan,
.inspector_subtype = false,
.v8_enable_sandbox = is_tsan,
.cache_root = b.pathFromRoot(".lp-cache"),
.prebuilt_v8_path = prebuilt_v8_path,
});
mod.addImport("v8", dep.module("v8"));
}
mod.link_libcpp = true;
fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void {
const is_debug = if (mod.optimize.? == .Debug) true else false;
{
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));
const exec_cargo = b.addSystemCommand(&.{
"cargo", "build",
"--profile", if (is_debug) "dev" else "release",
"--manifest-path", "src/html5ever/Cargo.toml",
});
// TODO: We can prefer `--artifact-dir` once it become stable.
const out_dir = exec_cargo.addPrefixedOutputDirectoryArg("--target-dir=", "html5ever");
const html5ever_step = b.step("html5ever", "Install html5ever dependency (requires cargo)");
html5ever_step.dependOn(&exec_cargo.step);
const obj = out_dir.path(b, if (is_debug) "debug" else "release").path(b, "liblitefetch_html5ever.a");
mod.addObjectFile(obj);
}
fn linkCurl(b: *Build, mod: *Build.Module) !void {
const target = mod.resolved_target.?;
const curl = buildCurl(b, target, mod.optimize.?);
mod.linkLibrary(curl);
const zlib = buildZlib(b, target, mod.optimize.?);
curl.root_module.linkLibrary(zlib);
const brotli = buildBrotli(b, target, mod.optimize.?);
for (brotli) |lib| curl.root_module.linkLibrary(lib);
const nghttp2 = buildNghttp2(b, target, mod.optimize.?);
curl.root_module.linkLibrary(nghttp2);
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
for (boringssl) |lib| curl.root_module.linkLibrary(lib);
switch (target.result.os.tag) {
.macos => {
// v8 has a dependency, abseil-cpp, which, on Mac, uses CoreFoundation
// needed for proxying on mac
mod.addSystemFrameworkPath(.{ .cwd_relative = "/System/Library/Frameworks" });
mod.linkFramework("CoreFoundation", .{});
mod.linkFramework("SystemConfiguration", .{});
},
else => {},
}
mod.addImport("build_config", opts.createModule());
}
fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
const dep = b.dependency("zlib", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
});
const lib = b.addLibrary(.{ .name = "z", .root_module = mod });
lib.installHeadersDirectory(dep.path(""), "", .{});
lib.addCSourceFiles(.{
.root = dep.path(""),
.flags = &.{
"-DHAVE_SYS_TYPES_H",
"-DHAVE_STDINT_H",
"-DHAVE_STDDEF_H",
"-DHAVE_UNISTD_H",
},
.files = &.{
"adler32.c", "compress.c", "crc32.c",
"deflate.c", "gzclose.c", "gzlib.c",
"gzread.c", "gzwrite.c", "infback.c",
"inffast.c", "inflate.c", "inftrees.c",
"trees.c", "uncompr.c", "zutil.c",
},
});
return lib;
}
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile {
const dep = b.dependency("brotli", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
});
mod.addIncludePath(dep.path("c/include"));
const brotlicmn = b.addLibrary(.{ .name = "brotlicommon", .root_module = mod });
const brotlidec = b.addLibrary(.{ .name = "brotlidec", .root_module = mod });
const brotlienc = b.addLibrary(.{ .name = "brotlienc", .root_module = mod });
brotlicmn.installHeadersDirectory(dep.path("c/include/brotli"), "brotli", .{});
brotlicmn.addCSourceFiles(.{
.root = dep.path("c/common"),
.files = &.{
"transform.c", "shared_dictionary.c", "platform.c",
"dictionary.c", "context.c", "constants.c",
},
});
brotlidec.addCSourceFiles(.{
.root = dep.path("c/dec"),
.files = &.{
"bit_reader.c", "decode.c", "huffman.c",
"prefix.c", "state.c", "static_init.c",
},
});
brotlienc.addCSourceFiles(.{
.root = dep.path("c/enc"),
.files = &.{
"backward_references.c", "backward_references_hq.c", "bit_cost.c",
"block_splitter.c", "brotli_bit_stream.c", "cluster.c",
"command.c", "compound_dictionary.c", "compress_fragment.c",
"compress_fragment_two_pass.c", "dictionary_hash.c", "encode.c",
"encoder_dict.c", "entropy_encode.c", "fast_log.c",
"histogram.c", "literal_cost.c", "memory.c",
"metablock.c", "static_dict.c", "static_dict_lut.c",
"static_init.c", "utf8_util.c",
},
});
return .{ brotlicmn, brotlidec, brotlienc };
}
fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [2]*Build.Step.Compile {
const dep = b.dependency("boringssl-zig", .{
.target = target,
.optimize = optimize,
.force_pic = true,
});
const ssl = dep.artifact("ssl");
ssl.bundle_ubsan_rt = false;
const crypto = dep.artifact("crypto");
crypto.bundle_ubsan_rt = false;
return .{ ssl, crypto };
}
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
const dep = b.dependency("nghttp2", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
});
mod.addIncludePath(dep.path("lib/includes"));
const config = b.addConfigHeader(.{
.include_path = "nghttp2ver.h",
.style = .{ .cmake = dep.path("lib/includes/nghttp2/nghttp2ver.h.in") },
}, .{
.PACKAGE_VERSION = "1.68.90",
.PACKAGE_VERSION_NUM = 0x016890,
});
mod.addConfigHeader(config);
const lib = b.addLibrary(.{ .name = "nghttp2", .root_module = mod });
lib.installConfigHeader(config);
lib.installHeadersDirectory(dep.path("lib/includes/nghttp2"), "nghttp2", .{});
lib.addCSourceFiles(.{
.root = dep.path("lib"),
.flags = &.{
"-DNGHTTP2_STATICLIB",
"-DHAVE_TIME_H",
"-DHAVE_ARPA_INET_H",
"-DHAVE_NETINET_IN_H",
},
.files = &.{
"sfparse.c", "nghttp2_alpn.c", "nghttp2_buf.c",
"nghttp2_callbacks.c", "nghttp2_debug.c", "nghttp2_extpri.c",
"nghttp2_frame.c", "nghttp2_hd.c", "nghttp2_hd_huffman.c",
"nghttp2_hd_huffman_data.c", "nghttp2_helper.c", "nghttp2_http.c",
"nghttp2_map.c", "nghttp2_mem.c", "nghttp2_option.c",
"nghttp2_outbound_item.c", "nghttp2_pq.c", "nghttp2_priority_spec.c",
"nghttp2_queue.c", "nghttp2_rcbuf.c", "nghttp2_session.c",
"nghttp2_stream.c", "nghttp2_submit.c", "nghttp2_version.c",
"nghttp2_ratelim.c", "nghttp2_time.c",
},
});
return lib;
}
fn buildCurl(
b: *Build,
target: Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
) *Build.Step.Compile {
const dep = b.dependency("curl", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
});
mod.addIncludePath(dep.path("lib"));
mod.addIncludePath(dep.path("include"));
const os = target.result.os.tag;
const arch = target.result.cpu.arch;
const abi = target.result.abi;
// 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) },
);
step.addObjectFile(b.path(libiconv_lib_path));
step.addIncludePath(b.path(libiconv_include_path));
const is_gnu = abi.isGnu();
const is_ios = os == .ios;
const is_android = abi.isAndroid();
const is_linux = os == .linux;
const is_darwin = os.isDarwin();
const is_windows = os == .windows;
const is_netbsd = os == .netbsd;
const is_openbsd = os == .openbsd;
const is_freebsd = os == .freebsd;
{
// 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) },
);
step.addObjectFile(b.path(lib_path));
step.addIncludePath(b.path(mimalloc ++ "/include"));
const byte_size = struct {
fn it(b2: *std.Build, target2: Build.ResolvedTarget, name: []const u8, comptime ctype: std.Target.CType) []const u8 {
const size = target2.result.cTypeByteSize(ctype);
return std.fmt.allocPrint(b2.allocator, "#define SIZEOF_{s} {d}", .{ name, size }) catch @panic("OOM");
}
}.it;
// 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) },
);
step.addIncludePath(b.path(ns_include_path));
const config = .{
.HAVE_LIBZ = true,
.HAVE_BROTLI = true,
.USE_NGHTTP2 = true,
const libs: [4][]const u8 = .{
"libdom",
"libhubbub",
"libparserutils",
"libwapcaplet",
.USE_OPENSSL = true,
.OPENSSL_IS_BORINGSSL = true,
.CURL_CA_PATH = null,
.CURL_CA_BUNDLE = null,
.CURL_CA_FALLBACK = false,
.CURL_CA_SEARCH_SAFE = false,
.CURL_DEFAULT_SSL_BACKEND = "openssl",
.CURL_DISABLE_AWS = true,
.CURL_DISABLE_DICT = true,
.CURL_DISABLE_DOH = true,
.CURL_DISABLE_FILE = true,
.CURL_DISABLE_FTP = true,
.CURL_DISABLE_GOPHER = true,
.CURL_DISABLE_KERBEROS_AUTH = true,
.CURL_DISABLE_IMAP = true,
.CURL_DISABLE_IPFS = true,
.CURL_DISABLE_LDAP = true,
.CURL_DISABLE_LDAPS = true,
.CURL_DISABLE_MQTT = true,
.CURL_DISABLE_NTLM = true,
.CURL_DISABLE_PROGRESS_METER = true,
.CURL_DISABLE_POP3 = true,
.CURL_DISABLE_RTSP = true,
.CURL_DISABLE_SMB = true,
.CURL_DISABLE_SMTP = true,
.CURL_DISABLE_TELNET = true,
.CURL_DISABLE_TFTP = true,
.ssize_t = null,
._FILE_OFFSET_BITS = 64,
.USE_IPV6 = true,
.CURL_OS = switch (os) {
.linux => if (is_android) "\"android\"" else "\"linux\"",
else => std.fmt.allocPrint(b.allocator, "\"{s}\"", .{@tagName(os)}) catch @panic("OOM"),
},
// Adjusts the sizes of variables
.SIZEOF_INT_CODE = byte_size(b, target, "INT", .int),
.SIZEOF_LONG_CODE = byte_size(b, target, "LONG", .long),
.SIZEOF_LONG_LONG_CODE = byte_size(b, target, "LONG_LONG", .longlong),
.SIZEOF_OFF_T_CODE = byte_size(b, target, "OFF_T", .longlong),
.SIZEOF_CURL_OFF_T_CODE = byte_size(b, target, "CURL_OFF_T", .longlong),
.SIZEOF_CURL_SOCKET_T_CODE = byte_size(b, target, "CURL_SOCKET_T", .int),
.SIZEOF_SIZE_T_CODE = byte_size(b, target, "SIZE_T", .longlong),
.SIZEOF_TIME_T_CODE = byte_size(b, target, "TIME_T", .longlong),
// headers availability
.HAVE_ARPA_INET_H = !is_windows,
.HAVE_DIRENT_H = true,
.HAVE_FCNTL_H = true,
.HAVE_IFADDRS_H = !is_windows,
.HAVE_IO_H = is_windows,
.HAVE_LIBGEN_H = true,
.HAVE_LINUX_TCP_H = is_linux and is_gnu,
.HAVE_LOCALE_H = true,
.HAVE_NETDB_H = !is_windows,
.HAVE_NETINET_IN6_H = is_android,
.HAVE_NETINET_IN_H = !is_windows,
.HAVE_NETINET_TCP_H = !is_windows,
.HAVE_NETINET_UDP_H = !is_windows,
.HAVE_NET_IF_H = !is_windows,
.HAVE_POLL_H = !is_windows,
.HAVE_PWD_H = !is_windows,
.HAVE_STDATOMIC_H = true,
.HAVE_STDBOOL_H = true,
.HAVE_STDDEF_H = true,
.HAVE_STDINT_H = true,
.HAVE_STRINGS_H = true,
.HAVE_STROPTS_H = false,
.HAVE_SYS_EVENTFD_H = is_linux or is_freebsd or is_netbsd,
.HAVE_SYS_FILIO_H = !is_linux and !is_windows,
.HAVE_SYS_IOCTL_H = !is_windows,
.HAVE_SYS_PARAM_H = true,
.HAVE_SYS_POLL_H = !is_windows,
.HAVE_SYS_RESOURCE_H = !is_windows,
.HAVE_SYS_SELECT_H = !is_windows,
.HAVE_SYS_SOCKIO_H = !is_linux and !is_windows,
.HAVE_SYS_TYPES_H = true,
.HAVE_SYS_UN_H = !is_windows,
.HAVE_SYS_UTIME_H = is_windows,
.HAVE_TERMIOS_H = !is_windows,
.HAVE_TERMIO_H = is_linux,
.HAVE_UNISTD_H = true,
.HAVE_UTIME_H = true,
.STDC_HEADERS = true,
// general environment
.CURL_KRB5_VERSION = null,
.HAVE_ALARM = !is_windows,
.HAVE_ARC4RANDOM = is_android,
.HAVE_ATOMIC = true,
.HAVE_BOOL_T = true,
.HAVE_BUILTIN_AVAILABLE = true,
.HAVE_CLOCK_GETTIME_MONOTONIC = !is_darwin and !is_windows,
.HAVE_CLOCK_GETTIME_MONOTONIC_RAW = is_linux,
.HAVE_FILE_OFFSET_BITS = true,
.HAVE_GETEUID = !is_windows,
.HAVE_GETPPID = !is_windows,
.HAVE_GETTIMEOFDAY = true,
.HAVE_GLIBC_STRERROR_R = is_gnu,
.HAVE_GMTIME_R = !is_windows,
.HAVE_LOCALTIME_R = !is_windows,
.HAVE_LONGLONG = !is_windows,
.HAVE_MACH_ABSOLUTE_TIME = is_darwin,
.HAVE_MEMRCHR = !is_darwin and !is_windows,
.HAVE_POSIX_STRERROR_R = !is_gnu and !is_windows,
.HAVE_PTHREAD_H = !is_windows,
.HAVE_SETLOCALE = true,
.HAVE_SETRLIMIT = !is_windows,
.HAVE_SIGACTION = !is_windows,
.HAVE_SIGINTERRUPT = !is_windows,
.HAVE_SIGNAL = true,
.HAVE_SIGSETJMP = !is_windows,
.HAVE_SIZEOF_SA_FAMILY_T = false,
.HAVE_SIZEOF_SUSECONDS_T = false,
.HAVE_SNPRINTF = true,
.HAVE_STRCASECMP = !is_windows,
.HAVE_STRCMPI = false,
.HAVE_STRDUP = true,
.HAVE_STRERROR_R = !is_windows,
.HAVE_STRICMP = false,
.HAVE_STRUCT_TIMEVAL = true,
.HAVE_TIME_T_UNSIGNED = false,
.HAVE_UTIME = true,
.HAVE_UTIMES = !is_windows,
.HAVE_WRITABLE_ARGV = !is_windows,
.HAVE__SETMODE = is_windows,
.USE_THREADS_POSIX = !is_windows,
// filesystem, network
.HAVE_ACCEPT4 = is_linux or is_freebsd or is_netbsd or is_openbsd,
.HAVE_BASENAME = true,
.HAVE_CLOSESOCKET = is_windows,
.HAVE_DECL_FSEEKO = !is_windows,
.HAVE_EVENTFD = is_linux or is_freebsd or is_netbsd,
.HAVE_FCNTL = !is_windows,
.HAVE_FCNTL_O_NONBLOCK = !is_windows,
.HAVE_FNMATCH = !is_windows,
.HAVE_FREEADDRINFO = true,
.HAVE_FSEEKO = !is_windows,
.HAVE_FSETXATTR = is_darwin or is_linux or is_netbsd,
.HAVE_FSETXATTR_5 = is_linux or is_netbsd,
.HAVE_FSETXATTR_6 = is_darwin,
.HAVE_FTRUNCATE = true,
.HAVE_GETADDRINFO = true,
.HAVE_GETADDRINFO_THREADSAFE = is_linux or is_freebsd or is_netbsd,
.HAVE_GETHOSTBYNAME_R = is_linux or is_freebsd,
.HAVE_GETHOSTBYNAME_R_3 = false,
.HAVE_GETHOSTBYNAME_R_3_REENTRANT = false,
.HAVE_GETHOSTBYNAME_R_5 = false,
.HAVE_GETHOSTBYNAME_R_5_REENTRANT = false,
.HAVE_GETHOSTBYNAME_R_6 = is_linux,
.HAVE_GETHOSTBYNAME_R_6_REENTRANT = is_linux,
.HAVE_GETHOSTNAME = true,
.HAVE_GETIFADDRS = if (is_windows) false else !is_android or target.result.os.versionRange().linux.android >= 24,
.HAVE_GETPASS_R = is_netbsd,
.HAVE_GETPEERNAME = true,
.HAVE_GETPWUID = !is_windows,
.HAVE_GETPWUID_R = !is_windows,
.HAVE_GETRLIMIT = !is_windows,
.HAVE_GETSOCKNAME = true,
.HAVE_IF_NAMETOINDEX = !is_windows,
.HAVE_INET_NTOP = !is_windows,
.HAVE_INET_PTON = !is_windows,
.HAVE_IOCTLSOCKET = is_windows,
.HAVE_IOCTLSOCKET_CAMEL = false,
.HAVE_IOCTLSOCKET_CAMEL_FIONBIO = false,
.HAVE_IOCTLSOCKET_FIONBIO = is_windows,
.HAVE_IOCTL_FIONBIO = !is_windows,
.HAVE_IOCTL_SIOCGIFADDR = !is_windows,
.HAVE_MSG_NOSIGNAL = !is_windows,
.HAVE_OPENDIR = true,
.HAVE_PIPE = !is_windows,
.HAVE_PIPE2 = is_linux or is_freebsd or is_netbsd or is_openbsd,
.HAVE_POLL = !is_windows,
.HAVE_REALPATH = !is_windows,
.HAVE_RECV = true,
.HAVE_SA_FAMILY_T = !is_windows,
.HAVE_SCHED_YIELD = !is_windows,
.HAVE_SELECT = true,
.HAVE_SEND = true,
.HAVE_SENDMMSG = !is_darwin and !is_windows,
.HAVE_SENDMSG = !is_windows,
.HAVE_SETMODE = !is_linux,
.HAVE_SETSOCKOPT_SO_NONBLOCK = false,
.HAVE_SOCKADDR_IN6_SIN6_ADDR = !is_windows,
.HAVE_SOCKADDR_IN6_SIN6_SCOPE_ID = true,
.HAVE_SOCKET = true,
.HAVE_SOCKETPAIR = !is_windows,
.HAVE_STRUCT_SOCKADDR_STORAGE = true,
.HAVE_SUSECONDS_T = is_android or is_ios,
.USE_UNIX_SOCKETS = !is_windows,
};
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) },
);
step.addObjectFile(b.path(ns_lib_path));
step.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
const curl_config = b.addConfigHeader(.{
.include_path = "curl_config.h",
.style = .{ .cmake = dep.path("lib/curl_config-cmake.h.in") },
}, .{
.CURL_EXTERN_SYMBOL = "__attribute__ ((__visibility__ (\"default\"))",
});
curl_config.addValues(config);
const lib = b.addLibrary(.{ .name = "curl", .root_module = mod });
lib.addConfigHeader(curl_config);
lib.installHeadersDirectory(dep.path("include/curl"), "curl", .{});
lib.addCSourceFiles(.{
.root = dep.path("lib"),
.flags = &.{
"-D_GNU_SOURCE",
"-DHAVE_CONFIG_H",
"-DCURL_STATICLIB",
"-DBUILDING_LIBCURL",
},
.files = &.{
// You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions
"altsvc.c", "amigaos.c", "asyn-ares.c",
"asyn-base.c", "asyn-thrdd.c", "bufq.c",
"bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c",
"cf-haproxy.c", "cf-https-connect.c", "cf-ip-happy.c",
"cf-socket.c", "cfilters.c", "conncache.c",
"connect.c", "content_encoding.c", "cookie.c",
"cshutdn.c", "curl_addrinfo.c", "curl_endian.c",
"curl_fnmatch.c", "curl_fopen.c", "curl_get_line.c",
"curl_gethostname.c", "curl_gssapi.c", "curl_memrchr.c",
"curl_ntlm_core.c", "curl_range.c", "curl_rtmp.c",
"curl_sasl.c", "curl_sha512_256.c", "curl_share.c",
"curl_sspi.c", "curl_threads.c", "curl_trc.c",
"curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c",
"curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c",
"curlx/nonblock.c", "curlx/strcopy.c", "curlx/strerr.c",
"curlx/strparse.c", "curlx/timediff.c", "curlx/timeval.c",
"curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c",
"curlx/winapi.c", "cw-out.c", "cw-pause.c",
"dict.c", "dllmain.c", "doh.c",
"dynhds.c", "easy.c", "easygetopt.c",
"easyoptions.c", "escape.c", "fake_addrinfo.c",
"file.c", "fileinfo.c", "formdata.c",
"ftp.c", "ftplistparser.c", "getenv.c",
"getinfo.c", "gopher.c", "hash.c",
"headers.c", "hmac.c", "hostip.c",
"hostip4.c", "hostip6.c", "hsts.c",
"http.c", "http1.c", "http2.c",
"http_aws_sigv4.c", "http_chunks.c", "http_digest.c",
"http_negotiate.c", "http_ntlm.c", "http_proxy.c",
"httpsrr.c", "idn.c", "if2ip.c",
"imap.c", "ldap.c", "llist.c",
"macos.c", "md4.c", "md5.c",
"memdebug.c", "mime.c", "mprintf.c",
"mqtt.c", "multi.c", "multi_ev.c",
"multi_ntfy.c", "netrc.c", "noproxy.c",
"openldap.c", "parsedate.c", "pingpong.c",
"pop3.c", "progress.c", "psl.c",
"rand.c", "ratelimit.c", "request.c",
"rtsp.c", "select.c", "sendf.c",
"setopt.c", "sha256.c", "slist.c",
"smb.c", "smtp.c", "socketpair.c",
"socks.c", "socks_gssapi.c", "socks_sspi.c",
"splay.c", "strcase.c", "strdup.c",
"strequal.c", "strerror.c", "system_win32.c",
"telnet.c", "tftp.c", "transfer.c",
"uint-bset.c", "uint-hash.c", "uint-spbset.c",
"uint-table.c", "url.c", "urlapi.c",
"vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c",
"vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c",
"vauth/krb5_sspi.c", "vauth/ntlm.c", "vauth/ntlm_sspi.c",
"vauth/oauth2.c", "vauth/spnego_gssapi.c", "vauth/spnego_sspi.c",
"vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c",
"vquic/curl_osslq.c", "vquic/curl_quiche.c", "vquic/vquic-tls.c",
"vquic/vquic.c", "vssh/libssh.c", "vssh/libssh2.c",
"vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c",
"vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c",
"vtls/mbedtls.c", "vtls/openssl.c", "vtls/rustls.c",
"vtls/schannel.c", "vtls/schannel_verify.c", "vtls/vtls.c",
"vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c",
"vtls/x509asn1.c", "ws.c",
},
});
return lib;
}
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);
},
}
};
}
};

View File

@@ -1,22 +1,36 @@
.{
.name = .browser,
.paths = .{""},
.version = "0.0.0",
.fingerprint = 0xda130f3af836cea0,
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.dependencies = .{
.tls = .{
.url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
.hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
},
.tigerbeetle_io = .{
.url = "https://github.com/lightpanda-io/tigerbeetle-io/archive/61d9652f1a957b7f4db723ea6aa0ce9635e840ce.tar.gz",
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
},
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/1d25fcf3ced688adca3c7a95a138771e4ebba692.tar.gz",
.hash = "v8-0.0.0-xddH61eyAwDICIkLAkfQcxsX4TMCKY80QiSUgNBQqx-u",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz",
.hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy",
},
//.v8 = .{ .path = "../zig-v8-fork" },
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
.brotli = .{
// v1.2.0
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",
.hash = "N-V-__8AAJudKgCQCuIiH6MJjAiIJHfg_tT_Ew-0vZwVkCo_",
},
.zlib = .{
.url = "https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz",
.hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ",
},
.nghttp2 = .{
.url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
.hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3",
},
.@"boringssl-zig" = .{
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
},
.curl = .{
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
},
},
.paths = .{""},
}

166
flake.lock generated
View File

@@ -1,5 +1,42 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1770708269,
"narHash": "sha256-OnZW86app7hHJJoB5lC9GNXY5QBBIESJB+sIdwEyld0=",
"owner": "nix-community",
"repo": "fenix",
"rev": "6b5325a017a9a9fe7e6252ccac3680cc7181cd63",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@@ -18,13 +55,52 @@
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"zlsPkg",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1748964450,
"narHash": "sha256-ZouDiXkUk8mkMnah10QcoQ9Nu6UW6AFAHLScS3En6aI=",
"lastModified": 1768649915,
"narHash": "sha256-jc21hKogFnxU7KXSVTRmxC7u5D4RHwm9BAvDf5/Z1Uo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9ff500cd9e123f46c55855eca64beccead29b152",
"rev": "3e3f3c7f9977dc123c23ee21e8085ed63daf8c37",
"type": "github"
},
"original": {
@@ -36,8 +112,28 @@
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"zigPkgs": "zigPkgs",
"zlsPkg": "zlsPkg"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1770668050,
"narHash": "sha256-Q05yaIZtQrBKHpyWaPmyJmDRj0lojnVf8nUFE0vydcY=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "9efc1f709f3c8134c3acac5d3592a8e4c184a0c6",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
@@ -54,6 +150,68 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"zigPkgs": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770598090,
"narHash": "sha256-k+82IDgTd9o5sxHIqGlvfwseKln3Ejx1edGtDltuPXo=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "142495696982c88edddc8e17e4da90d8164acadf",
"type": "github"
},
"original": {
"owner": "mitchellh",
"repo": "zig-overlay",
"type": "github"
}
},
"zlsPkg": {
"inputs": {
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"zig-overlay": [
"zigPkgs"
]
},
"locked": {
"lastModified": 1756048867,
"narHash": "sha256-GFzSHUljcxy7sM1PaabbkQUdUnLwpherekPWJFxXtnk=",
"owner": "zigtools",
"repo": "zls",
"rev": "ce6c8f02c78e622421cfc2405c67c5222819ec03",
"type": "github"
},
"original": {
"owner": "zigtools",
"ref": "0.15.0",
"repo": "zls",
"type": "github"
}
}
},
"root": "root",

View File

@@ -3,22 +3,47 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/release-25.05";
zigPkgs.url = "github:mitchellh/zig-overlay";
zigPkgs.inputs.nixpkgs.follows = "nixpkgs";
zlsPkg.url = "github:zigtools/zls/0.15.0";
zlsPkg.inputs.zig-overlay.follows = "zigPkgs";
zlsPkg.inputs.nixpkgs.follows = "nixpkgs";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
zigPkgs,
zlsPkg,
fenix,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [
(final: prev: {
zigpkgs = zigPkgs.packages.${prev.system};
zls = zlsPkg.packages.${prev.system}.default;
})
];
pkgs = import nixpkgs {
inherit system;
inherit system overlays;
};
rustToolchain = fenix.packages.${system}.stable.toolchain;
# We need crtbeginS.o for building.
crtFiles = pkgs.runCommand "crt-files" { } ''
mkdir -p $out/lib
@@ -32,8 +57,9 @@
targetPkgs =
pkgs: with pkgs; [
# Build Tools
zig
zigpkgs."0.15.2"
zls
rustToolchain
python3
pkg-config
cmake

113
src/App.zig Normal file
View File

@@ -0,0 +1,113 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
pub const Http = @import("http/Http.zig");
pub const ArenaPool = @import("ArenaPool.zig");
const App = @This();
http: Http,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
app_dir_path: ?[]const u8,
shutdown: bool = false,
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.config = config;
app.allocator = allocator;
app.robots = RobotStore.init(allocator);
app.http = try Http.init(allocator, &app.robots, config);
errdefer app.http.deinit();
app.platform = try Platform.init();
errdefer app.platform.deinit();
app.snapshot = try Snapshot.load();
errdefer app.snapshot.deinit();
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.mode);
errdefer app.telemetry.deinit();
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
errdefer app.arena_pool.deinit();
return app;
}
pub fn deinit(self: *App) void {
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
return;
}
const allocator = self.allocator;
if (self.app_dir_path) |app_dir_path| {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
self.telemetry.deinit();
self.robots.deinit();
self.http.deinit();
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();
allocator.destroy(self);
}
fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
if (@import("builtin").is_test) {
return allocator.dupe(u8, "/tmp") catch unreachable;
}
const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
log.warn(.app, "get data dir", .{ .err = err });
return null;
};
std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
error.PathAlreadyExists => return app_dir_path,
else => {
allocator.free(app_dir_path);
log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path });
return null;
},
};
return app_dir_path;
}

212
src/ArenaPool.zig Normal file
View File

@@ -0,0 +1,212 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArenaPool = @This();
allocator: Allocator,
retain_bytes: usize,
free_list_len: u16 = 0,
free_list: ?*Entry = null,
free_list_max: u16,
entry_pool: std.heap.MemoryPool(Entry),
mutex: std.Thread.Mutex = .{},
const Entry = struct {
next: ?*Entry,
arena: ArenaAllocator,
};
pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool {
return .{
.allocator = allocator,
.free_list_max = free_list_max,
.retain_bytes = retain_bytes,
.entry_pool = .init(allocator),
};
}
pub fn deinit(self: *ArenaPool) void {
var entry = self.free_list;
while (entry) |e| {
entry = e.next;
e.arena.deinit();
}
self.entry_pool.deinit();
}
pub fn acquire(self: *ArenaPool) !Allocator {
self.mutex.lock();
defer self.mutex.unlock();
if (self.free_list) |entry| {
self.free_list = entry.next;
self.free_list_len -= 1;
return entry.arena.allocator();
}
const entry = try self.entry_pool.create();
entry.* = .{
.next = null,
.arena = ArenaAllocator.init(self.allocator),
};
return entry.arena.allocator();
}
pub fn release(self: *ArenaPool, allocator: Allocator) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
const entry: *Entry = @fieldParentPtr("arena", arena);
// Reset the arena before acquiring the lock to minimize lock hold time
_ = arena.reset(.{ .retain_with_limit = self.retain_bytes });
self.mutex.lock();
defer self.mutex.unlock();
const free_list_len = self.free_list_len;
if (free_list_len == self.free_list_max) {
arena.deinit();
self.entry_pool.destroy(entry);
return;
}
entry.next = self.free_list;
self.free_list_len = free_list_len + 1;
self.free_list = entry;
}
pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
_ = arena.reset(.{ .retain_with_limit = retain });
}
const testing = std.testing;
test "arena pool - basic acquire and use" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 64);
@memset(buf, 0xAB);
try testing.expectEqual(@as(u8, 0xAB), buf[0]);
pool.release(alloc);
}
test "arena pool - reuse entry after release" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc1 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
pool.release(alloc1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The same entry should be returned from the free list.
const alloc2 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
try testing.expectEqual(alloc1.ptr, alloc2.ptr);
pool.release(alloc2);
}
test "arena pool - multiple concurrent arenas" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire();
const a2 = try pool.acquire();
const a3 = try pool.acquire();
// All three must be distinct arenas.
try testing.expect(a1.ptr != a2.ptr);
try testing.expect(a2.ptr != a3.ptr);
try testing.expect(a1.ptr != a3.ptr);
_ = try a1.alloc(u8, 16);
_ = try a2.alloc(u8, 32);
_ = try a3.alloc(u8, 48);
pool.release(a1);
pool.release(a2);
pool.release(a3);
try testing.expectEqual(@as(u16, 3), pool.free_list_len);
}
test "arena pool - free list respects max limit" {
// Cap the free list at 1 so the second release discards its arena.
var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire();
const a2 = try pool.acquire();
pool.release(a1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The free list is full; a2's arena should be destroyed, not queued.
pool.release(a2);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
}
test "arena pool - reset clears memory without releasing" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 128);
@memset(buf, 0xFF);
// reset() frees arena memory but keeps the allocator in-flight.
pool.reset(alloc, 0);
// The free list must stay empty; the allocator was not released.
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
// Allocating again through the same arena must still work.
const buf2 = try alloc.alloc(u8, 64);
@memset(buf2, 0x00);
try testing.expectEqual(@as(u8, 0x00), buf2[0]);
pool.release(alloc);
}
test "arena pool - deinit with entries in free list" {
// Verifies that deinit properly cleans up free-listed arenas (no leaks
// detected by the test allocator).
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
const a1 = try pool.acquire();
const a2 = try pool.acquire();
_ = try a1.alloc(u8, 256);
_ = try a2.alloc(u8, 512);
pool.release(a1);
pool.release(a2);
try testing.expectEqual(@as(u16, 2), pool.free_list_len);
pool.deinit();
}

849
src/Config.zig Normal file
View File

@@ -0,0 +1,849 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const dump = @import("browser/dump.zig");
pub const RunMode = enum {
help,
fetch,
serve,
version,
mcp,
};
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
// +14 for max websocket payload overhead
// +140 for the max control packet that might be interleaved in a message
pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
mode: Mode,
exec_name: []const u8,
http_headers: HttpHeaders,
const Config = @This();
pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
var config = Config{
.mode = mode,
.exec_name = exec_name,
.http_headers = undefined,
};
config.http_headers = try HttpHeaders.init(allocator, &config);
return config;
}
pub fn deinit(self: *const Config, allocator: Allocator) void {
self.http_headers.deinit(allocator);
}
pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
else => unreachable,
};
}
pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
else => unreachable,
};
}
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
else => unreachable,
};
}
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
.help, .version => null,
};
}
pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable,
};
}
pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable,
};
}
pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable,
};
}
pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
else => unreachable,
};
}
pub fn httpMaxRedirects(_: *const Config) u8 {
return 10;
}
pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
else => unreachable,
};
}
pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
else => unreachable,
};
}
pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
else => unreachable,
};
}
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
else => unreachable,
};
}
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
.help, .version => null,
};
}
pub fn maxConnections(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections,
else => unreachable,
};
}
pub fn maxPendingConnections(self: *const Config) u31 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_pending_connections,
else => unreachable,
};
}
pub const Mode = union(RunMode) {
help: bool, // false when being printed because of an error
fetch: Fetch,
serve: Serve,
version: void,
mcp: Mcp,
};
pub const Serve = struct {
host: []const u8 = "127.0.0.1",
port: u16 = 9222,
timeout: u31 = 10,
cdp_max_connections: u16 = 16,
cdp_max_pending_connections: u16 = 128,
common: Common = .{},
};
pub const Mcp = struct {
common: Common = .{},
};
pub const DumpFormat = enum {
html,
markdown,
wpt,
};
pub const Fetch = struct {
url: [:0]const u8,
dump_mode: ?DumpFormat = null,
common: Common = .{},
with_base: bool = false,
with_frames: bool = false,
strip: dump.Opts.Strip = .{},
};
pub const Common = struct {
obey_robots: bool = false,
proxy_bearer_token: ?[:0]const u8 = null,
http_proxy: ?[:0]const u8 = null,
http_max_concurrent: ?u8 = null,
http_max_host_open: ?u8 = null,
http_timeout: ?u31 = null,
http_connect_timeout: ?u31 = null,
http_max_response_size: ?usize = null,
tls_verify_host: bool = true,
log_level: ?log.Level = null,
log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null,
};
/// Pre-formatted HTTP headers for reuse across Http and Client.
/// Must be initialized with an allocator that outlives all HTTP connections.
pub const HttpHeaders = struct {
const user_agent_base: [:0]const u8 = "Lightpanda/1.0";
user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0")
user_agent_header: [:0]const u8,
proxy_bearer_header: ?[:0]const u8,
pub fn init(allocator: Allocator, config: *const Config) !HttpHeaders {
const user_agent: [:0]const u8 = if (config.userAgentSuffix()) |suffix|
try std.fmt.allocPrintSentinel(allocator, "{s} {s}", .{ user_agent_base, suffix }, 0)
else
user_agent_base;
errdefer if (config.userAgentSuffix() != null) allocator.free(user_agent);
const user_agent_header = try std.fmt.allocPrintSentinel(allocator, "User-Agent: {s}", .{user_agent}, 0);
errdefer allocator.free(user_agent_header);
const proxy_bearer_header: ?[:0]const u8 = if (config.proxyBearerToken()) |token|
try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0)
else
null;
return .{
.user_agent = user_agent,
.user_agent_header = user_agent_header,
.proxy_bearer_header = proxy_bearer_header,
};
}
pub fn deinit(self: *const HttpHeaders, allocator: Allocator) void {
if (self.proxy_bearer_header) |hdr| {
allocator.free(hdr);
}
allocator.free(self.user_agent_header);
if (self.user_agent.ptr != user_agent_base.ptr) {
allocator.free(self.user_agent);
}
}
};
pub fn printUsageAndExit(self: *const Config, success: bool) void {
// MAX_HELP_LEN|
const common_options =
\\
\\--insecure_disable_tls_host_verification
\\ Disables host verification on all HTTP requests. This is an
\\ advanced option which should only be set if you understand
\\ and accept the risk of disabling host verification.
\\
\\--obey_robots
\\ Fetches and obeys the robots.txt (if available) of the web pages
\\ we make requests towards.
\\ Defaults to false.
\\
\\--http_proxy The HTTP proxy to use for all HTTP requests.
\\ A username:password can be included for basic authentication.
\\ Defaults to none.
\\
\\--proxy_bearer_token
\\ The <token> to send for bearer authentication with the proxy
\\ Proxy-Authorization: Bearer <token>
\\
\\--http_max_concurrent
\\ The maximum number of concurrent HTTP requests.
\\ Defaults to 10.
\\
\\--http_max_host_open
\\ The maximum number of open connection to a given host:port.
\\ Defaults to 4.
\\
\\--http_connect_timeout
\\ The time, in milliseconds, for establishing an HTTP connection
\\ before timing out. 0 means it never times out.
\\ Defaults to 0.
\\
\\--http_timeout
\\ The maximum time, in milliseconds, the transfer is allowed
\\ to complete. 0 means it never times out.
\\ Defaults to 10000.
\\
\\--http_max_response_size
\\ Limits the acceptable response size for any request
\\ (e.g. XHR, fetch, script loading, ...).
\\ Defaults to no limit.
\\
\\--log_level The log level: debug, info, warn, error or fatal.
\\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
\\
\\
\\--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
\\
;
// MAX_HELP_LEN|
const usage =
\\usage: {s} command [options] [URL]
\\
\\Command can be either 'fetch', 'serve', 'mcp' or 'help'
\\
\\fetch command
\\Fetches the specified URL
\\Example: {s} fetch --dump html https://lightpanda.io/
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Argument must be 'html' or 'markdown'.
\\ Defaults to no dump.
\\
\\--strip_mode Comma separated list of tag groups to remove from dump
\\ the dump. e.g. --strip_mode js,css
\\ - "js" script and link[as=script, rel=preload]
\\ - "ui" includes img, picture, video, css and svg
\\ - "css" includes style and link[rel=stylesheet]
\\ - "full" includes js, ui and css
\\
\\--with_base Add a <base> tag in dump. Defaults to false.
\\
\\--with_frames Includes the contents of iframes. Defaults to false.
\\
++ common_options ++
\\
\\serve command
\\Starts a websocket CDP server
\\Example: {s} serve --host 127.0.0.1 --port 9222
\\
\\Options:
\\--host Host of the CDP server
\\ Defaults to "127.0.0.1"
\\
\\--port Port of the CDP server
\\ Defaults to 9222
\\
\\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\
\\--cdp_max_connections
\\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16.
\\
\\--cdp_max_pending_connections
\\ Maximum pending connections in the accept queue.
\\ Defaults to 128.
\\
++ common_options ++
\\
\\mcp command
\\Starts an MCP (Model Context Protocol) server over stdio
\\Example: {s} mcp
\\
++ common_options ++
\\
\\version command
\\Displays the version of {s}
\\
\\help command
\\Displays this message
\\
;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) {
return std.process.cleanExit();
}
std.process.exit(1);
}
pub fn parseArgs(allocator: Allocator) !Config {
var args = try std.process.argsWithAllocator(allocator);
defer args.deinit();
const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?));
const mode_string = args.next() orelse "";
const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: {
const inferred_mode = inferMode(mode_string) orelse
return init(allocator, exec_name, .{ .help = false });
// "command" wasn't a command but an option. We can't reset args, but
// we can create a new one. Not great, but this fallback is temporary
// as we transition to this command mode approach.
args.deinit();
args = try std.process.argsWithAllocator(allocator);
// skip the exec_name
_ = args.skip();
break :blk inferred_mode;
};
const mode: Mode = switch (run_mode) {
.help => .{ .help = true },
.serve => .{ .serve = parseServeArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} },
};
return init(allocator, exec_name, mode);
}
fn inferMode(opt: []const u8) ?RunMode {
if (opt.len == 0) {
return .serve;
}
if (std.mem.startsWith(u8, opt, "--") == false) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--dump")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--noscript")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--strip_mode")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--with_base")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--with_frames")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--host")) {
return .serve;
}
if (std.mem.eql(u8, opt, "--port")) {
return .serve;
}
if (std.mem.eql(u8, opt, "--timeout")) {
return .serve;
}
return null;
}
fn parseServeArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Serve {
var serve: Serve = .{};
while (args.next()) |opt| {
if (std.mem.eql(u8, "--host", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--host" });
return error.InvalidArgument;
};
serve.host = try allocator.dupe(u8, str);
continue;
}
if (std.mem.eql(u8, "--port", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--port" });
return error.InvalidArgument;
};
serve.port = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
return error.InvalidArgument;
};
serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
return error.InvalidArgument;
};
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
return error.InvalidArgument;
};
serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_pending_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (try parseCommonArg(allocator, opt, args, &serve.common)) {
continue;
}
log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt });
return error.UnkownOption;
}
return serve;
}
fn parseMcpArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Mcp {
var mcp: Mcp = .{};
while (args.next()) |opt| {
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
continue;
}
log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt });
return error.UnkownOption;
}
return mcp;
}
fn parseFetchArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Fetch {
var dump_mode: ?DumpFormat = null;
var with_base: bool = false;
var with_frames: bool = false;
var url: ?[:0]const u8 = null;
var common: Common = .{};
var strip: dump.Opts.Strip = .{};
while (args.next()) |opt| {
if (std.mem.eql(u8, "--dump", opt)) {
var peek_args = args.*;
if (peek_args.next()) |next_arg| {
if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| {
dump_mode = mode;
_ = args.next();
} else {
dump_mode = .html;
}
} else {
dump_mode = .html;
}
continue;
}
if (std.mem.eql(u8, "--noscript", opt)) {
log.warn(.app, "deprecation warning", .{
.feature = "--noscript argument",
.hint = "use '--strip_mode js' instead",
});
strip.js = true;
continue;
}
if (std.mem.eql(u8, "--with_base", opt)) {
with_base = true;
continue;
}
if (std.mem.eql(u8, "--with_frames", opt)) {
with_frames = true;
continue;
}
if (std.mem.eql(u8, "--strip_mode", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" });
return error.InvalidArgument;
};
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, "js")) {
strip.js = true;
} else if (std.mem.eql(u8, trimmed, "ui")) {
strip.ui = true;
} else if (std.mem.eql(u8, trimmed, "css")) {
strip.css = true;
} else if (std.mem.eql(u8, trimmed, "full")) {
strip.js = true;
strip.ui = true;
strip.css = true;
} else {
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed });
}
}
continue;
}
if (try parseCommonArg(allocator, opt, args, &common)) {
continue;
}
if (std.mem.startsWith(u8, opt, "--")) {
log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt });
return error.UnkownOption;
}
if (url != null) {
log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" });
return error.TooManyURLs;
}
url = try allocator.dupeZ(u8, opt);
}
if (url == null) {
log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" });
return error.MissingURL;
}
return .{
.url = url.?,
.dump_mode = dump_mode,
.strip = strip,
.common = common,
.with_base = with_base,
.with_frames = with_frames,
};
}
fn parseCommonArg(
allocator: Allocator,
opt: []const u8,
args: *std.process.ArgIterator,
common: *Common,
) !bool {
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
common.tls_verify_host = false;
return true;
}
if (std.mem.eql(u8, "--obey_robots", opt)) {
common.obey_robots = true;
return true;
}
if (std.mem.eql(u8, "--http_proxy", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
return error.InvalidArgument;
};
common.http_proxy = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
return error.InvalidArgument;
};
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
return error.InvalidArgument;
};
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
return error.InvalidArgument;
};
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
return error.InvalidArgument;
};
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
return error.InvalidArgument;
};
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
return error.InvalidArgument;
};
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
return error.InvalidArgument;
};
common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: {
if (std.mem.eql(u8, str, "error")) {
break :blk .err;
}
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_format", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
return error.InvalidArgument;
};
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
if (builtin.mode != .Debug) {
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
return false;
}
const str = args.next() orelse {
// disables the default filters
common.log_filter_scopes = &.{};
return true;
};
var arr: std.ArrayList(log.Scope) = .empty;
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
return false;
});
}
common.log_filter_scopes = arr.items;
return true;
}
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
};
for (str) |c| {
if (!std.ascii.isPrint(c)) {
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
}
}
common.user_agent_suffix = try allocator.dupe(u8, str);
return true;
}
return false;
}

1375
src/Net.zig Normal file

File diff suppressed because it is too large Load Diff

419
src/Notification.zig Normal file
View File

@@ -0,0 +1,419 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = @import("log.zig");
const Page = @import("browser/Page.zig");
const Transfer = @import("http/Client.zig").Transfer;
const Allocator = std.mem.Allocator;
const List = std.DoublyLinkedList;
// Allows code to register for and emit events.
// Keeps two lists
// 1 - for a given event type, a linked list of all the listeners
// 2 - for a given listener, a list of all it's registration
// The 2nd one is so that a listener can unregister all of it's listeners
// (there's currently no need for a listener to unregister only 1 or more
// specific listener).
//
// Scoping is important. Imagine we created a global singleton registry, and our
// CDP code registers for the "network_bytes_sent" event, because it needs to
// send messages to the client when this happens. Our HTTP client could then
// emit a "network_bytes_sent" message. It would be easy, and it would work.
// That is, it would work until multiple CDP clients connect, and because
// everything's just one big global, events from one CDP session would be sent
// to all CDP clients.
//
// To avoid this, one way or another, we need scoping. We could still have
// a global registry but every "register" and every "emit" has some type of
// "scope". This would have a run-time cost and still require some coordination
// between components to share a common scope.
//
// Instead, the approach that we take is to have a notification instance per
// CDP connection (BrowserContext). Each CDP connection has its own notification
// that is shared across all Sessions (tabs) within that connection. This ensures
// proper isolation between different CDP clients while allowing a single client
// to receive events from all its tabs.
const Notification = @This();
// Every event type (which are hard-coded), has a list of Listeners.
// When the event happens, we dispatch to those listener.
event_listeners: EventListeners,
// list of listeners for a specified receiver
// @intFromPtr(receiver) -> [listener1, listener2, ...]
// Used when `unregisterAll` is called.
listeners: std.AutoHashMapUnmanaged(usize, std.ArrayList(*Listener)),
allocator: Allocator,
mem_pool: std.heap.MemoryPool(Listener),
const EventListeners = struct {
page_remove: List = .{},
page_created: List = .{},
page_navigate: List = .{},
page_navigated: List = .{},
page_network_idle: List = .{},
page_network_almost_idle: List = .{},
page_frame_created: List = .{},
http_request_fail: List = .{},
http_request_start: List = .{},
http_request_intercept: List = .{},
http_request_done: List = .{},
http_request_auth_required: List = .{},
http_response_data: List = .{},
http_response_header_done: List = .{},
};
const Events = union(enum) {
page_remove: PageRemove,
page_created: *Page,
page_navigate: *const PageNavigate,
page_navigated: *const PageNavigated,
page_network_idle: *const PageNetworkIdle,
page_network_almost_idle: *const PageNetworkAlmostIdle,
page_frame_created: *const PageFrameCreated,
http_request_fail: *const RequestFail,
http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept,
http_request_auth_required: *const RequestAuthRequired,
http_request_done: *const RequestDone,
http_response_data: *const ResponseData,
http_response_header_done: *const ResponseHeaderDone,
};
const EventType = std.meta.FieldEnum(Events);
pub const PageRemove = struct {};
pub const PageNavigate = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Page.NavigateOpts,
};
pub const PageNavigated = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Page.NavigatedOpts,
};
pub const PageNetworkIdle = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageNetworkAlmostIdle = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageFrameCreated = struct {
frame_id: u32,
parent_id: u32,
timestamp: u64,
};
pub const RequestStart = struct {
transfer: *Transfer,
};
pub const RequestIntercept = struct {
transfer: *Transfer,
wait_for_interception: *bool,
};
pub const RequestAuthRequired = struct {
transfer: *Transfer,
wait_for_interception: *bool,
};
pub const ResponseData = struct {
data: []const u8,
transfer: *Transfer,
};
pub const ResponseHeaderDone = struct {
transfer: *Transfer,
};
pub const RequestDone = struct {
transfer: *Transfer,
};
pub const RequestFail = struct {
transfer: *Transfer,
err: anyerror,
};
pub fn init(allocator: Allocator) !*Notification {
const notification = try allocator.create(Notification);
errdefer allocator.destroy(notification);
notification.* = .{
.listeners = .{},
.event_listeners = .{},
.allocator = allocator,
.mem_pool = std.heap.MemoryPool(Listener).init(allocator),
};
return notification;
}
pub fn deinit(self: *Notification) void {
const allocator = self.allocator;
var it = self.listeners.valueIterator();
while (it.next()) |listener| {
listener.deinit(allocator);
}
self.listeners.deinit(allocator);
self.mem_pool.deinit();
allocator.destroy(self);
}
pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void {
var list = &@field(self.event_listeners, @tagName(event));
var listener = try self.mem_pool.create();
errdefer self.mem_pool.destroy(listener);
listener.* = .{
.node = .{},
.list = list,
.receiver = receiver,
.event = event,
.func = @ptrCast(func),
.struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
};
const allocator = self.allocator;
const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
if (gop.found_existing == false) {
gop.value_ptr.* = .{};
}
try gop.value_ptr.append(allocator, listener);
// we don't add this until we've successfully added the entry to
// self.listeners
list.append(&listener.node);
}
pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void {
var listeners = self.listeners.getPtr(@intFromPtr(receiver)) orelse return;
var i: usize = 0;
while (i < listeners.items.len) {
const listener = listeners.items[i];
if (listener.event != event) {
i += 1;
continue;
}
listener.list.remove(&listener.node);
self.mem_pool.destroy(listener);
_ = listeners.swapRemove(i);
}
if (listeners.items.len == 0) {
listeners.deinit(self.allocator);
const removed = self.listeners.remove(@intFromPtr(receiver));
lp.assert(removed == true, "Notification.unregister", .{ .type = event });
}
}
pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
var kv = self.listeners.fetchRemove(@intFromPtr(receiver)) orelse return;
for (kv.value.items) |listener| {
listener.list.remove(&listener.node);
self.mem_pool.destroy(listener);
}
kv.value.deinit(self.allocator);
}
pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void {
if (self.listeners.count() == 0) {
return;
}
const list = &@field(self.event_listeners, @tagName(event));
var node = list.first;
while (node) |n| {
const listener: *Listener = @fieldParentPtr("node", n);
const func: EventFunc(event) = @ptrCast(@alignCast(listener.func));
func(listener.receiver, data) catch |err| {
log.err(.app, "dispatch error", .{
.err = err,
.event = event,
.source = "notification",
.listener = listener.struct_name,
});
};
node = n.next;
}
}
// Given an event type enum, returns the type of arg the event emits
fn ArgType(comptime event: Notification.EventType) type {
inline for (std.meta.fields(Notification.Events)) |f| {
if (std.mem.eql(u8, f.name, @tagName(event))) {
return f.type;
}
}
unreachable;
}
// Given an event type enum, returns the listening function type
fn EventFunc(comptime event: Notification.EventType) type {
return *const fn (*anyopaque, ArgType(event)) anyerror!void;
}
// A listener. This is 1 receiver, with its function, and the linked list
// node that goes in the appropriate EventListeners list.
const Listener = struct {
// the receiver of the event, i.e. the self parameter to `func`
receiver: *anyopaque,
// the function to call
func: *const anyopaque,
// For logging slightly better error
struct_name: []const u8,
event: Notification.EventType,
// intrusive linked list node
node: List.Node,
// The event list this listener belongs to.
// We need this in order to be able to remove the node from the list
list: *List,
};
const testing = std.testing;
test "Notification" {
var notifier = try Notification.init(testing.allocator);
defer notifier.deinit();
// noop
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
.opts = .{},
});
var tc = TestClient{};
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
.opts = .{},
});
try testing.expectEqual(4, tc.page_navigate);
notifier.unregisterAll(&tc);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
.opts = .{},
});
try testing.expectEqual(4, tc.page_navigate);
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .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, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 100,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
try testing.expectEqual(14, tc.page_navigate);
try testing.expectEqual(6, tc.page_navigated);
{
// unregister
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .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, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .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, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .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, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.page_navigate);
try testing.expectEqual(2006, tc.page_navigated);
}
}
const TestClient = struct {
page_navigate: u64 = 0,
page_navigated: u64 = 0,
fn pageNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void {
const self: *TestClient = @ptrCast(@alignCast(ptr));
self.page_navigate += data.timestamp;
}
fn pageNavigated(ptr: *anyopaque, data: *const Notification.PageNavigated) !void {
const self: *TestClient = @ptrCast(@alignCast(ptr));
self.page_navigated += data.timestamp;
}
};

981
src/Server.zig Normal file
View File

@@ -0,0 +1,981 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const net = std.net;
const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const Net = @import("Net.zig");
const Http = @import("http/Http.zig");
const HttpClient = @import("http/Client.zig");
const Server = @This();
app: *App,
shutdown: std.atomic.Value(bool) = .init(false),
allocator: Allocator,
listener: ?posix.socket_t,
json_version_response: []const u8,
// Thread management
active_threads: std.atomic.Value(u32) = .init(0),
clients: std.ArrayList(*Client) = .{},
client_mutex: std.Thread.Mutex = .{},
clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(allocator, address);
errdefer allocator.free(json_version_response);
return .{
.app = app,
.listener = null,
.allocator = allocator,
.json_version_response = json_version_response,
.clients_pool = std.heap.MemoryPool(Client).init(app.allocator),
};
}
/// Interrupts the server so that main can complete normally and call all defer handlers.
pub fn stop(self: *Server) void {
if (self.shutdown.swap(true, .release)) {
return;
}
// Shutdown all active clients
{
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items) |client| {
client.stop();
}
}
// Linux and BSD/macOS handle canceling a socket blocked on accept differently.
// For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL).
// For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF).
if (self.listener) |listener| switch (builtin.target.os.tag) {
.linux => posix.shutdown(listener, .recv) catch |err| {
log.warn(.app, "listener shutdown", .{ .err = err });
},
.macos, .freebsd, .netbsd, .openbsd => {
self.listener = null;
posix.close(listener);
},
else => unreachable,
};
}
pub fn deinit(self: *Server) void {
if (!self.shutdown.load(.acquire)) {
self.stop();
}
self.joinThreads();
if (self.listener) |listener| {
posix.close(listener);
self.listener = null;
}
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
self.allocator.free(self.json_version_response);
}
pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK;
const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
self.listener = listener;
try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
if (@hasDecl(posix.TCP, "NODELAY")) {
try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
}
try posix.bind(listener, &address.any, address.getOsSockLen());
try posix.listen(listener, self.app.config.maxPendingConnections());
log.info(.app, "server running", .{ .address = address });
while (!self.shutdown.load(.acquire)) {
const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
switch (err) {
error.SocketNotListening, error.ConnectionAborted => {
log.info(.app, "server stopped", .{});
break;
},
error.WouldBlock => {
std.Thread.sleep(10 * std.time.ns_per_ms);
continue;
},
else => {
log.err(.app, "CDP accept", .{ .err = err });
std.Thread.sleep(std.time.ns_per_s);
continue;
},
}
};
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
}
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
defer posix.close(socket);
// Client is HUGE (> 512KB) because it has a large read buffer.
// V8 crashes if this is on the stack (likely related to its size).
const client = self.getClient() catch |err| {
log.err(.app, "CDP client create", .{ .err = err });
return;
};
defer self.releaseClient(client);
client.* = Client.init(
socket,
self.allocator,
self.app,
self.json_version_response,
timeout_ms,
) catch |err| {
log.err(.app, "CDP client init", .{ .err = err });
return;
};
defer client.deinit();
self.registerClient(client);
defer self.unregisterClient(client);
// Check shutdown after registering to avoid missing stop() signal.
// If stop() already iterated over clients, this client won't receive stop()
// and would block joinThreads() indefinitely.
if (self.shutdown.load(.acquire)) {
return;
}
client.start();
}
fn getClient(self: *Server) !*Client {
self.client_mutex.lock();
defer self.client_mutex.unlock();
return self.clients_pool.create();
}
fn releaseClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients_pool.destroy(client);
}
fn registerClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients.append(self.allocator, client) catch {};
}
fn unregisterClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items, 0..) |c, i| {
if (c == client) {
_ = self.clients.swapRemove(i);
break;
}
}
}
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
if (self.shutdown.load(.acquire)) {
return error.ShuttingDown;
}
// Atomically increment active_threads only if below max_connections.
// Uses CAS loop to avoid race between checking the limit and incrementing.
//
// cmpxchgWeak may fail for two reasons:
// 1. Another thread changed the value (increment or decrement)
// 2. Spurious failure on some architectures (e.g. ARM)
//
// We use Weak instead of Strong because we need a retry loop anyway:
// if CAS fails because a thread finished (counter decreased), we should
// retry rather than return an error - there may now be room for a new connection.
//
// On failure, cmpxchgWeak returns the actual value, which we reuse to avoid
// an extra load on the next iteration.
const max_connections = self.app.config.maxConnections();
var current = self.active_threads.load(.monotonic);
while (current < max_connections) {
current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break;
} else {
return error.MaxThreadsReached;
}
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms });
thread.detach();
}
fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
defer _ = self.active_threads.fetchSub(1, .monotonic);
handleConnection(self, socket, timeout_ms);
}
fn joinThreads(self: *Server) void {
while (self.active_threads.load(.monotonic) > 0) {
std.Thread.sleep(10 * std.time.ns_per_ms);
}
}
// Handle exactly one TCP connection.
pub const Client = struct {
// The client is initially serving HTTP requests but, under normal circumstances
// should eventually be upgraded to a websocket connections
mode: union(enum) {
http: void,
cdp: CDP,
},
allocator: Allocator,
app: *App,
http: *HttpClient,
ws: Net.WsConnection,
fn init(
socket: posix.socket_t,
allocator: Allocator,
app: *App,
json_version_response: []const u8,
timeout_ms: u32,
) !Client {
var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms);
errdefer ws.deinit();
if (log.enabled(.app, .info)) {
const client_address = ws.getAddress() catch null;
log.info(.app, "client connected", .{ .ip = client_address });
}
const http = try app.http.createClient(allocator);
errdefer http.deinit();
return .{
.allocator = allocator,
.app = app,
.http = http,
.ws = ws,
.mode = .{ .http = {} },
};
}
fn stop(self: *Client) void {
self.ws.shutdown();
}
fn deinit(self: *Client) void {
switch (self.mode) {
.cdp => |*cdp| cdp.deinit(),
.http => {},
}
self.ws.deinit();
self.http.deinit();
}
fn start(self: *Client) void {
const http = self.http;
http.cdp_client = .{
.socket = self.ws.socket,
.ctx = self,
.blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead,
.blocking_read_end = Client.blockingReadStop,
};
defer http.cdp_client = null;
self.httpLoop(http) catch |err| {
log.err(.app, "CDP client loop", .{ .err = err });
};
}
fn httpLoop(self: *Client, http: *HttpClient) !void {
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
while (true) {
const status = http.tick(self.ws.timeout_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (self.readSocket() == false) {
return;
}
if (self.mode == .cdp) {
break;
}
}
var cdp = &self.mode.cdp;
var last_message = timestamp(.monotonic);
var ms_remaining = self.ws.timeout_ms;
while (true) {
switch (cdp.pageWait(ms_remaining)) {
.cdp_socket => {
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.no_page => {
const status = http.tick(ms_remaining) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.done => {
const elapsed = timestamp(.monotonic) - last_message;
if (elapsed > ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
},
}
}
}
fn blockingReadStart(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(true) catch |err| {
log.warn(.app, "CDP blockingReadStart", .{ .err = err });
return false;
};
return true;
}
fn blockingRead(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
return self.readSocket();
}
fn blockingReadStop(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(false) catch |err| {
log.warn(.app, "CDP blockingReadStop", .{ .err = err });
return false;
};
return true;
}
fn readSocket(self: *Client) bool {
const n = self.ws.read() catch |err| {
log.warn(.app, "CDP read", .{ .err = err });
return false;
};
if (n == 0) {
log.info(.app, "CDP disconnect", .{});
return false;
}
return self.processData() catch false;
}
fn processData(self: *Client) !bool {
switch (self.mode) {
.cdp => |*cdp| return self.processWebsocketMessage(cdp),
.http => return self.processHTTPRequest(),
}
}
fn processHTTPRequest(self: *Client) !bool {
lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos });
const request = self.ws.reader.buf[0..self.ws.reader.len];
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
self.writeHTTPErrorResponse(413, "Request too large");
return error.RequestTooLarge;
}
// we're only expecting [body-less] GET requests.
if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) {
// we need more data, put any more data here
return true;
}
// the next incoming data can go to the front of our buffer
defer self.ws.reader.len = 0;
return self.handleHTTPRequest(request) catch |err| {
switch (err) {
error.NotFound => self.writeHTTPErrorResponse(404, "Not found"),
error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"),
error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"),
error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"),
error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"),
error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"),
error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"),
else => {
log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] });
self.writeHTTPErrorResponse(500, "Internal Server Error");
},
}
return err;
};
}
fn handleHTTPRequest(self: *Client, request: []u8) !bool {
if (request.len < 18) {
// 18 is [generously] the smallest acceptable HTTP request
return error.InvalidRequest;
}
if (std.mem.eql(u8, request[0..4], "GET ") == false) {
return error.NotFound;
}
const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse {
return error.InvalidRequest;
};
const url = request[4..url_end];
if (std.mem.eql(u8, url, "/")) {
try self.upgradeConnection(request);
return true;
}
if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
try self.ws.send(self.ws.json_version_response);
// Chromedp (a Go driver) does an http request to /json/version
// then to / (websocket upgrade) using a different connection.
// Since we only allow 1 connection at a time, the 2nd one (the
// websocket upgrade) blocks until the first one times out.
// We can avoid that by closing the connection. json_version_response
// has a Connection: Close header too.
self.ws.shutdown();
return false;
}
return error.NotFound;
}
fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
}
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
self.ws.sendHttpError(status, body);
}
fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool {
return self.ws.processMessages(cdp);
}
pub fn sendAllocator(self: *Client) Allocator {
return self.ws.send_arena.allocator();
}
pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void {
return self.ws.sendJSON(message, opts);
}
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
return self.ws.sendJSONRaw(buf);
}
};
// Utils
// --------
fn buildJSONVersionResponse(
allocator: Allocator,
address: net.Address,
) ![]const u8 {
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}";
const body_len = std.fmt.count(body_format, .{address});
// We send a Connection: Close (and actually close the connection)
// because chromedp (Go driver) sends a request to /json/version and then
// does an upgrade request, on a different connection. Since we only allow
// 1 connection at a time, the upgrade connection doesn't proceed until we
// timeout the /json/version. So, instead of waiting for that, we just
// always close HTTP requests.
const response_format =
"HTTP/1.1 200 OK\r\n" ++
"Content-Length: {d}\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format;
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
}
pub const timestamp = @import("datetime.zig").timestamp;
const testing = std.testing;
test "server: buildJSONVersionResponse" {
const address = try net.Address.parseIp4("127.0.0.1", 9001);
const res = try buildJSONVersionResponse(testing.allocator, address);
defer testing.allocator.free(res);
try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res);
}
test "Client: http invalid request" {
var c = try createTestClient();
defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++
"Request too large", res);
}
test "Client: http invalid handshake" {
try assertHTTPError(
400,
"Invalid request",
"\r\n\r\n",
);
try assertHTTPError(
404,
"Not found",
"GET /over/9000 HTTP/1.1\r\n\r\n",
);
try assertHTTPError(
404,
"Not found",
"POST / HTTP/1.1\r\n\r\n",
);
try assertHTTPError(
400,
"Invalid HTTP protocol",
"GET / HTTP/1.0\r\n\r\n",
);
try assertHTTPError(
400,
"Missing required header",
"GET / HTTP/1.1\r\n\r\n",
);
try assertHTTPError(
400,
"Missing required header",
"GET / HTTP/1.1\r\nConnection: upgrade\r\n\r\n",
);
try assertHTTPError(
400,
"Missing required header",
"GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n",
);
try assertHTTPError(
400,
"Missing required header",
"GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\n\r\n",
);
}
test "Client: http valid handshake" {
var c = try createTestClient();
defer c.deinit();
const request =
"GET / HTTP/1.1\r\n" ++
"Connection: upgrade\r\n" ++
"Upgrade: websocket\r\n" ++
"sec-websocket-version:13\r\n" ++
"sec-websocket-key: this is my key\r\n" ++
"Custom: Header-Value\r\n\r\n";
const res = try c.httpRequest(request);
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
}
test "Client: read invalid websocket message" {
// 131 = 128 (fin) | 3 where 3 isn't a valid type
try assertWebSocketError(
1002,
&.{ 131, 128, 'm', 'a', 's', 'k' },
);
for ([_]u8{ 16, 32, 64 }) |rsv| {
// none of the reserve flags should be set
try assertWebSocketError(
1002,
&.{ rsv, 128, 'm', 'a', 's', 'k' },
);
// as a bitmask
try assertWebSocketError(
1002,
&.{ rsv + 4, 128, 'm', 'a', 's', 'k' },
);
}
// client->server messages must be masked
try assertWebSocketError(
1002,
&.{ 129, 1, 'a' },
);
// control types (ping/ping/close) can't be > 125 bytes
for ([_]u8{ 136, 137, 138 }) |op| {
try assertWebSocketError(
1002,
&.{ op, 254, 1, 1 },
);
}
// length of message is 0000 0810, i.e: 1024 * 512 + 265
try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 8, 1, 0, 'm', 'a', 's', 'k' });
// continuation type message must come after a normal message
// even when not a fin frame
try assertWebSocketError(
1002,
&.{ 0, 129, 'm', 'a', 's', 'k', 'd' },
);
// continuation type message must come after a normal message
// even as a fin frame
try assertWebSocketError(
1002,
&.{ 128, 129, 'm', 'a', 's', 'k', 'd' },
);
// text (non-fin) - text (non-fin)
try assertWebSocketError(
1002,
&.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' },
);
// text (non-fin) - text (fin) should always been continuation after non-fin
try assertWebSocketError(
1002,
&.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' },
);
// close must be fin
try assertWebSocketError(
1002,
&.{
8, 129, 'm', 'a', 's', 'k', 'd',
},
);
// ping must be fin
try assertWebSocketError(
1002,
&.{
9, 129, 'm', 'a', 's', 'k', 'd',
},
);
// pong must be fin
try assertWebSocketError(
1002,
&.{
10, 129, 'm', 'a', 's', 'k', 'd',
},
);
}
test "Client: ping reply" {
try assertWebSocketMessage(
// fin | pong, len
&.{ 138, 0 },
// fin | ping, masked | len, 4-byte mask
&.{ 137, 128, 0, 0, 0, 0 },
);
try assertWebSocketMessage(
// fin | pong, len, payload
&.{ 138, 5, 100, 96, 97, 109, 104 },
// fin | ping, masked | len, 4-byte mask, 5 byte payload
&.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 },
);
}
test "Client: close message" {
try assertWebSocketMessage(
// fin | close, len, close code (normal)
&.{ 136, 2, 3, 232 },
// fin | close, masked | len, 4-byte mask
&.{ 136, 128, 0, 0, 0, 0 },
);
}
test "server: 404" {
var c = try createTestClient();
defer c.deinit();
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 9\r\n\r\n" ++
"Not found", res);
}
test "server: get /json/version" {
const expected_response =
"HTTP/1.1 200 OK\r\n" ++
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}";
{
// twice on the same connection
var c = try createTestClient();
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings(expected_response, res1);
}
{
// again on a new connection
var c = try createTestClient();
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings(expected_response, res1);
}
}
fn assertHTTPError(
comptime expected_status: u16,
comptime expected_body: []const u8,
input: []const u8,
) !void {
var c = try createTestClient();
defer c.deinit();
const res = try c.httpRequest(input);
const expected_response = std.fmt.comptimePrint(
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
.{ expected_status, expected_body.len, expected_body },
);
try testing.expectEqualStrings(expected_response, res);
}
fn assertWebSocketError(close_code: u16, input: []const u8) !void {
var c = try createTestClient();
defer c.deinit();
try c.handshake();
try c.stream.writeAll(input);
const msg = try c.readWebsocketMessage() orelse return error.NoMessage;
defer if (msg.cleanup_fragment) {
c.reader.cleanup();
};
try testing.expectEqual(.close, msg.type);
try testing.expectEqual(2, msg.data.len);
try testing.expectEqual(close_code, std.mem.readInt(u16, msg.data[0..2], .big));
}
fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void {
var c = try createTestClient();
defer c.deinit();
try c.handshake();
try c.stream.writeAll(input);
const msg = try c.readWebsocketMessage() orelse return error.NoMessage;
defer if (msg.cleanup_fragment) {
c.reader.cleanup();
};
const actual = c.reader.buf[0 .. msg.data.len + 2];
try testing.expectEqualSlices(u8, expected, actual);
}
const MockCDP = struct {
messages: std.ArrayList([]const u8) = .{},
allocator: Allocator = testing.allocator,
fn init(_: Allocator, client: anytype) MockCDP {
_ = client;
return .{};
}
fn deinit(self: *MockCDP) void {
const allocator = self.allocator;
for (self.messages.items) |msg| {
allocator.free(msg);
}
self.messages.deinit(allocator);
}
fn handleMessage(self: *MockCDP, message: []const u8) bool {
const owned = self.allocator.dupe(u8, message) catch unreachable;
self.messages.append(self.allocator, owned) catch unreachable;
return true;
}
};
fn createTestClient() !TestClient {
const address = std.net.Address.initIp4([_]u8{ 127, 0, 0, 1 }, 9583);
const stream = try std.net.tcpConnectToAddress(address);
const timeout = std.mem.toBytes(posix.timeval{
.sec = 2,
.usec = 0,
});
try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
return .{
.stream = stream,
.reader = .{
.allocator = testing.allocator,
.buf = try testing.allocator.alloc(u8, 1024 * 16),
},
};
}
const TestClient = struct {
stream: std.net.Stream,
buf: [1024]u8 = undefined,
reader: Net.Reader(false),
fn deinit(self: *TestClient) void {
self.stream.close();
self.reader.deinit();
}
fn httpRequest(self: *TestClient, req: []const u8) ![]const u8 {
try self.stream.writeAll(req);
var pos: usize = 0;
var total_length: ?usize = null;
while (true) {
pos += try self.stream.read(self.buf[pos..]);
if (pos == 0) {
return error.NoMoreData;
}
const response = self.buf[0..pos];
if (total_length == null) {
const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue;
const header = response[0 .. header_end + 4];
const cl = blk: {
const cl_header = "Content-Length: ";
const start = (std.mem.indexOf(u8, header, cl_header) orelse {
break :blk 0;
}) + cl_header.len;
const end = std.mem.indexOfScalarPos(u8, header, start, '\r') orelse {
return error.InvalidContentLength;
};
break :blk std.fmt.parseInt(usize, header[start..end], 10) catch {
return error.InvalidContentLength;
};
};
total_length = cl + header.len;
}
if (total_length) |tl| {
if (pos == tl) {
return response;
}
if (pos > tl) {
return error.DataExceedsContentLength;
}
}
}
}
fn handshake(self: *TestClient) !void {
const request =
"GET / HTTP/1.1\r\n" ++
"Connection: upgrade\r\n" ++
"Upgrade: websocket\r\n" ++
"sec-websocket-version:13\r\n" ++
"sec-websocket-key: this is my key\r\n" ++
"Custom: Header-Value\r\n\r\n";
const res = try self.httpRequest(request);
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
}
fn readWebsocketMessage(self: *TestClient) !?Net.Message {
while (true) {
const n = try self.stream.read(self.reader.readBuf());
if (n == 0) {
return error.Closed;
}
self.reader.len += n;
if (try self.reader.next()) |msg| {
return msg;
}
}
}
};

107
src/Sighandler.zig Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! This structure processes operating system signals (SIGINT, SIGTERM)
//! and runs callbacks to clean up the system gracefully.
//!
//! The structure does not clear the memory allocated in the arena,
//! clear the entire arena when exiting the program.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const lp = @import("lightpanda");
const log = lp.log;
const SigHandler = @This();
arena: Allocator,
sigset: std.posix.sigset_t = undefined,
handle_thread: ?std.Thread = null,
attempt: u32 = 0,
listeners: std.ArrayList(Listener) = .empty,
pub const Listener = struct {
args: []const u8,
start: *const fn (context: *const anyopaque) void,
};
pub fn install(self: *SigHandler) !void {
// Block SIGINT and SIGTERM for the current thread and all created from it
self.sigset = std.posix.sigemptyset();
std.posix.sigaddset(&self.sigset, std.posix.SIG.INT);
std.posix.sigaddset(&self.sigset, std.posix.SIG.TERM);
std.posix.sigaddset(&self.sigset, std.posix.SIG.QUIT);
std.posix.sigprocmask(std.posix.SIG.BLOCK, &self.sigset, null);
self.handle_thread = try std.Thread.spawn(.{ .allocator = self.arena }, SigHandler.sighandle, .{self});
self.handle_thread.?.detach();
}
pub fn on(self: *SigHandler, func: anytype, args: std.meta.ArgsTuple(@TypeOf(func))) !void {
assert(@typeInfo(@TypeOf(func)).@"fn".return_type.? == void);
const Args = @TypeOf(args);
const TypeErased = struct {
fn start(context: *const anyopaque) void {
const args_casted: *const Args = @ptrCast(@alignCast(context));
@call(.auto, func, args_casted.*);
}
};
const buffer = try self.arena.alignedAlloc(u8, .of(Args), @sizeOf(Args));
errdefer self.arena.free(buffer);
const bytes: []const u8 = @ptrCast((&args)[0..1]);
@memcpy(buffer, bytes);
try self.listeners.append(self.arena, .{
.args = buffer,
.start = TypeErased.start,
});
}
fn sighandle(self: *SigHandler) noreturn {
while (true) {
var sig: c_int = 0;
const rc = std.c.sigwait(&self.sigset, &sig);
if (rc != 0) {
log.err(.app, "Unable to process signal {}", .{rc});
std.process.exit(1);
}
switch (sig) {
std.posix.SIG.INT, std.posix.SIG.TERM => {
if (self.attempt > 1) {
std.process.exit(1);
}
self.attempt += 1;
log.info(.app, "Received termination signal...", .{});
for (self.listeners.items) |*item| {
item.start(item.args.ptr);
}
continue;
},
else => continue,
}
}
}

150
src/TestHTTPServer.zig Normal file
View File

@@ -0,0 +1,150 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const TestHTTPServer = @This();
shutdown: std.atomic.Value(bool),
listener: ?std.net.Server,
handler: Handler,
const Handler = *const fn (req: *std.http.Server.Request) anyerror!void;
pub fn init(handler: Handler) TestHTTPServer {
return .{
.shutdown = .init(true),
.listener = null,
.handler = handler,
};
}
pub fn deinit(self: *TestHTTPServer) void {
self.listener = null;
}
pub fn stop(self: *TestHTTPServer) void {
self.shutdown.store(true, .release);
if (self.listener) |*listener| {
switch (@import("builtin").target.os.tag) {
.linux => std.posix.shutdown(listener.stream.handle, .recv) catch {},
else => std.posix.close(listener.stream.handle),
}
}
}
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.?;
self.shutdown.store(false, .release);
wg.finish();
while (true) {
const conn = listener.accept() catch |err| {
if (self.shutdown.load(.acquire) or err == error.SocketNotListening) {
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;
};
}
}
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";
}
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";
}

View File

@@ -1,100 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Loop = @import("runtime/loop.zig").Loop;
const HttpClient = @import("http/client.zig").Client;
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 {
loop: *Loop,
config: Config,
allocator: Allocator,
telemetry: Telemetry,
http_client: HttpClient,
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: ?std.Uri = null,
};
pub fn init(allocator: Allocator, config: Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
const loop = try allocator.create(Loop);
errdefer allocator.destroy(loop);
loop.* = try Loop.init(allocator);
errdefer loop.deinit();
const notification = try Notification.init(allocator, null);
errdefer notification.deinit();
const app_dir_path = getAndMakeAppDir(allocator);
app.* = .{
.loop = loop,
.allocator = allocator,
.telemetry = undefined,
.app_dir_path = app_dir_path,
.notification = notification,
.http_client = try HttpClient.init(allocator, .{
.max_concurrent = 3,
.http_proxy = config.http_proxy,
.tls_verify_host = config.tls_verify_host,
}),
.config = config,
};
app.telemetry = Telemetry.init(app, config.run_mode);
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.loop.deinit();
allocator.destroy(self.loop);
self.http_client.deinit();
self.notification.deinit();
allocator.destroy(self);
}
};
fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
if (@import("builtin").is_test) {
return allocator.dupe(u8, "/tmp") catch unreachable;
}
const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
log.warn(.app, "get data dir", .{ .err = err });
return null;
};
std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
error.PathAlreadyExists => return app_dir_path,
else => {
allocator.free(app_dir_path);
log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path });
return null;
},
};
return app_dir_path;
}

115
src/browser/Browser.zig Normal file
View File

@@ -0,0 +1,115 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const ArenaPool = App.ArenaPool;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @import("Session.zig");
const Notification = @import("../Notification.zig");
// Browser is an instance of the browser.
// You can create multiple browser instances.
// A browser contains only one session.
const Browser = @This();
env: js.Env,
app: *App,
session: ?Session,
allocator: Allocator,
arena_pool: *ArenaPool,
http_client: *HttpClient,
const InitOpts = struct {
env: js.Env.InitOpts = .{},
http_client: *HttpClient,
};
pub fn init(app: *App, opts: InitOpts) !Browser {
const allocator = app.allocator;
var env = try js.Env.init(app, opts.env);
errdefer env.deinit();
return .{
.app = app,
.env = env,
.session = null,
.allocator = allocator,
.arena_pool = &app.arena_pool,
.http_client = opts.http_client,
};
}
pub fn deinit(self: *Browser) void {
self.closeSession();
self.env.deinit();
}
pub fn newSession(self: *Browser, notification: *Notification) !*Session {
self.closeSession();
self.session = @as(Session, undefined);
const session = &self.session.?;
try Session.init(session, self, notification);
return session;
}
pub fn closeSession(self: *Browser) void {
if (self.session) |*session| {
session.deinit();
self.session = null;
self.env.memoryPressureNotification(.critical);
}
}
pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks();
}
pub fn runMacrotasks(self: *Browser) !?u64 {
const env = &self.env;
const time_to_next = try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
return time_to_next;
}
pub fn hasBackgroundTasks(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
pub fn waitForBackgroundTasks(self: *Browser) void {
self.env.waitForBackgroundTasks();
}
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks();
}

View File

@@ -0,0 +1,910 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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 Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const EventKey = struct {
event_target: usize,
type_string: String,
};
const EventKeyContext = struct {
pub fn hash(_: @This(), key: EventKey) u64 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(std.mem.asBytes(&key.event_target));
hasher.update(key.type_string.str());
return hasher.final();
}
pub fn eql(_: @This(), a: EventKey, b: EventKey) bool {
return a.event_target == b.event_target and a.type_string.eql(b.type_string);
}
};
pub const EventManager = @This();
page: *Page,
arena: Allocator,
// Used as an optimization in Page._documentIsComplete. If we know there are no
// 'load' listeners in the document, we can skip dispatching the per-resource
// 'load' event (e.g. amazon product page has no listener and ~350 resources)
has_dom_load_listener: bool,
listener_pool: std.heap.MemoryPool(Listener),
ignore_list: std.ArrayList(*Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.HashMapUnmanaged(
EventKey,
*std.DoublyLinkedList,
EventKeyContext,
std.hash_map.default_max_load_percentage,
),
dispatch_depth: usize,
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
pub fn init(arena: Allocator, page: *Page) EventManager {
return .{
.page = page,
.lookup = .{},
.arena = arena,
.ignore_list = .{},
.list_pool = .init(arena),
.listener_pool = .init(arena),
.dispatch_depth = 0,
.deferred_removals = .{},
.has_dom_load_listener = false,
};
}
pub const RegisterOptions = struct {
once: bool = false,
capture: bool = false,
passive: bool = false,
signal: ?*@import("webapi/AbortSignal.zig") = null,
};
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, .target = target.toString() });
}
// If a signal is provided and already aborted, don't register the listener
if (opts.signal) |signal| {
if (signal.getAborted()) {
return;
}
}
// Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{});
if (type_string.eql(comptime .wrap("load")) and target._type == .node) {
self.has_dom_load_listener = true;
}
const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string,
.event_target = @intFromPtr(target),
});
if (gop.found_existing) {
// check for duplicate callbacks already registered
var node = gop.value_ptr.*.first;
while (node) |n| {
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
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;
}
} else {
gop.value_ptr.* = try self.list_pool.create();
gop.value_ptr.*.* = .{};
}
const func = switch (callback) {
.function => |f| Function{ .value = try f.persist() },
.object => |o| Function{ .object = try o.persist() },
};
const listener = try self.listener_pool.create();
listener.* = .{
.node = .{},
.once = opts.once,
.capture = opts.capture,
.passive = opts.passive,
.function = func,
.signal = opts.signal,
.typ = type_string,
};
// append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node);
// Track load listeners for script execution ignore list
if (type_string.eql(comptime .wrap("load"))) {
try self.ignore_list.append(self.arena, listener);
}
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
const list = self.lookup.get(.{
.type_string = .wrap(typ),
.event_target = @intFromPtr(target),
}) orelse return;
if (findListener(list, callback, use_capture)) |listener| {
self.removeListener(list, listener);
}
}
pub fn clearIgnoreList(self: *EventManager) void {
self.ignore_list.clearRetainingCapacity();
}
// Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and
// inferred error.
const DispatchError = error{
OutOfMemory,
StringTooLarge,
JSExecCallback,
CompilationError,
ExecutionError,
JsException,
};
pub const DispatchOpts = struct {
// A "load" event triggered by a script (in ScriptManager) should not trigger
// a "load" listener added within that script. Therefore, any "load" listener
// that we add go into an ignore list until after the script finishes executing.
// The ignore list is only checked when apply_ignore == true, which is only
// set by the ScriptManager when raising the script's "load" event.
apply_ignore: bool = false,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
return self.dispatchOpts(target, event, .{});
}
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page);
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}
switch (target._type) {
.node => |node| try self.dispatchNode(node, event, opts),
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
}
}
// 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 DispatchDirectOptions = struct {
context: []const u8,
inject_target: bool = true,
};
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
// property handlers. No propagation - just calls the handler and registered listeners.
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const page = self.page;
event.acquireRef();
defer event.deinit(false, page);
if (comptime IS_DEBUG) {
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
}
if (comptime opts.inject_target) {
event._target = target;
event._dispatch_target = target; // Store original target for composedPath()
}
var was_dispatched = false;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer {
ls.local.runMicrotasks();
ls.deinit();
}
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
if (func.callWithThis(void, target, .{event})) {
was_dispatched = true;
} else |err| {
// a non-JS error
log.warn(.event, opts.context, .{ .err = err });
}
}
// listeners reigstered via addEventListener
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
// This is a slightly simplified version of what you'll find in dispatchPhase
// It is simpler because, for direct dispatching, we know there's no ancestors
// and only the single target phase.
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
while (node) |n| {
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
self.removeListener(list, listener);
continue;
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
was_dispatched = true;
event._current_target = target;
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
if (event._stop_immediate_propagation) {
return;
}
}
}
fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
const T = @TypeOf(handler);
const ti = @typeInfo(T);
if (ti == .null) {
return null;
}
if (ti == .optional) {
return getFunction(handler orelse return null, local);
}
return switch (T) {
js.Function => handler,
js.Function.Temp => local.toLocal(handler),
js.Function.Global => local.toLocal(handler),
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
};
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
{
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
}
const page = self.page;
var was_handled = false;
defer if (was_handled) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
const activation_state = ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
state.restore(event, page);
}
// Execute default action if not prevented
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eql(comptime .wrap("click"))) {
page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err });
};
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = err });
};
}
}
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var node: ?*Node = target;
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, most events propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
// The only explicit exception is "load"
if (event._type_string.eql(comptime .wrap("load")) == false) {
if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
}
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)
// This happens for all events, regardless of bubbling
event._event_phase = .capturing_phase;
var i: usize = path_len;
while (i > 1) {
i -= 1;
if (event._stop_propagation) return;
const current_target = path[i];
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(true, opts));
}
}
// Phase 2: At target
if (event._stop_propagation) return;
event._event_phase = .at_target;
const target_et = target.asEventTarget();
blk: {
// Get inline handler (e.g., onclick property) for this target
if (self.getInlineHandler(target_et, event)) |inline_handler| {
was_handled = true;
event._current_target = target_et;
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
if (event._stop_propagation) {
return;
}
if (event._stop_immediate_propagation) {
break :blk;
}
}
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, &was_handled, comptime .init(null, opts));
if (event._stop_propagation) {
return;
}
}
}
// Phase 3: Bubbling phase (target → root, excluding target)
// This only happens if the event bubbles
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (event._stop_propagation) break;
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(false, opts));
}
}
}
}
const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts {
return .{
.capture_only = capture_only,
.apply_ignore = opts.apply_ignore,
};
}
};
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void {
const page = self.page;
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
node_loop: while (node) |n| {
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip non-matching listeners
if (comptime opts.capture_only) |capture| {
if (listener.capture != capture) {
continue;
}
}
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
self.removeListener(list, listener);
continue;
}
}
if (comptime opts.apply_ignore) {
for (self.ignore_list.items) |ignored| {
if (ignored == listener) {
continue :node_loop;
}
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
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);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
// Restore original target (only if we changed it)
if (event._needs_retargeting) {
event._target = original_target;
}
if (event._stop_immediate_propagation) {
return;
}
}
}
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
else => return null,
};
return html_element.getAttributeFunction(handler_type, self.page) catch |err| {
log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err });
return null;
};
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
// If we're in a dispatch, defer removal to avoid invalidating iteration
if (self.dispatch_depth > 0) {
listener.removed = true;
self.deferred_removals.append(self.arena, .{ .list = list, .listener = listener }) catch unreachable;
} else {
// Outside dispatch, remove immediately
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
fn findListener(list: *const std.DoublyLinkedList, callback: Callback, capture: bool) ?*Listener {
var node = list.first;
while (node) |n| {
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
const matches = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (!matches) {
continue;
}
if (listener.capture != capture) {
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,
removed: bool = false,
};
const Function = union(enum) {
value: js.Function.Global,
string: String,
object: js.Object.Global,
fn eqlFunction(self: Function, func: js.Function) bool {
return switch (self) {
.value => |v| v.isEqual(func),
else => false,
};
}
fn eqlObject(self: Function, obj: js.Object) bool {
return switch (self) {
.object => |o| return o.isEqual(obj),
else => false,
};
}
};
// 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;
}
// Handles the default action for clicking on input checked/radio. Maybe this
// could be generalized if needed, but I'm not sure. This wasn't obvious to me
// but when an input is clicked, it's important to think about both the intent
// and the actual result. Imagine you have an unchecked checkbox. When clicked,
// the checkbox immediately becomes checked, and event handlers see this "checked"
// intent. But a listener can preventDefault() in which case the check we did at
// the start will be undone.
// This is a bit more complicated for radio buttons, as the checking/unchecking
// and the rollback can impact a different radio input. So if you "check" a radio
// the intent is that it becomes checked and whatever was checked before becomes
// unchecked, so that if you have to rollback (because of a preventDefault())
// then both inputs have to revert to their original values.
const ActivationState = struct {
old_checked: bool,
input: *Element.Html.Input,
previously_checked_radio: ?*Input,
const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) {
return null;
}
const input = target.is(Element.Html.Input) orelse return null;
if (input._input_type != .checkbox and input._input_type != .radio) {
return null;
}
const old_checked = input._checked;
var previously_checked_radio: ?*Element.Html.Input = null;
// For radio buttons, find the currently checked radio in the group
if (input._input_type == .radio and !old_checked) {
previously_checked_radio = try findCheckedRadioInGroup(input, page);
}
// Toggle checkbox or check radio (which unchecks others in group)
const new_checked = if (input._input_type == .checkbox) !old_checked else true;
try input.setChecked(new_checked, page);
return .{
.input = input,
.old_checked = old_checked,
.previously_checked_radio = previously_checked_radio,
};
}
fn restore(self: *const ActivationState, event: *const Event, page: *Page) void {
const input = self.input;
if (event._prevent_default) {
// Rollback: restore previous state
input._checked = self.old_checked;
input._checked_dirty = true;
if (self.previously_checked_radio) |prev_radio| {
prev_radio._checked = true;
prev_radio._checked_dirty = true;
}
return;
}
// Commit: fire input and change events only if state actually changed
// and the element is connected to a document (detached elements don't fire).
// For checkboxes, state always changes. For radios, only if was unchecked.
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
if (state_changed and input.asElement().asNode().isConnected()) {
fireEvent(page, input, "input") catch |err| {
log.warn(.event, "input event", .{ .err = err });
};
fireEvent(page, input, "change") catch |err| {
log.warn(.event, "change event", .{ .err = err });
};
}
}
fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input {
const elem = input.asElement();
const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null;
if (name.len == 0) {
return null;
}
const form = input.getForm(page);
// Walk from the root of the tree containing this element
// This handles both document-attached and orphaned elements
const root = elem.asNode().getRootNode(null);
const TreeWalker = @import("webapi/TreeWalker.zig");
var walker = TreeWalker.Full.init(root, .{});
while (walker.next()) |node| {
const other_element = node.is(Element) orelse continue;
const other_input = other_element.is(Input) orelse continue;
if (other_input._input_type != .radio) {
continue;
}
// Skip the input we're checking from
if (other_input == input) {
continue;
}
const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
if (!std.mem.eql(u8, name, other_name)) {
continue;
}
// Check if same form context
const other_form = other_input.getForm(page);
if (form) |f| {
const of = other_form orelse continue;
if (f != of) {
continue; // Different forms
}
} else if (other_form != null) {
continue; // form is null but other has a form
}
if (other_input._checked) {
return other_input;
}
}
return null;
}
// Fire input or change event
fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void {
const event = try Event.initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = false,
}, page);
const target = input.asElement().asEventTarget();
try page._event_manager.dispatch(target, event);
}
};

461
src/browser/Factory.zig Normal file
View File

@@ -0,0 +1,461 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const reflect = @import("reflect.zig");
const log = @import("../log.zig");
const String = @import("../string.zig").String;
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 MouseEvent = @import("webapi/event/MouseEvent.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");
const Blob = @import("webapi/Blob.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert;
// Shared across all frames of a Page.
const Factory = @This();
_arena: Allocator,
_slab: SlabAllocator,
pub fn init(arena: Allocator) !*Factory {
const self = try arena.create(Factory);
self.* = .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
return self;
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return self.eventTargetWithAllocator(self._slab.allocator(), child);
}
pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
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);
}
};
}
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._rc = 0,
._arena = arena,
._type = unionInit(Event.Type, value),
._type_string = typ,
._time_stamp = time_stamp,
};
}
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 abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
const doc = page.document.asNode();
chain.set(0, AbstractRange{
._type = unionInit(AbstractRange.Type, chain.get(1)),
._end_offset = 0,
._start_offset = 0,
._end_container = doc,
._start_container = doc,
});
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(
&.{ EventTarget, Node, @TypeOf(child) },
).create(allocator, child);
}
pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) {
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 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 allocator = self._slab.allocator();
return try AutoPrototypeChain(
&.{ EventTarget, Node, Element, @TypeOf(child) },
).create(allocator, child);
}
pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
return try AutoPrototypeChain(
&.{ EventTarget, Node, Element, Element.Html, @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);
if (ChildT == Element.Svg) {
return self.element(child);
}
const chain = try PrototypeChain(
&.{ 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._arena, tag_name, .{}) catch unreachable;
// 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, chain.get(4)),
});
chain.setLeaf(4, child);
return chain.get(4);
}
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
return try AutoPrototypeChain(
&.{ EventTarget, XMLHttpRequestEventTarget, @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);
}
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 (@hasDecl(S, "_prototype_root")) {
// 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;
}
}
}
if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, 0, std.mem.Alignment.@"1");
} else {
self.destroyStandalone(value);
}
}
pub fn destroyStandalone(self: *Factory, value: anytype) void {
const allocator = self._slab.allocator();
allocator.destroy(value);
}
fn destroyChain(
self: *Factory,
value: anytype,
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 new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, new_size, new_align);
} 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: [*]u8 = @ptrCast(@constCast(value));
const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits());
allocator.rawFree(memory_ptr[0..len], new_align, @returnAddress());
}
}
pub fn createT(self: *Factory, comptime T: type) !*T {
const allocator = self._slab.allocator();
return try allocator.create(T);
}
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 {
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");
}

578
src/browser/Mime.zig Normal file
View File

@@ -0,0 +1,578 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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,
charset_len: usize = default_charset_len,
/// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
/// 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,
image_jpeg,
image_gif,
image_png,
image_webp,
application_json,
unknown,
other,
};
pub const ContentType = union(ContentTypeEnum) {
text_xml: void,
text_html: void,
text_javascript: void,
text_plain: void,
text_css: void,
image_jpeg: void,
image_gif: void,
image_png: void,
image_webp: void,
application_json: void,
unknown: void,
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",
.image_jpeg => "image/jpeg",
.image_png => "image/png",
.image_gif => "image/gif",
.image_webp => "image/webp",
.application_json => "application/json",
else => "",
};
}
/// Returns the null-terminated charset value.
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.
///
/// 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 = default_charset;
var charset_len: usize = default_charset_len;
var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| {
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
const name = trimLeft(attr[0..i]);
const value = trimRight(attr[i + 1 ..]);
if (value.len == 0) {
continue;
}
const attribute_name = std.meta.stringToEnum(enum {
charset,
}, name) orelse continue;
switch (attribute_name) {
.charset => {
if (value.len == 0) {
break;
}
const attribute_value = parseCharset(value) catch continue;
@memcpy(charset[0..attribute_value.len], attribute_value);
// Null-terminate right after attribute value.
charset[attribute_value.len] = 0;
charset_len = attribute_value.len;
},
}
}
return .{
.params = params,
.charset = charset,
.charset_len = charset_len,
.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 "<!DOCTYPE HTML ", 15 bytes. If we're
// here, we already know content[0] == '<', so we can skip that. So 14
// bytes.
// +1 because we don't need the leading '<'
var buf: [14]u8 = undefined;
const stripped = content[1..];
const prefix_len = @min(stripped.len, buf.len);
const prefix = std.ascii.lowerString(&buf, stripped[0..prefix_len]);
// we already know it starts with a <
const known_prefixes = [_]struct { []const u8, ContentType }{
.{ "!doctype html", .{ .text_html = {} } },
.{ "html", .{ .text_html = {} } },
.{ "script", .{ .text_html = {} } },
.{ "iframe", .{ .text_html = {} } },
.{ "h1", .{ .text_html = {} } },
.{ "div", .{ .text_html = {} } },
.{ "font", .{ .text_html = {} } },
.{ "table", .{ .text_html = {} } },
.{ "a", .{ .text_html = {} } },
.{ "style", .{ .text_html = {} } },
.{ "title", .{ .text_html = {} } },
.{ "b", .{ .text_html = {} } },
.{ "body", .{ .text_html = {} } },
.{ "br", .{ .text_html = {} } },
.{ "p", .{ .text_html = {} } },
.{ "!--", .{ .text_html = {} } },
.{ "xml", .{ .text_xml = {} } },
};
inline for (known_prefixes) |kp| {
const known_prefix = kp.@"0";
if (std.mem.startsWith(u8, prefix, known_prefix) and prefix.len > 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",
@"image/jpeg",
@"image/png",
@"image/gif",
@"image/webp",
@"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 = {} },
.@"image/jpeg" => .{ .image_jpeg = {} },
.@"image/png" => .{ .image_png = {} },
.@"image/gif" => .{ .image_gif = {} },
.@"image/webp" => .{ .image_webp = {} },
.@"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 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",
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
}
}
test "Mime: malformed parameters are ignored" {
defer testing.reset();
// These should all parse successfully as text/html with malformed params ignored
const valid_with_malformed_params = [_][]const u8{
"text/html; x",
"text/html; x=",
"text/html; x= ",
"text/html; = ",
"text/html;=",
"text/html; charset=\"\"",
"text/html; charset=\"",
"text/html; charset=\"\\",
"text/html;\"",
};
for (valid_with_malformed_params) |input| {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
const mime = try Mime.parse(mutable_input);
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
}
}
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");
try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg");
try expect(.{ .content_type = .{ .image_png = {} } }, "image/png");
try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif");
try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp");
}
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\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "UTF-8",
.params = "x=\"",
}, "text/html;x=\"");
}
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("<htm"));
try testing.expectEqual(null, Mime.sniff("<html!"));
try testing.expectEqual(null, Mime.sniff("<a_"));
try testing.expectEqual(null, Mime.sniff("<!doctype html"));
try testing.expectEqual(null, Mime.sniff("<!doctype html>"));
try testing.expectEqual(null, Mime.sniff("\n <!doctype html>"));
try testing.expectEqual(null, Mime.sniff("\n \t <font/>"));
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("<!doctype html ");
try expectHTML("\n \t <!DOCTYPE HTML ");
try expectHTML("<html ");
try expectHTML("\n \t <HtmL> even more stufff");
try expectHTML("<script>");
try expectHTML("\n \t <SCRIpt >alert(document.cookies)</script>");
try expectHTML("<iframe>");
try expectHTML(" \t <ifRAME >");
try expectHTML("<h1>");
try expectHTML(" <H1>");
try expectHTML("<div>");
try expectHTML("\n\r\r <DiV>");
try expectHTML("<font>");
try expectHTML(" <fonT>");
try expectHTML("<table>");
try expectHTML("\t\t<TAblE>");
try expectHTML("<a>");
try expectHTML("\n\n<A>");
try expectHTML("<style>");
try expectHTML(" \n\t <STyLE>");
try expectHTML("<title>");
try expectHTML(" \n\t <TITLE>");
try expectHTML("<b>");
try expectHTML(" \n\t <B>");
try expectHTML("<body>");
try expectHTML(" \n\t <BODY>");
try expectHTML("<br>");
try expectHTML(" \n\t <BR>");
try expectHTML("<p>");
try expectHTML(" \n\t <P>");
try expectHTML("<!-->");
try expectHTML(" \n\t <!-->");
}
const Expectation = struct {
content_type: Mime.ContentType,
params: []const u8 = "",
charset: ?[]const u8 = null,
};
fn expect(expected: Expectation, input: []const u8) !void {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
const actual = try Mime.parse(mutable_input);
try testing.expectEqual(
std.meta.activeTag(expected.content_type),
std.meta.activeTag(actual.content_type),
);
switch (expected.content_type) {
.other => |e| {
const a = actual.content_type.other;
try testing.expectEqual(e.type, a.type);
try testing.expectEqual(e.sub_type, a.sub_type);
},
else => {}, // already asserted above
}
try testing.expectEqual(expected.params, actual.params);
if (expected.charset) |ec| {
// We remove the null characters for testing purposes here.
try testing.expectEqual(ec, actual.charsetString());
} else {
const m: Mime = .unknown;
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
}
}

3225
src/browser/Page.zig Normal file

File diff suppressed because it is too large Load Diff

1003
src/browser/Robots.zig Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

384
src/browser/Session.zig Normal file
View File

@@ -0,0 +1,384 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
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");
const Notification = @import("../Notification.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = 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.
// You can create successively multiple pages for a session, but you must
// deinit a page before running another one.
const Session = @This();
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed,
history: History,
navigation: Navigation,
page: ?Page,
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
self.* = .{
.page = null,
.arena = arena,
.history = .{},
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
}
pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage();
}
const browser = self.browser;
self.cookie_jar.deinit();
self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
// the pointer on Page is just returned as a convenience
pub fn createPage(self: *Session) !*Page {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self.nextFrameId(), self, null);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(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.notification.dispatch(.page_created, page);
return page;
}
pub fn removePage(self: *Session) void {
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
self.page = null;
self.navigation.onRemovePage();
if (comptime IS_DEBUG) {
log.debug(.browser, "remove page", .{});
}
}
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
var current = self.page.?;
const frame_id = current._frame_id;
const parent = current.parent;
current.deinit();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
return page;
}
pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null);
}
pub const WaitResult = enum {
done,
no_page,
cdp_socket,
};
pub fn findPage(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return if (page._frame_id == frame_id) page else null;
}
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
var page = &(self.page orelse return .no_page);
while (true) {
const wait_result = self._wait(page, wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = page.url,
}),
}
return .done;
};
switch (wait_result) {
.done => {
if (page._queued_navigation == null) {
return .done;
}
page = self.processScheduledNavigation(page) catch return .done;
},
else => |result| return result,
}
}
}
fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
const browser = self.browser;
var http_client = browser.http_client;
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
while (true) {
switch (page._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (page._queued_navigation != null) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
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)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
const ms: u64 = 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 50;
}
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
return .done;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we
// minimize how long we'll poll for network I/O.
var ms_to_wait = @min(200, ms_to_next_task orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
}
fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page {
const browser = self.browser;
const qn = current_page._queued_navigation.?;
// take ownership of the page's queued navigation
current_page._queued_navigation = null;
defer browser.arena_pool.release(qn.arena);
const frame_id, const parent = blk: {
const page = &self.page.?;
const frame_id = page._frame_id;
const parent = page.parent;
browser.http_client.abort();
self.removePage();
break :blk .{ frame_id, parent };
};
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(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.notification.dispatch(.page_created, page);
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
return err;
};
return page;
}
pub fn nextFrameId(self: *Session) u32 {
const id = self.frame_id_gen +% 1;
self.frame_id_gen = id;
return id;
}

View File

@@ -1,65 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// Sometimes we need to extend libdom. For example, its HTMLDocument doesn't
// have a readyState. We have a couple different options, such as making the
// correction in libdom directly. Another option stems from the fact that every
// libdom node has an opaque embedder_data field. This is the struct that we
// lazily load into that field.
//
// It didn't originally start off as a collection of every single extension, but
// this quickly proved necessary, since different fields are needed on the same
// data at different levels of the prototype chain. This isn't memory efficient.
const Env = @import("env.zig").Env;
const parser = @import("netsurf.zig");
const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration;
// for HTMLScript (but probably needs to be added to more)
onload: ?Env.Function = null,
onerror: ?Env.Function = null,
// for HTMLElement
style: CSSStyleDeclaration = .empty,
// for html/document
ready_state: ReadyState = .loading,
// for dom/document
active_element: ?*parser.Element = null,
// for HTMLSelectElement
// By default, if no option is explicitly selected, the first option should
// be selected. However, libdom doesn't do this, and it sets the
// selectedIndex to -1, which is a valid value for "nothing selected".
// Therefore, when libdom says the selectedIndex == -1, we don't know if
// it means that nothing is selected, or if the first option is selected by
// default.
// There are cases where this won't work, but when selectedIndex is
// explicitly set, we set this boolean flag. Then, when we're getting then
// selectedIndex, if this flag is == false, which is to say that if
// selectedIndex hasn't been explicitly set AND if we have at least 1 option
// AND if it isn't a multi select, we can make the 1st item selected by
// default (by returning selectedIndex == 0).
explicit_index_set: bool = false,
const ReadyState = enum {
loading,
interactive,
complete,
};

1327
src/browser/URL.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const State = @import("State.zig");
const Env = @import("env.zig").Env;
const App = @import("../app.zig").App;
const Session = @import("session.zig").Session;
const Notification = @import("../notification.zig").Notification;
const http = @import("../http/client.zig");
// Browser is an instance of the browser.
// You can create multiple browser instances.
// A browser contains only one session.
pub const Browser = struct {
env: *Env,
app: *App,
session: ?Session,
allocator: Allocator,
http_client: *http.Client,
page_arena: ArenaAllocator,
session_arena: ArenaAllocator,
transfer_arena: ArenaAllocator,
notification: *Notification,
state_pool: std.heap.MemoryPool(State),
pub fn init(app: *App) !Browser {
const allocator = app.allocator;
const env = try Env.init(allocator, .{});
errdefer env.deinit();
const notification = try Notification.init(allocator, app.notification);
errdefer notification.deinit();
return .{
.app = app,
.env = env,
.session = null,
.allocator = allocator,
.notification = notification,
.http_client = &app.http_client,
.page_arena = ArenaAllocator.init(allocator),
.session_arena = ArenaAllocator.init(allocator),
.transfer_arena = ArenaAllocator.init(allocator),
.state_pool = std.heap.MemoryPool(State).init(allocator),
};
}
pub fn deinit(self: *Browser) void {
self.closeSession();
self.env.deinit();
self.page_arena.deinit();
self.session_arena.deinit();
self.transfer_arena.deinit();
self.notification.deinit();
self.state_pool.deinit();
}
pub fn newSession(self: *Browser) !*Session {
self.closeSession();
self.session = @as(Session, undefined);
const session = &self.session.?;
try Session.init(session, self);
return session;
}
pub fn closeSession(self: *Browser) void {
if (self.session) |*session| {
session.deinit();
self.session = null;
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.env.lowMemoryNotification();
}
}
pub fn runMicrotasks(self: *const Browser) void {
return self.env.runMicrotasks();
}
};
const testing = @import("../testing.zig");
test "Browser" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
// this will crash if ICU isn't properly configured / ininitialized
try runner.testCases(&.{
.{ "new Intl.DateTimeFormat()", "[object Intl.DateTimeFormat]" },
}, .{});
}

298
src/browser/color.zig Normal file
View File

@@ -0,0 +1,298 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Io = std.Io;
pub fn isHexColor(value: []const u8) bool {
if (value.len == 0) {
return false;
}
if (value[0] != '#') {
return false;
}
const hex_part = value[1..];
switch (hex_part.len) {
3, 4, 6, 8 => for (hex_part) |c| if (!std.ascii.isHex(c)) return false,
else => return false,
}
return true;
}
pub const RGBA = packed struct(u32) {
r: u8,
g: u8,
b: u8,
/// Opaque by default.
a: u8 = std.math.maxInt(u8),
pub const Named = struct {
// Basic colors (CSS Level 1)
pub const black: RGBA = .init(0, 0, 0, 1);
pub const silver: RGBA = .init(192, 192, 192, 1);
pub const gray: RGBA = .init(128, 128, 128, 1);
pub const white: RGBA = .init(255, 255, 255, 1);
pub const maroon: RGBA = .init(128, 0, 0, 1);
pub const red: RGBA = .init(255, 0, 0, 1);
pub const purple: RGBA = .init(128, 0, 128, 1);
pub const fuchsia: RGBA = .init(255, 0, 255, 1);
pub const green: RGBA = .init(0, 128, 0, 1);
pub const lime: RGBA = .init(0, 255, 0, 1);
pub const olive: RGBA = .init(128, 128, 0, 1);
pub const yellow: RGBA = .init(255, 255, 0, 1);
pub const navy: RGBA = .init(0, 0, 128, 1);
pub const blue: RGBA = .init(0, 0, 255, 1);
pub const teal: RGBA = .init(0, 128, 128, 1);
pub const aqua: RGBA = .init(0, 255, 255, 1);
// Extended colors (CSS Level 2+)
pub const aliceblue: RGBA = .init(240, 248, 255, 1);
pub const antiquewhite: RGBA = .init(250, 235, 215, 1);
pub const aquamarine: RGBA = .init(127, 255, 212, 1);
pub const azure: RGBA = .init(240, 255, 255, 1);
pub const beige: RGBA = .init(245, 245, 220, 1);
pub const bisque: RGBA = .init(255, 228, 196, 1);
pub const blanchedalmond: RGBA = .init(255, 235, 205, 1);
pub const blueviolet: RGBA = .init(138, 43, 226, 1);
pub const brown: RGBA = .init(165, 42, 42, 1);
pub const burlywood: RGBA = .init(222, 184, 135, 1);
pub const cadetblue: RGBA = .init(95, 158, 160, 1);
pub const chartreuse: RGBA = .init(127, 255, 0, 1);
pub const chocolate: RGBA = .init(210, 105, 30, 1);
pub const coral: RGBA = .init(255, 127, 80, 1);
pub const cornflowerblue: RGBA = .init(100, 149, 237, 1);
pub const cornsilk: RGBA = .init(255, 248, 220, 1);
pub const crimson: RGBA = .init(220, 20, 60, 1);
pub const cyan: RGBA = .init(0, 255, 255, 1); // Synonym of aqua
pub const darkblue: RGBA = .init(0, 0, 139, 1);
pub const darkcyan: RGBA = .init(0, 139, 139, 1);
pub const darkgoldenrod: RGBA = .init(184, 134, 11, 1);
pub const darkgray: RGBA = .init(169, 169, 169, 1);
pub const darkgreen: RGBA = .init(0, 100, 0, 1);
pub const darkgrey: RGBA = .init(169, 169, 169, 1); // Synonym of darkgray
pub const darkkhaki: RGBA = .init(189, 183, 107, 1);
pub const darkmagenta: RGBA = .init(139, 0, 139, 1);
pub const darkolivegreen: RGBA = .init(85, 107, 47, 1);
pub const darkorange: RGBA = .init(255, 140, 0, 1);
pub const darkorchid: RGBA = .init(153, 50, 204, 1);
pub const darkred: RGBA = .init(139, 0, 0, 1);
pub const darksalmon: RGBA = .init(233, 150, 122, 1);
pub const darkseagreen: RGBA = .init(143, 188, 143, 1);
pub const darkslateblue: RGBA = .init(72, 61, 139, 1);
pub const darkslategray: RGBA = .init(47, 79, 79, 1);
pub const darkslategrey: RGBA = .init(47, 79, 79, 1); // Synonym of darkslategray
pub const darkturquoise: RGBA = .init(0, 206, 209, 1);
pub const darkviolet: RGBA = .init(148, 0, 211, 1);
pub const deeppink: RGBA = .init(255, 20, 147, 1);
pub const deepskyblue: RGBA = .init(0, 191, 255, 1);
pub const dimgray: RGBA = .init(105, 105, 105, 1);
pub const dimgrey: RGBA = .init(105, 105, 105, 1); // Synonym of dimgray
pub const dodgerblue: RGBA = .init(30, 144, 255, 1);
pub const firebrick: RGBA = .init(178, 34, 34, 1);
pub const floralwhite: RGBA = .init(255, 250, 240, 1);
pub const forestgreen: RGBA = .init(34, 139, 34, 1);
pub const gainsboro: RGBA = .init(220, 220, 220, 1);
pub const ghostwhite: RGBA = .init(248, 248, 255, 1);
pub const gold: RGBA = .init(255, 215, 0, 1);
pub const goldenrod: RGBA = .init(218, 165, 32, 1);
pub const greenyellow: RGBA = .init(173, 255, 47, 1);
pub const grey: RGBA = .init(128, 128, 128, 1); // Synonym of gray
pub const honeydew: RGBA = .init(240, 255, 240, 1);
pub const hotpink: RGBA = .init(255, 105, 180, 1);
pub const indianred: RGBA = .init(205, 92, 92, 1);
pub const indigo: RGBA = .init(75, 0, 130, 1);
pub const ivory: RGBA = .init(255, 255, 240, 1);
pub const khaki: RGBA = .init(240, 230, 140, 1);
pub const lavender: RGBA = .init(230, 230, 250, 1);
pub const lavenderblush: RGBA = .init(255, 240, 245, 1);
pub const lawngreen: RGBA = .init(124, 252, 0, 1);
pub const lemonchiffon: RGBA = .init(255, 250, 205, 1);
pub const lightblue: RGBA = .init(173, 216, 230, 1);
pub const lightcoral: RGBA = .init(240, 128, 128, 1);
pub const lightcyan: RGBA = .init(224, 255, 255, 1);
pub const lightgoldenrodyellow: RGBA = .init(250, 250, 210, 1);
pub const lightgray: RGBA = .init(211, 211, 211, 1);
pub const lightgreen: RGBA = .init(144, 238, 144, 1);
pub const lightgrey: RGBA = .init(211, 211, 211, 1); // Synonym of lightgray
pub const lightpink: RGBA = .init(255, 182, 193, 1);
pub const lightsalmon: RGBA = .init(255, 160, 122, 1);
pub const lightseagreen: RGBA = .init(32, 178, 170, 1);
pub const lightskyblue: RGBA = .init(135, 206, 250, 1);
pub const lightslategray: RGBA = .init(119, 136, 153, 1);
pub const lightslategrey: RGBA = .init(119, 136, 153, 1); // Synonym of lightslategray
pub const lightsteelblue: RGBA = .init(176, 196, 222, 1);
pub const lightyellow: RGBA = .init(255, 255, 224, 1);
pub const limegreen: RGBA = .init(50, 205, 50, 1);
pub const linen: RGBA = .init(250, 240, 230, 1);
pub const magenta: RGBA = .init(255, 0, 255, 1); // Synonym of fuchsia
pub const mediumaquamarine: RGBA = .init(102, 205, 170, 1);
pub const mediumblue: RGBA = .init(0, 0, 205, 1);
pub const mediumorchid: RGBA = .init(186, 85, 211, 1);
pub const mediumpurple: RGBA = .init(147, 112, 219, 1);
pub const mediumseagreen: RGBA = .init(60, 179, 113, 1);
pub const mediumslateblue: RGBA = .init(123, 104, 238, 1);
pub const mediumspringgreen: RGBA = .init(0, 250, 154, 1);
pub const mediumturquoise: RGBA = .init(72, 209, 204, 1);
pub const mediumvioletred: RGBA = .init(199, 21, 133, 1);
pub const midnightblue: RGBA = .init(25, 25, 112, 1);
pub const mintcream: RGBA = .init(245, 255, 250, 1);
pub const mistyrose: RGBA = .init(255, 228, 225, 1);
pub const moccasin: RGBA = .init(255, 228, 181, 1);
pub const navajowhite: RGBA = .init(255, 222, 173, 1);
pub const oldlace: RGBA = .init(253, 245, 230, 1);
pub const olivedrab: RGBA = .init(107, 142, 35, 1);
pub const orange: RGBA = .init(255, 165, 0, 1);
pub const orangered: RGBA = .init(255, 69, 0, 1);
pub const orchid: RGBA = .init(218, 112, 214, 1);
pub const palegoldenrod: RGBA = .init(238, 232, 170, 1);
pub const palegreen: RGBA = .init(152, 251, 152, 1);
pub const paleturquoise: RGBA = .init(175, 238, 238, 1);
pub const palevioletred: RGBA = .init(219, 112, 147, 1);
pub const papayawhip: RGBA = .init(255, 239, 213, 1);
pub const peachpuff: RGBA = .init(255, 218, 185, 1);
pub const peru: RGBA = .init(205, 133, 63, 1);
pub const pink: RGBA = .init(255, 192, 203, 1);
pub const plum: RGBA = .init(221, 160, 221, 1);
pub const powderblue: RGBA = .init(176, 224, 230, 1);
pub const rebeccapurple: RGBA = .init(102, 51, 153, 1);
pub const rosybrown: RGBA = .init(188, 143, 143, 1);
pub const royalblue: RGBA = .init(65, 105, 225, 1);
pub const saddlebrown: RGBA = .init(139, 69, 19, 1);
pub const salmon: RGBA = .init(250, 128, 114, 1);
pub const sandybrown: RGBA = .init(244, 164, 96, 1);
pub const seagreen: RGBA = .init(46, 139, 87, 1);
pub const seashell: RGBA = .init(255, 245, 238, 1);
pub const sienna: RGBA = .init(160, 82, 45, 1);
pub const skyblue: RGBA = .init(135, 206, 235, 1);
pub const slateblue: RGBA = .init(106, 90, 205, 1);
pub const slategray: RGBA = .init(112, 128, 144, 1);
pub const slategrey: RGBA = .init(112, 128, 144, 1); // Synonym of slategray
pub const snow: RGBA = .init(255, 250, 250, 1);
pub const springgreen: RGBA = .init(0, 255, 127, 1);
pub const steelblue: RGBA = .init(70, 130, 180, 1);
pub const tan: RGBA = .init(210, 180, 140, 1);
pub const thistle: RGBA = .init(216, 191, 216, 1);
pub const tomato: RGBA = .init(255, 99, 71, 1);
pub const transparent: RGBA = .init(0, 0, 0, 0);
pub const turquoise: RGBA = .init(64, 224, 208, 1);
pub const violet: RGBA = .init(238, 130, 238, 1);
pub const wheat: RGBA = .init(245, 222, 179, 1);
pub const whitesmoke: RGBA = .init(245, 245, 245, 1);
pub const yellowgreen: RGBA = .init(154, 205, 50, 1);
};
pub fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
const clamped = std.math.clamp(a, 0, 1);
return .{ .r = r, .g = g, .b = b, .a = @intFromFloat(clamped * 255) };
}
/// Finds a color by its name.
pub fn find(name: []const u8) ?RGBA {
const match = std.meta.stringToEnum(std.meta.DeclEnum(Named), name) orelse return null;
return switch (match) {
inline else => |comptime_enum| @field(Named, @tagName(comptime_enum)),
};
}
/// Parses the given color.
/// Currently we only parse hex colors and named colors; other variants
/// require CSS evaluation.
pub fn parse(input: []const u8) !RGBA {
if (!isHexColor(input)) {
// Try named colors.
return find(input) orelse return error.Invalid;
}
const slice = input[1..];
switch (slice.len) {
// This means the digit for a color is repeated.
// Given HEX is #f0c, its interpreted the same as #FF00CC.
3 => {
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
return .{ .r = r, .g = g, .b = b, .a = 255 };
},
4 => {
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
const a = try std.fmt.parseInt(u8, &.{ slice[3], slice[3] }, 16);
return .{ .r = r, .g = g, .b = b, .a = a };
},
// Regular HEX format.
6 => {
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
return .{ .r = r, .g = g, .b = b, .a = 255 };
},
8 => {
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
const a = try std.fmt.parseInt(u8, slice[6..8], 16);
return .{ .r = r, .g = g, .b = b, .a = a };
},
else => return error.Invalid,
}
}
/// By default, browsers prefer lowercase formatting.
const format_upper = false;
/// Formats the `Color` according to web expectations.
/// If color is opaque, HEX is preferred; RGBA otherwise.
pub fn format(self: *const RGBA, writer: *Io.Writer) Io.Writer.Error!void {
if (self.isOpaque()) {
// Convert RGB to HEX.
// https://gristle.tripod.com/hexconv.html
// Hexadecimal characters up to 15.
const char: []const u8 = "0123456789" ++ if (format_upper) "ABCDEF" else "abcdef";
// This variant always prefers 6 digit format, +1 is for hash char.
const buffer = [7]u8{
'#',
char[self.r >> 4],
char[self.r & 15],
char[self.g >> 4],
char[self.g & 15],
char[self.b >> 4],
char[self.b & 15],
};
return writer.writeAll(&buffer);
}
// Prefer RGBA format for everything else.
return writer.print("rgba({d}, {d}, {d}, {d:.2})", .{ self.r, self.g, self.b, self.normalizedAlpha() });
}
/// Returns true if `Color` is opaque.
pub inline fn isOpaque(self: *const RGBA) bool {
return self.a == std.math.maxInt(u8);
}
/// Returns the normalized alpha value.
pub inline fn normalizedAlpha(self: *const RGBA) f32 {
return @as(f32, @floatFromInt(self.a)) / 255;
}
};

View File

@@ -1,329 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const Page = @import("../page.zig").Page;
const JsObject = @import("../env.zig").Env.JsObject;
const log = if (builtin.is_test) &test_capture else @import("../../log.zig");
pub const Console = struct {
// TODO: configurable writer
timers: std.StringHashMapUnmanaged(u32) = .{},
counts: std.StringHashMapUnmanaged(u32) = .{},
pub fn static_lp(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) });
}
pub fn static_log(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.info(.console, "info", .{ .args = try serializeValues(values, page) });
}
pub fn static_info(values: []JsObject, page: *Page) !void {
return static_log(values, page);
}
pub fn static_debug(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.debug(.console, "debug", .{ .args = try serializeValues(values, page) });
}
pub fn static_warn(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.warn(.console, "warn", .{ .args = try serializeValues(values, page) });
}
pub fn static_error(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.info(.console, "error", .{
.args = try serializeValues(values, page),
.stack = page.stackTrace() catch "???",
});
}
pub fn static_clear() void {}
pub fn _count(self: *Console, label_: ?[]const u8, page: *Page) !void {
const label = label_ orelse "default";
const gop = try self.counts.getOrPut(page.arena, label);
var current: u32 = 0;
if (gop.found_existing) {
current = gop.value_ptr.*;
} else {
gop.key_ptr.* = try page.arena.dupe(u8, label);
}
const count = current + 1;
gop.value_ptr.* = count;
log.info(.console, "count", .{ .label = label, .count = count });
}
pub fn _countReset(self: *Console, label_: ?[]const u8) !void {
const label = label_ orelse "default";
const kv = self.counts.fetchRemove(label) orelse {
log.info(.console, "invalid counter", .{ .label = label });
return;
};
log.info(.console, "count reset", .{ .label = label, .count = kv.value });
}
pub fn _time(self: *Console, label_: ?[]const u8, page: *Page) !void {
const label = label_ orelse "default";
const gop = try self.timers.getOrPut(page.arena, label);
if (gop.found_existing) {
log.info(.console, "duplicate timer", .{ .label = label });
return;
}
gop.key_ptr.* = try page.arena.dupe(u8, label);
gop.value_ptr.* = timestamp();
}
pub fn _timeLog(self: *Console, label_: ?[]const u8) void {
const elapsed = timestamp();
const label = label_ orelse "default";
const start = self.timers.get(label) orelse {
log.info(.console, "invalid timer", .{ .label = label });
return;
};
log.info(.console, "timer", .{ .label = label, .elapsed = elapsed - start });
}
pub fn _timeStop(self: *Console, label_: ?[]const u8) void {
const elapsed = timestamp();
const label = label_ orelse "default";
const kv = self.timers.fetchRemove(label) orelse {
log.info(.console, "invalid timer", .{ .label = label });
return;
};
log.warn(.console, "timer stop", .{ .label = label, .elapsed = elapsed - kv.value });
}
pub fn static_assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
if (assertion.isTruthy()) {
return;
}
var serialized_values: []const u8 = "";
if (values.len > 0) {
serialized_values = try serializeValues(values, page);
}
log.info(.console, "assertion failed", .{ .values = serialized_values });
}
fn serializeValues(values: []JsObject, page: *Page) ![]const u8 {
if (values.len == 0) {
return "";
}
const arena = page.call_arena;
const separator = log.separator();
var arr: std.ArrayListUnmanaged(u8) = .{};
for (values, 1..) |value, i| {
try arr.appendSlice(arena, separator);
try arr.writer(arena).print("{d}: ", .{i});
const serialized = if (builtin.mode == .Debug) value.toDetailString() else value.toString();
try arr.appendSlice(arena, try serialized);
}
return arr.items;
}
};
fn timestamp() u32 {
const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable;
return @intCast(ts.sec);
}
var test_capture = TestCapture{};
const testing = @import("../../testing.zig");
test "Browser.Console" {
defer testing.reset();
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
{
try runner.testCases(&.{
.{ "console.log('a')", "undefined" },
.{ "console.warn('hello world', 23, true, new Object())", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("[info] args= 1: a", captured[0]);
try testing.expectEqual("[warn] args= 1: hello world 2: 23 3: true 4: #<Object>", captured[1]);
}
{
test_capture.reset();
try runner.testCases(&.{
.{ "console.countReset()", "undefined" },
.{ "console.count()", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count()", "undefined" },
.{ "console.countReset('teg')", "undefined" },
.{ "console.countReset()", "undefined" },
.{ "console.count()", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("[invalid counter] label=default", captured[0]);
try testing.expectEqual("[count] label=default count=1", captured[1]);
try testing.expectEqual("[count] label=teg count=1", captured[2]);
try testing.expectEqual("[count] label=teg count=2", captured[3]);
try testing.expectEqual("[count] label=teg count=3", captured[4]);
try testing.expectEqual("[count] label=default count=2", captured[5]);
try testing.expectEqual("[count reset] label=teg count=3", captured[6]);
try testing.expectEqual("[count reset] label=default count=2", captured[7]);
try testing.expectEqual("[count] label=default count=1", captured[8]);
}
{
test_capture.reset();
try runner.testCases(&.{
.{ "console.assert(true)", "undefined" },
.{ "console.assert('a', 2, 3, 4)", "undefined" },
.{ "console.assert('')", "undefined" },
.{ "console.assert('', 'x', true)", "undefined" },
.{ "console.assert(false, 'x')", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("[assertion failed] values=", captured[0]);
try testing.expectEqual("[assertion failed] values= 1: x 2: true", captured[1]);
try testing.expectEqual("[assertion failed] values= 1: x", captured[2]);
}
{
test_capture.reset();
try runner.testCases(&.{
.{ "[1].forEach(console.log)", null },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("[info] args= 1: 1 2: 0 3: [1]", captured[0]);
}
}
const TestCapture = struct {
captured: std.ArrayListUnmanaged([]const u8) = .{},
fn separator(_: *const TestCapture) []const u8 {
return " ";
}
fn reset(self: *TestCapture) void {
self.captured = .{};
}
fn debug(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self.capture(scope, msg, args);
}
fn info(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self.capture(scope, msg, args);
}
fn warn(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self.capture(scope, msg, args);
}
fn err(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self.capture(scope, msg, args);
}
fn fatal(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self.capture(scope, msg, args);
}
fn capture(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) void {
self._capture(scope, msg, args) catch unreachable;
}
fn _capture(
self: *TestCapture,
comptime scope: @Type(.enum_literal),
comptime msg: []const u8,
args: anytype,
) !void {
std.debug.assert(scope == .console);
const allocator = testing.arena_allocator;
var buf: std.ArrayListUnmanaged(u8) = .empty;
try buf.appendSlice(allocator, "[" ++ msg ++ "] ");
inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| {
try buf.appendSlice(allocator, f.name);
try buf.append(allocator, '=');
try @import("../../log.zig").writeValue(.pretty, @field(args, f.name), buf.writer(allocator));
try buf.append(allocator, ' ');
}
self.captured.append(testing.arena_allocator, std.mem.trimRight(u8, buf.items, " ")) catch unreachable;
}
};

View File

@@ -1,82 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const uuidv4 = @import("../../id.zig").uuidv4;
// https://w3c.github.io/webcrypto/#crypto-interface
pub const Crypto = struct {
pub fn _getRandomValues(_: *const Crypto, into: RandomValues) !void {
const buf = into.asBuffer();
if (buf.len > 65_536) {
return error.QuotaExceededError;
}
std.crypto.random.bytes(buf);
}
pub fn _randomUUID(_: *const Crypto) [36]u8 {
var hex: [36]u8 = undefined;
uuidv4(&hex);
return hex;
}
};
const RandomValues = union(enum) {
int8: []i8,
uint8: []u8,
int16: []i16,
uint16: []u16,
int32: []i32,
uint32: []u32,
int64: []i64,
uint64: []u64,
fn asBuffer(self: RandomValues) []u8 {
switch (self) {
.int8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
.uint8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
.int16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.uint16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.int32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.uint32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.int64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
.uint64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
}
}
};
const testing = @import("../../testing.zig");
test "Browser.Crypto" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const a = crypto.randomUUID();", "undefined" },
.{ "const b = crypto.randomUUID();", "undefined" },
.{ "a.length;", "36" },
.{ "a.length;", "36" },
.{ "a == b;", "false" },
}, .{});
try runner.testCases(&.{
.{ "try { crypto.getRandomValues(new BigUint64Array(8193)) } catch(e) { e.message == 'QuotaExceededError' }", "true" },
.{ "let r1 = new Int32Array(5)", "undefined" },
.{ "crypto.getRandomValues(r1)", "undefined" },
.{ "new Set(r1).size", "5" },
}, .{});
}

295
src/browser/css/Parser.zig Normal file
View File

@@ -0,0 +1,295 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Tokenizer = @import("Tokenizer.zig");
pub const Declaration = struct {
name: []const u8,
value: []const u8,
important: bool,
};
const TokenSpan = struct {
token: Tokenizer.Token,
start: usize,
end: usize,
};
const TokenStream = struct {
tokenizer: Tokenizer,
peeked: ?TokenSpan = null,
fn init(input: []const u8) TokenStream {
return .{ .tokenizer = .{ .input = input } };
}
fn nextRaw(self: *TokenStream) ?TokenSpan {
const start = self.tokenizer.position;
const token = self.tokenizer.next() orelse return null;
const end = self.tokenizer.position;
return .{ .token = token, .start = start, .end = end };
}
fn next(self: *TokenStream) ?TokenSpan {
if (self.peeked) |token| {
self.peeked = null;
return token;
}
return self.nextRaw();
}
fn peek(self: *TokenStream) ?TokenSpan {
if (self.peeked == null) {
self.peeked = self.nextRaw();
}
return self.peeked;
}
};
pub fn parseDeclarationsList(input: []const u8) DeclarationsIterator {
return DeclarationsIterator.init(input);
}
pub const DeclarationsIterator = struct {
input: []const u8,
stream: TokenStream,
pub fn init(input: []const u8) DeclarationsIterator {
return .{
.input = input,
.stream = TokenStream.init(input),
};
}
pub fn next(self: *DeclarationsIterator) ?Declaration {
while (true) {
self.skipTriviaAndSemicolons();
const peeked = self.stream.peek() orelse return null;
switch (peeked.token) {
.at_keyword => {
_ = self.stream.next();
self.skipAtRule();
},
.ident => |name| {
_ = self.stream.next();
if (self.consumeDeclaration(name)) |declaration| {
return declaration;
}
},
else => {
_ = self.stream.next();
self.skipInvalidDeclaration();
},
}
}
return null;
}
fn consumeDeclaration(self: *DeclarationsIterator, name: []const u8) ?Declaration {
self.skipTrivia();
const colon = self.stream.next() orelse return null;
if (!isColon(colon.token)) {
self.skipInvalidDeclaration();
return null;
}
const value = self.consumeValue() orelse return null;
return .{
.name = name,
.value = value.value,
.important = value.important,
};
}
const ValueResult = struct {
value: []const u8,
important: bool,
};
fn consumeValue(self: *DeclarationsIterator) ?ValueResult {
self.skipTrivia();
var depth: usize = 0;
var start: ?usize = null;
var last_sig: ?TokenSpan = null;
var prev_sig: ?TokenSpan = null;
while (true) {
const peeked = self.stream.peek() orelse break;
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
break;
}
const span = self.stream.next() orelse break;
if (isWhitespaceOrComment(span.token)) {
continue;
}
if (start == null) start = span.start;
prev_sig = last_sig;
last_sig = span;
updateDepth(span.token, &depth);
}
const value_start = start orelse return null;
const last = last_sig orelse return null;
var important = false;
var end_pos = last.end;
if (isImportantPair(prev_sig, last)) {
important = true;
const bang = prev_sig orelse return null;
if (value_start >= bang.start) return null;
end_pos = bang.start;
}
var value_slice = self.input[value_start..end_pos];
value_slice = std.mem.trim(u8, value_slice, &std.ascii.whitespace);
if (value_slice.len == 0) return null;
return .{ .value = value_slice, .important = important };
}
fn skipTrivia(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (!isWhitespaceOrComment(peeked.token)) break;
_ = self.stream.next();
}
}
fn skipTriviaAndSemicolons(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token)) {
_ = self.stream.next();
} else {
break;
}
}
}
fn skipAtRule(self: *DeclarationsIterator) void {
var depth: usize = 0;
var saw_block = false;
while (true) {
const peeked = self.stream.peek() orelse return;
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
if (isBlockStart(span.token)) {
depth += 1;
saw_block = true;
} else if (isBlockEnd(span.token)) {
if (depth > 0) depth -= 1;
if (saw_block and depth == 0) return;
}
}
}
fn skipInvalidDeclaration(self: *DeclarationsIterator) void {
var depth: usize = 0;
while (self.stream.peek()) |peeked| {
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
updateDepth(span.token, &depth);
}
}
};
fn isWhitespaceOrComment(token: Tokenizer.Token) bool {
return switch (token) {
.white_space, .comment => true,
else => false,
};
}
fn isSemicolon(token: Tokenizer.Token) bool {
return switch (token) {
.semicolon => true,
else => false,
};
}
fn isColon(token: Tokenizer.Token) bool {
return switch (token) {
.colon => true,
else => false,
};
}
fn isBlockStart(token: Tokenizer.Token) bool {
return switch (token) {
.curly_bracket_block, .square_bracket_block, .parenthesis_block, .function => true,
else => false,
};
}
fn isBlockEnd(token: Tokenizer.Token) bool {
return switch (token) {
.close_curly_bracket, .close_parenthesis, .close_square_bracket => true,
else => false,
};
}
fn updateDepth(token: Tokenizer.Token, depth: *usize) void {
if (isBlockStart(token)) {
depth.* += 1;
return;
}
if (isBlockEnd(token)) {
if (depth.* > 0) depth.* -= 1;
}
}
fn isImportantPair(prev_sig: ?TokenSpan, last_sig: TokenSpan) bool {
if (!isIdentImportant(last_sig.token)) return false;
const prev = prev_sig orelse return false;
return isBang(prev.token);
}
fn isIdentImportant(token: Tokenizer.Token) bool {
return switch (token) {
.ident => |name| std.ascii.eqlIgnoreCase(name, "important"),
else => false,
};
}
fn isBang(token: Tokenizer.Token) bool {
return switch (token) {
.delim => |c| c == '!',
else => false,
};
}

View File

@@ -1,218 +0,0 @@
# css
Lightpanda css implements CSS selectors parsing and matching in Zig.
This package is a port of the Go lib [andybalholm/cascadia](https://github.com/andybalholm/cascadia).
## Usage
### Query parser
```zig
const css = @import("css.zig");
const selector = try css.parse(alloc, "h1", .{});
defer selector.deinit(alloc);
```
### DOM tree match
The lib expects a `Node` interface implementation to match your DOM tree.
```zig
pub const Node = struct {
pub fn firstChild(_: Node) !?Node {
return error.TODO;
}
pub fn lastChild(_: Node) !?Node {
return error.TODO;
}
pub fn nextSibling(_: Node) !?Node {
return error.TODO;
}
pub fn prevSibling(_: Node) !?Node {
return error.TODO;
}
pub fn parent(_: Node) !?Node {
return error.TODO;
}
pub fn isElement(_: Node) bool {
return false;
}
pub fn isDocument(_: Node) bool {
return false;
}
pub fn isComment(_: Node) bool {
return false;
}
pub fn isText(_: Node) bool {
return false;
}
pub fn isEmptyText(_: Node) !bool {
return error.TODO;
}
pub fn tag(_: Node) ![]const u8 {
return error.TODO;
}
pub fn attr(_: Node, _: []const u8) !?[]const u8 {
return error.TODO;
}
pub fn eql(_: Node, _: Node) bool {
return false;
}
};
```
You also need do define a `Matcher` implementing a `match` function to
accumulate the results.
```zig
const Matcher = struct {
const Nodes = std.ArrayList(Node);
nodes: Nodes,
fn init(alloc: std.mem.Allocator) Matcher {
return .{ .nodes = Nodes.init(alloc) };
}
fn deinit(m: *Matcher) void {
m.nodes.deinit();
}
pub fn match(m: *Matcher, n: Node) !void {
try m.nodes.append(n);
}
};
```
Then you can use the lib itself.
```zig
var matcher = Matcher.init(alloc);
defer matcher.deinit();
try css.matchAll(selector, node, &matcher);
_ = try css.matchFirst(selector, node, &matcher); // returns true if a node matched.
```
## Features
* [x] parse query selector
* [x] `matchAll`
* [x] `matchFirst`
* [ ] specificity
### Selectors implemented
#### Selectors
* [x] Class selectors
* [x] Id selectors
* [x] Type selectors
* [x] Universal selectors
* [ ] Nesting selectors
#### Combinators
* [x] Child combinator
* [ ] Column combinator
* [x] Descendant combinator
* [ ] Namespace combinator
* [x] Next-sibling combinator
* [x] Selector list combinator
* [x] Subsequent-sibling combinator
#### Attribute
* [x] `[attr]`
* [x] `[attr=value]`
* [x] `[attr|=value]`
* [x] `[attr^=value]`
* [x] `[attr$=value]`
* [ ] `[attr*=value]`
* [x] `[attr operator value i]`
* [ ] `[attr operator value s]`
#### Pseudo classes
* [ ] `:active`
* [ ] `:any-link`
* [ ] `:autofill`
* [ ] `:blank Experimental`
* [x] `:checked`
* [ ] `:current Experimental`
* [ ] `:default`
* [ ] `:defined`
* [ ] `:dir() Experimental`
* [x] `:disabled`
* [x] `:empty`
* [x] `:enabled`
* [ ] `:first`
* [x] `:first-child`
* [x] `:first-of-type`
* [ ] `:focus`
* [ ] `:focus-visible`
* [ ] `:focus-within`
* [ ] `:fullscreen`
* [ ] `:future Experimental`
* [x] `:has() Experimental`
* [ ] `:host`
* [ ] `:host()`
* [ ] `:host-context() Experimental`
* [ ] `:hover`
* [ ] `:indeterminate`
* [ ] `:in-range`
* [ ] `:invalid`
* [ ] `:is()`
* [x] `:lang()`
* [x] `:last-child`
* [x] `:last-of-type`
* [ ] `:left`
* [x] `:link`
* [ ] `:local-link Experimental`
* [ ] `:modal`
* [x] `:not()`
* [x] `:nth-child()`
* [x] `:nth-last-child()`
* [x] `:nth-last-of-type()`
* [x] `:nth-of-type()`
* [x] `:only-child`
* [x] `:only-of-type`
* [ ] `:optional`
* [ ] `:out-of-range`
* [ ] `:past Experimental`
* [ ] `:paused`
* [ ] `:picture-in-picture`
* [ ] `:placeholder-shown`
* [ ] `:playing`
* [ ] `:read-only`
* [ ] `:read-write`
* [ ] `:required`
* [ ] `:right`
* [x] `:root`
* [ ] `:scope`
* [ ] `:state() Experimental`
* [ ] `:target`
* [ ] `:target-within Experimental`
* [ ] `:user-invalid Experimental`
* [ ] `:valid`
* [ ] `:visited`
* [ ] `:where()`
* [ ] `:contains()`
* [ ] `:containsown()`
* [ ] `:matched()`
* [ ] `:matchesown()`
* [x] `:root`

View File

@@ -0,0 +1,824 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! This file implements the tokenization step defined in the CSS Syntax Module Level 3 specification.
//!
//! The algorithm accepts a valid UTF-8 string and returns a stream of tokens.
//! The tokenization step never fails, even for complete gibberish.
//! Validity must then be checked by the parser.
//!
//! NOTE: The tokenizer is not thread-safe and does not own any memory, and does not check the validity of utf8.
//!
//! See spec for more info: https://drafts.csswg.org/css-syntax/#tokenization
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Tokenizer = @This();
pub const Token = union(enum) {
/// A `<ident-token>`
ident: []const u8,
/// A `<function-token>`
///
/// The value (name) does not include the `(` marker.
function: []const u8,
/// A `<at-keyword-token>`
///
/// The value does not include the `@` marker.
at_keyword: []const u8,
/// A `<hash-token>` with the type flag set to "id"
///
/// The value does not include the `#` marker.
id_hash: []const u8, // Hash that is a valid ID selector.
/// A `<hash-token>` with the type flag set to "unrestricted"
///
/// The value does not include the `#` marker.
unrestricted_hash: []const u8,
/// A `<string-token>`
///
/// The value does not include the quotes.
string: []const u8,
/// A `<bad-string-token>`
///
/// This token always indicates a parse error.
bad_string: []const u8,
/// A `<url-token>`
///
/// The value does not include the `url(` `)` markers. Note that `url( <string-token> )` is represented by a
/// `Function` token.
url: []const u8,
/// A `<bad-url-token>`
///
/// This token always indicates a parse error.
bad_url: []const u8,
/// A `<delim-token>`
delim: u8,
/// A `<number-token>`
number: struct {
/// Whether the number had a `+` or `-` sign.
///
/// This is used is some cases like the <An+B> micro syntax. (See the `parse_nth` function.)
has_sign: bool,
/// If the origin source did not include a fractional part, the value as an integer.
int_value: ?i32,
/// The value as a float
value: f32,
},
/// A `<percentage-token>`
percentage: struct {
/// Whether the number had a `+` or `-` sign.
has_sign: bool,
/// If the origin source did not include a fractional part, the value as an integer.
/// It is **not** divided by 100.
int_value: ?i32,
/// The value as a float, divided by 100 so that the nominal range is 0.0 to 1.0.
unit_value: f32,
},
/// A `<dimension-token>`
dimension: struct {
/// Whether the number had a `+` or `-` sign.
///
/// This is used is some cases like the <An+B> micro syntax. (See the `parse_nth` function.)
has_sign: bool,
/// If the origin source did not include a fractional part, the value as an integer.
int_value: ?i32,
/// The value as a float
value: f32,
/// The unit, e.g. "px" in `12px`
unit: []const u8,
},
/// A `<unicode-range-token>`
unicode_range: struct { bgn: u32, end: i32 },
/// A `<whitespace-token>`
white_space: []const u8,
/// A `<!--` `<CDO-token>`
cdo,
/// A `-->` `<CDC-token>`
cdc,
/// A `:` `<colon-token>`
colon, // :
/// A `;` `<semicolon-token>`
semicolon, // ;
/// A `,` `<comma-token>`
comma, // ,
/// A `<[-token>`
square_bracket_block,
/// A `<]-token>`
///
/// When obtained from one of the `Parser::next*` methods,
/// this token is always unmatched and indicates a parse error.
close_square_bracket,
/// A `<(-token>`
parenthesis_block,
/// A `<)-token>`
///
/// When obtained from one of the `Parser::next*` methods,
/// this token is always unmatched and indicates a parse error.
close_parenthesis,
/// A `<{-token>`
curly_bracket_block,
/// A `<}-token>`
///
/// When obtained from one of the `Parser::next*` methods,
/// this token is always unmatched and indicates a parse error.
close_curly_bracket,
/// A comment.
///
/// The CSS Syntax spec does not generate tokens for comments,
/// But we do for simplicity of the interface.
///
/// The value does not include the `/*` `*/` markers.
comment: []const u8,
};
input: []const u8,
/// Counted in bytes, not code points. From 0.
position: usize = 0,
// If true, the input has at least `n` bytes left *after* the current one.
// That is, `Lexer.byteAt(n)` will not panic.
fn hasAtLeast(self: *const Tokenizer, n: usize) bool {
return self.position + n < self.input.len;
}
fn isEof(self: *const Tokenizer) bool {
return !self.hasAtLeast(0);
}
fn byteAt(self: *const Tokenizer, offset: usize) u8 {
return self.input[self.position + offset];
}
// Assumes non-EOF
fn nextByteUnchecked(self: *const Tokenizer) u8 {
return self.byteAt(0);
}
fn nextByte(self: *const Tokenizer) ?u8 {
return if (self.isEof())
null
else
self.input[self.position];
}
fn startsWith(self: *const Tokenizer, needle: []const u8) bool {
return std.mem.startsWith(u8, self.input[self.position..], needle);
}
fn slice(self: *const Tokenizer, start: usize, end: usize) []const u8 {
return self.input[start..end];
}
fn sliceFrom(self: *const Tokenizer, start_pos: usize) []const u8 {
return self.slice(start_pos, self.position);
}
// Advance over N bytes in the input. This function can advance
// over ASCII bytes (excluding newlines), or UTF-8 sequence
// leaders (excluding leaders for 4-byte sequences).
fn advance(self: *Tokenizer, n: usize) void {
if (builtin.mode == .Debug) {
// Each byte must either be an ASCII byte or a sequence leader,
// but not a 4-byte leader; also newlines are rejected.
for (0..n) |i| {
const b = self.byteAt(i);
assert(b != '\r' and b != '\n' and b != '\x0C');
assert(b <= 0x7F or (b & 0xF0 != 0xF0 and b & 0xC0 != 0x80));
}
}
self.position += n;
}
fn hasNewlineAt(self: *const Tokenizer, offset: usize) bool {
if (!self.hasAtLeast(offset)) return false;
return switch (self.byteAt(offset)) {
'\n', '\r', '\x0C' => true,
else => false,
};
}
fn hasNonAsciiAt(self: *const Tokenizer, offset: usize) bool {
if (!self.hasAtLeast(offset)) return false;
const byte = self.byteAt(offset);
const len_utf8 = std.unicode.utf8ByteSequenceLength(byte) catch return false;
if (!self.hasAtLeast(offset + len_utf8 - 1)) return false;
const start = self.position + offset;
const bytes = self.slice(start, start + len_utf8);
const codepoint = std.unicode.utf8Decode(bytes) catch return false;
// https://drafts.csswg.org/css-syntax/#non-ascii-ident-code-point
return switch (codepoint) {
'\u{00B7}', '\u{200C}', '\u{200D}', '\u{203F}', '\u{2040}' => true,
'\u{00C0}'...'\u{00D6}' => true,
'\u{00D8}'...'\u{00F6}' => true,
'\u{00F8}'...'\u{037D}' => true,
'\u{037F}'...'\u{1FFF}' => true,
'\u{2070}'...'\u{218F}' => true,
'\u{2C00}'...'\u{2FEF}' => true,
'\u{3001}'...'\u{D7FF}' => true,
'\u{F900}'...'\u{FDCF}' => true,
'\u{FDF0}'...'\u{FFFD}' => true,
else => codepoint >= '\u{10000}',
};
}
fn isIdentStart(self: *Tokenizer) bool {
if (self.isEof()) return false;
var b = self.nextByteUnchecked();
if (b == '-') {
b = if (self.hasAtLeast(1)) self.byteAt(1) else return false;
}
return switch (b) {
'a'...'z', 'A'...'Z', '_', 0x0 => true,
'\\' => !self.hasNewlineAt(1),
else => b > 0x7F, // not is ascii
};
}
fn consumeChar(self: *Tokenizer) void {
const byte = self.nextByteUnchecked();
const len_utf8 = std.unicode.utf8ByteSequenceLength(byte) catch 1;
self.position += len_utf8;
}
// Given that a newline has been seen, advance over the newline
// and update the state.
fn consumeNewline(self: *Tokenizer) void {
const byte = self.nextByteUnchecked();
assert(byte == '\r' or byte == '\n' or byte == '\x0C');
self.position += 1;
if (byte == '\r' and self.nextByte() == '\n') {
self.position += 1;
}
}
fn consumeWhiteSpace(self: *Tokenizer, newline: bool) Token {
const start_position = self.position;
if (newline) {
self.consumeNewline();
} else {
self.advance(1);
}
while (!self.isEof()) {
const b = self.nextByteUnchecked();
switch (b) {
' ', '\t' => {
self.advance(1);
},
'\n', '\x0C', '\r' => {
self.consumeNewline();
},
else => break,
}
}
return .{ .white_space = self.sliceFrom(start_position) };
}
fn consumeComment(self: *Tokenizer) []const u8 {
self.advance(2); // consume "/*"
const start_position = self.position;
while (!self.isEof()) {
switch (self.nextByteUnchecked()) {
'*' => {
const end_position = self.position;
self.advance(1);
if (self.nextByte() == '/') {
self.advance(1);
return self.slice(start_position, end_position);
}
},
'\n', '\x0C', '\r' => {
self.consumeNewline();
},
0x0 => self.advance(1),
else => self.consumeChar(),
}
}
return self.sliceFrom(start_position);
}
fn byteToHexDigit(b: u8) ?u32 {
return switch (b) {
'0'...'9' => b - '0',
'a'...'f' => b - 'a' + 10,
'A'...'F' => b - 'A' + 10,
else => null,
};
}
fn byteToDecimalDigit(b: u8) ?u32 {
return if (std.ascii.isDigit(b)) b - '0' else null;
}
// (value, number of digits up to 6)
fn consumeHexDigits(self: *Tokenizer) void {
var value: u32 = 0;
var digits: u32 = 0;
while (digits < 6 and !self.isEof()) {
if (byteToHexDigit(self.nextByteUnchecked())) |digit| {
value = value * 16 + digit;
digits += 1;
self.advance(1);
} else {
break;
}
}
_ = &value;
}
// Assumes that the U+005C REVERSE SOLIDUS (\) has already been consumed
// and that the next input character has already been verified
// to not be a newline.
fn consumeEscape(self: *Tokenizer) void {
if (self.isEof())
return; // Escaped EOF
switch (self.nextByteUnchecked()) {
'0'...'9', 'A'...'F', 'a'...'f' => {
consumeHexDigits(self);
if (!self.isEof()) {
switch (self.nextByteUnchecked()) {
' ', '\t' => {
self.advance(1);
},
'\n', '\x0C', '\r' => {
self.consumeNewline();
},
else => {},
}
}
},
else => self.consumeChar(),
}
}
/// https://drafts.csswg.org/css-syntax/#consume-string-token
fn consumeString(self: *Tokenizer, single_quote: bool) Token {
self.advance(1); // Skip the initial quote
// start_pos is at code point boundary, after " or '
const start_pos = self.position;
while (!self.isEof()) {
switch (self.nextByteUnchecked()) {
'"' => {
if (!single_quote) {
const value = self.sliceFrom(start_pos);
self.advance(1);
return .{ .string = value };
}
self.advance(1);
},
'\'' => {
if (single_quote) {
const value = self.sliceFrom(start_pos);
self.advance(1);
return .{ .string = value };
}
self.advance(1);
},
'\n', '\r', '\x0C' => {
return .{ .bad_string = self.sliceFrom(start_pos) };
},
'\\' => {
self.advance(1);
if (self.isEof())
continue; // escaped EOF, do nothing.
switch (self.nextByteUnchecked()) {
// Escaped newline
'\n', '\x0C', '\r' => self.consumeNewline(),
// Spec calls for replacing escape sequences with characters,
// but this would require allocating a new string.
// Therefore, we leave it as is and let the parser handle the escaping.
else => self.consumeEscape(),
}
},
else => self.consumeChar(),
}
}
return .{ .string = self.sliceFrom(start_pos) };
}
fn consumeName(self: *Tokenizer) []const u8 {
// start_pos is the end of the previous token, therefore at a code point boundary
const start_pos = self.position;
while (!self.isEof()) {
switch (self.nextByteUnchecked()) {
'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => self.advance(1),
'\\' => {
if (self.hasNewlineAt(1)) {
break;
}
self.advance(1);
self.consumeEscape();
},
0x0 => self.advance(1),
'\x80'...'\xBF', '\xC0'...'\xEF', '\xF0'...'\xFF' => {
// This byte *is* part of a multi-byte code point,
// well end up copying the whole code point before this loop does something else.
self.advance(1);
},
else => {
if (self.hasNonAsciiAt(0)) {
self.consumeChar();
} else {
break; // ASCII
}
},
}
}
return self.sliceFrom(start_pos);
}
fn consumeMark(self: *Tokenizer) Token {
const byte = self.nextByteUnchecked();
self.advance(1);
return switch (byte) {
',' => .comma,
':' => .colon,
';' => .semicolon,
'(' => .parenthesis_block,
')' => .close_parenthesis,
'{' => .curly_bracket_block,
'}' => .close_curly_bracket,
'[' => .square_bracket_block,
']' => .close_square_bracket,
else => unreachable,
};
}
fn consumeNumeric(self: *Tokenizer) Token {
// Parse [+-]?\d*(\.\d+)?([eE][+-]?\d+)?
// But this is always called so that there is at least one digit in \d*(\.\d+)?
// Do all the math in f64 so that large numbers overflow to +/-inf
// and i32::{MIN, MAX} are within range.
var sign: f64 = 1.0;
var has_sign = false;
switch (self.nextByteUnchecked()) {
'+' => {
has_sign = true;
},
'-' => {
has_sign = true;
sign = -1.0;
},
else => {},
}
if (has_sign) {
self.advance(1);
}
var is_integer = true;
var integral_part: f64 = 0.0;
var fractional_part: f64 = 0.0;
while (!self.isEof()) {
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
integral_part = integral_part * 10.0 + @as(f64, @floatFromInt(digit));
self.advance(1);
} else {
break;
}
}
if (self.hasAtLeast(1) and self.nextByteUnchecked() == '.' and std.ascii.isDigit(self.byteAt(1))) {
is_integer = false;
self.advance(1); // Consume '.'
var factor: f64 = 0.1;
while (!self.isEof()) {
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
fractional_part += @as(f64, @floatFromInt(digit)) * factor;
factor *= 0.1;
self.advance(1);
} else {
break;
}
}
}
var value = sign * (integral_part + fractional_part);
blk: {
const e = self.nextByte() orelse break :blk;
if (e != 'e' and e != 'E') break :blk;
var mul: f64 = 1.0;
if (self.hasAtLeast(2) and (self.byteAt(1) == '+' or self.byteAt(1) == '-') and std.ascii.isDigit(self.byteAt(2))) {
mul = switch (self.byteAt(1)) {
'-' => -1.0,
'+' => 1.0,
else => unreachable,
};
self.advance(2);
} else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
self.advance(1);
} else {
break :blk;
}
is_integer = false;
var exponent: f64 = 0.0;
while (!self.isEof()) {
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
exponent = exponent * 10.0 + @as(f64, @floatFromInt(digit));
self.advance(1);
} else {
break;
}
}
value *= std.math.pow(f64, 10.0, mul * exponent);
}
const int_value: ?i32 = if (is_integer) blk: {
if (value >= std.math.maxInt(i32)) {
break :blk std.math.maxInt(i32);
}
if (value <= std.math.minInt(i32)) {
break :blk std.math.minInt(i32);
}
break :blk @as(i32, @intFromFloat(value));
} else null;
if (!self.isEof() and self.nextByteUnchecked() == '%') {
self.advance(1);
return .{ .percentage = .{
.has_sign = has_sign,
.int_value = int_value,
.unit_value = @as(f32, @floatCast(value / 100.0)),
} };
}
if (isIdentStart(self)) {
return .{ .dimension = .{
.has_sign = has_sign,
.int_value = int_value,
.value = @as(f32, @floatCast(value)),
.unit = consumeName(self),
} };
}
return .{ .number = .{
.has_sign = has_sign,
.int_value = int_value,
.value = @as(f32, @floatCast(value)),
} };
}
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
// TODO: true url parser
if (self.nextByte()) |it| {
return self.consumeString(it == '\'');
}
return null;
}
fn consumeIdentLike(self: *Tokenizer) Token {
const value = self.consumeName();
if (!self.isEof() and self.nextByteUnchecked() == '(') {
self.advance(1);
if (std.ascii.eqlIgnoreCase(value, "url")) {
if (self.consumeUnquotedUrl()) |result| {
return result;
}
}
return .{ .function = value };
}
return .{ .ident = value };
}
pub fn next(self: *Tokenizer) ?Token {
if (self.isEof()) {
return null;
}
const b = self.nextByteUnchecked();
return switch (b) {
// Consume comments
'/' => {
if (self.startsWith("/*")) {
return .{ .comment = self.consumeComment() };
} else {
self.advance(1);
return .{ .delim = '/' };
}
},
// Consume marks
'(', ')', '{', '}', '[', ']', ',', ':', ';' => {
return self.consumeMark();
},
// Consume as much whitespace as possible. Return a <whitespace-token>.
' ', '\t' => self.consumeWhiteSpace(false),
'\n', '\x0C', '\r' => self.consumeWhiteSpace(true),
// Consume a string token and return it.
'"' => self.consumeString(false),
'\'' => self.consumeString(true),
'0'...'9' => self.consumeNumeric(),
'a'...'z', 'A'...'Z', '_', 0x0 => self.consumeIdentLike(),
'+' => {
if ((self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) or
(self.hasAtLeast(2) and self.byteAt(1) == '.' and std.ascii.isDigit(self.byteAt(2))))
{
return self.consumeNumeric();
}
self.advance(1);
return .{ .delim = '+' };
},
'-' => {
if ((self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) or
(self.hasAtLeast(2) and self.byteAt(1) == '.' and std.ascii.isDigit(self.byteAt(2))))
{
return self.consumeNumeric();
}
if (self.startsWith("-->")) {
self.advance(3);
return .cdc;
}
if (isIdentStart(self)) {
return self.consumeIdentLike();
}
self.advance(1);
return .{ .delim = '-' };
},
'.' => {
if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) {
return self.consumeNumeric();
}
self.advance(1);
return .{ .delim = '.' };
},
// Consume hash token
'#' => {
self.advance(1);
if (self.isIdentStart()) {
return .{ .id_hash = self.consumeName() };
}
if (self.nextByte()) |it| {
switch (it) {
// Any other valid case here already resulted in IDHash.
'0'...'9', '-' => return .{ .unrestricted_hash = self.consumeName() },
else => {},
}
}
return .{ .delim = '#' };
},
// Consume at-rules
'@' => {
self.advance(1);
return if (isIdentStart(self))
.{ .at_keyword = consumeName(self) }
else
.{ .delim = '@' };
},
'<' => {
if (self.startsWith("<!--")) {
self.advance(4);
return .cdo;
} else {
self.advance(1);
return .{ .delim = '<' };
}
},
'\\' => {
if (!self.hasNewlineAt(1)) {
return self.consumeIdentLike();
}
self.advance(1);
return .{ .delim = '\\' };
},
else => {
if (b > 0x7F) { // not is ascii
return self.consumeIdentLike();
}
self.advance(1);
return .{ .delim = b };
},
};
}
const testing = std.testing;
fn expectTokensEqual(input: []const u8, tokens: []const Token) !void {
var lexer = Tokenizer{ .input = input };
var i: usize = 0;
while (lexer.next()) |token| : (i += 1) {
assert(i < tokens.len);
try testing.expectEqualDeep(tokens[i], token);
}
try testing.expectEqual(i, tokens.len);
try testing.expectEqualDeep(null, lexer.next());
}
test "smoke" {
try expectTokensEqual(
\\.lightpanda {color:red;}
, &.{
.{ .delim = '.' },
.{ .ident = "lightpanda" },
.{ .white_space = " " },
.curly_bracket_block,
.{ .ident = "color" },
.colon,
.{ .ident = "red" },
.semicolon,
.close_curly_bracket,
});
}

View File

@@ -1,176 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// CSS Selector parser and query
// This package is a rewrite in Zig of Cascadia CSS Selector parser.
// see https://github.com/andybalholm/cascadia
const std = @import("std");
const Selector = @import("selector.zig").Selector;
const parser = @import("parser.zig");
// parse parse a selector string and returns the parsed result or an error.
pub fn parse(alloc: std.mem.Allocator, s: []const u8, opts: parser.ParseOptions) parser.ParseError!Selector {
var p = parser.Parser{ .s = s, .i = 0, .opts = opts };
return p.parse(alloc);
}
// matchFirst call m.match with the first node that matches the selector s, from the
// descendants of n and returns true. If none matches, it returns false.
pub fn matchFirst(s: Selector, node: anytype, m: anytype) !bool {
var c = try node.firstChild();
while (true) {
if (c == null) break;
if (try s.match(c.?)) {
try m.match(c.?);
return true;
}
if (try matchFirst(s, c.?, m)) return true;
c = try c.?.nextSibling();
}
return false;
}
// matchAll call m.match with the all the nodes that matches the selector s, from the
// descendants of n.
pub fn matchAll(s: Selector, node: anytype, m: anytype) !void {
var c = try node.firstChild();
while (true) {
if (c == null) break;
if (try s.match(c.?)) try m.match(c.?);
try matchAll(s, c.?, m);
c = try c.?.nextSibling();
}
}
test "parse" {
const alloc = std.testing.allocator;
const testcases = [_][]const u8{
"address",
"*",
"#foo",
"li#t1",
"*#t4",
".t1",
"p.t1",
"div.teST",
".t1.fail",
"p.t1.t2",
"p.--t1",
"p.--t1.--t2",
"p[title]",
"div[class=\"red\" i]",
"address[title=\"foo\"]",
"address[title=\"FoOIgnoRECaSe\" i]",
"address[title!=\"foo\"]",
"address[title!=\"foo\" i]",
"p[title!=\"FooBarUFoo\" i]",
"[ \t title ~= foo ]",
"p[title~=\"FOO\" i]",
"p[title~=toofoo i]",
"[title~=\"hello world\"]",
"[title~=\"hello\" i]",
"[title~=\"hello\" I]",
"[lang|=\"en\"]",
"[lang|=\"EN\" i]",
"[lang|=\"EN\" i]",
"[title^=\"foo\"]",
"[title^=\"foo\" i]",
"[title$=\"bar\"]",
"[title$=\"BAR\" i]",
"[title*=\"bar\"]",
"[title*=\"BaRu\" i]",
"[title*=\"BaRu\" I]",
"p[class$=\" \"]",
"p[class$=\"\"]",
"p[class^=\" \"]",
"p[class^=\"\"]",
"p[class*=\" \"]",
"p[class*=\"\"]",
"input[name=Sex][value=F]",
"table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]",
".t1:not(.t2)",
"div:not(.t1)",
"div:not([class=\"t2\"])",
"li:nth-child(odd)",
"li:nth-child(even)",
"li:nth-child(-n+2)",
"li:nth-child(3n+1)",
"li:nth-last-child(odd)",
"li:nth-last-child(even)",
"li:nth-last-child(-n+2)",
"li:nth-last-child(3n+1)",
"span:first-child",
"span:last-child",
"p:nth-of-type(2)",
"p:nth-last-of-type(2)",
"p:last-of-type",
"p:first-of-type",
"p:only-child",
"p:only-of-type",
":empty",
"div p",
"div table p",
"div > p",
"p ~ p",
"p + p",
"li, p",
"p +/*This is a comment*/ p",
"p:contains(\"that wraps\")",
"p:containsOwn(\"that wraps\")",
":containsOwn(\"inner\")",
"p:containsOwn(\"block\")",
"div:has(#p1)",
"div:has(:containsOwn(\"2\"))",
"body :has(:containsOwn(\"2\"))",
"body :haschild(:containsOwn(\"2\"))",
"p:matches([\\d])",
"p:matches([a-z])",
"p:matches([a-zA-Z])",
"p:matches([^\\d])",
"p:matches(^(0|a))",
"p:matches(^\\d+$)",
"p:not(:matches(^\\d+$))",
"div :matchesOwn(^\\d+$)",
"[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])",
"[href#=(^https:\\/\\/[^\\/]*\\/?news)]",
":input",
":root",
"*:root",
"html:nth-child(1)",
"*:root:first-child",
"*:root:nth-child(1)",
"a:not(:root)",
"body > *:nth-child(3n+2)",
"input:disabled",
":disabled",
":enabled",
"div.class1, div.class2",
};
for (testcases) |tc| {
const s = parse(alloc, tc, .{}) catch |e| {
std.debug.print("query {s}", .{tc});
return e;
};
defer s.deinit(alloc);
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
// Node implementation with Netsurf Libdom C lib.
pub const Node = struct {
node: *parser.Node,
pub fn firstChild(n: Node) !?Node {
const c = try parser.nodeFirstChild(n.node);
if (c) |cc| return .{ .node = cc };
return null;
}
pub fn lastChild(n: Node) !?Node {
const c = try parser.nodeLastChild(n.node);
if (c) |cc| return .{ .node = cc };
return null;
}
pub fn nextSibling(n: Node) !?Node {
const c = try parser.nodeNextSibling(n.node);
if (c) |cc| return .{ .node = cc };
return null;
}
pub fn prevSibling(n: Node) !?Node {
const c = try parser.nodePreviousSibling(n.node);
if (c) |cc| return .{ .node = cc };
return null;
}
pub fn parent(n: Node) !?Node {
const c = try parser.nodeParentNode(n.node);
if (c) |cc| return .{ .node = cc };
return null;
}
pub fn isElement(n: Node) bool {
const t = parser.nodeType(n.node) catch return false;
return t == .element;
}
pub fn isDocument(n: Node) bool {
const t = parser.nodeType(n.node) catch return false;
return t == .document;
}
pub fn isComment(n: Node) bool {
const t = parser.nodeType(n.node) catch return false;
return t == .comment;
}
pub fn isText(n: Node) bool {
const t = parser.nodeType(n.node) catch return false;
return t == .text;
}
pub fn isEmptyText(n: Node) !bool {
const data = try parser.nodeTextContent(n.node);
if (data == null) return true;
if (data.?.len == 0) return true;
return std.mem.trim(u8, data.?, &std.ascii.whitespace).len == 0;
}
pub fn tag(n: Node) ![]const u8 {
return try parser.nodeName(n.node);
}
pub fn attr(n: Node, key: []const u8) !?[]const u8 {
if (!n.isElement()) return null;
return try parser.elementGetAttribute(parser.nodeToElement(n.node), key);
}
pub fn eql(a: Node, b: Node) bool {
return a.node == b.node;
}
};

View File

@@ -1,325 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const css = @import("css.zig");
const Node = @import("libdom.zig").Node;
const parser = @import("../netsurf.zig");
const Matcher = struct {
const Nodes = std.ArrayList(Node);
nodes: Nodes,
fn init(alloc: std.mem.Allocator) Matcher {
return .{ .nodes = Nodes.init(alloc) };
}
fn deinit(m: *Matcher) void {
m.nodes.deinit();
}
fn reset(m: *Matcher) void {
m.nodes.clearRetainingCapacity();
}
pub fn match(m: *Matcher, n: Node) !void {
try m.nodes.append(n);
}
};
test "matchFirst" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
const testcases = [_]struct {
q: []const u8,
html: []const u8,
exp: usize,
}{
.{ .q = "address", .html = "<body><address>This address...</address></body>", .exp = 1 },
.{ .q = "*", .html = "<!-- comment --><html><head></head><body>text</body></html>", .exp = 1 },
.{ .q = "*", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "#foo", .html = "<p id=\"foo\"><p id=\"bar\">", .exp = 1 },
.{ .q = "li#t1", .html = "<ul><li id=\"t1\"><p id=\"t1\">", .exp = 1 },
.{ .q = ".t3", .html = "<ul><li class=\"t1\"><li class=\"t2 t3\">", .exp = 1 },
.{ .q = "*#t4", .html = "<ol><li id=\"t4\"><li id=\"t44\">", .exp = 1 },
.{ .q = ".t1", .html = "<ul><li class=\"t1\"><li class=\"t2\">", .exp = 1 },
.{ .q = "p.t1", .html = "<p class=\"t1 t2\">", .exp = 1 },
.{ .q = "div.teST", .html = "<div class=\"test\">", .exp = 0 },
.{ .q = ".t1.fail", .html = "<p class=\"t1 t2\">", .exp = 0 },
.{ .q = "p.t1.t2", .html = "<p class=\"t1 t2\">", .exp = 1 },
.{ .q = "p.--t1", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
.{ .q = "p.--t1.--t2", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
.{ .q = "p[title]", .html = "<p><p title=\"title\">", .exp = 1 },
.{ .q = "div[class=\"red\" i]", .html = "<div><div class=\"Red\">", .exp = 1 },
.{ .q = "address[title=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
.{ .q = "address[title=\"FoOIgnoRECaSe\" i]", .html = "<address><address title=\"fooIgnoreCase\"><address title=\"bar\">", .exp = 1 },
.{ .q = "address[title!=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
.{ .q = "address[title!=\"foo\" i]", .html = "<address><address title=\"FOO\"><address title=\"bar\">", .exp = 1 },
.{ .q = "p[title!=\"FooBarUFoo\" i]", .html = "<p title=\"fooBARuFOO\"><p title=\"varfoo\">", .exp = 1 },
.{ .q = "[ title ~= foo ]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
.{ .q = "p[title~=\"FOO\" i]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
.{ .q = "p[title~=toofoo i]", .html = "<p title=\"tot foo bar\">", .exp = 0 },
.{ .q = "[title~=\"hello world\"]", .html = "<p title=\"hello world\">", .exp = 0 },
.{ .q = "[title~=\"hello\" i]", .html = "<p title=\"HELLO world\">", .exp = 1 },
.{ .q = "[title~=\"hello\" I]", .html = "<p title=\"HELLO world\">", .exp = 1 },
.{ .q = "[lang|=\"en\"]", .html = "<p lang=\"en\"><p lang=\"en-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
.{ .q = "[title^=\"foo\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title^=\"foo\" i]", .html = "<p title=\"FooBAR\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title$=\"bar\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title$=\"BAR\" i]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title*=\"bar\"]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "[title*=\"BaRu\" i]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "[title*=\"BaRu\" I]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "p[class$=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class$=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class^=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class^=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class*=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class*=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "input[name=Sex][value=F]", .html = "<input type=\"radio\" name=\"Sex\" value=\"F\"/>", .exp = 1 },
.{ .q = "table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]", .html = "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout: fixed; width: 100%; border: 0 dashed; border-color: #FFFFFF\"><tr style=\"height:64px\">aaa</tr></table>", .exp = 1 },
.{ .q = ".t1:not(.t2)", .html = "<p class=\"t1 t2\">", .exp = 0 },
.{ .q = "div:not(.t1)", .html = "<div class=\"t3\">", .exp = 1 },
.{ .q = "div:not([class=\"t2\"])", .html = "<div><div class=\"t2\"><div class=\"t3\">", .exp = 1 },
.{ .q = "li:nth-child(odd)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-child(even)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-last-child(odd)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
.{ .q = "li:nth-last-child(even)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
.{ .q = "li:nth-last-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
.{ .q = "li:nth-last-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
.{ .q = "span:first-child", .html = "<p>some text <span id=\"1\">and a span</span><span id=\"2\"> and another</span></p>", .exp = 1 },
.{ .q = "span:last-child", .html = "<span>a span</span> and some text", .exp = 1 },
.{ .q = "p:nth-of-type(2)", .html = "<address></address><p id=1><p id=2>", .exp = 1 },
.{ .q = "p:nth-last-of-type(2)", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:last-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:first-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:only-child", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p></div>", .exp = 1 },
.{ .q = "p:only-of-type", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p><p id=\"3\"></p></div>", .exp = 1 },
.{ .q = ":empty", .html = "<p id=\"1\"><!-- --><p id=\"2\">Hello<p id=\"3\"><span>", .exp = 1 },
.{ .q = "div p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
.{ .q = "div table p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
.{ .q = "div > p", .html = "<div><p id=\"1\"><div><p id=\"2\"></div><table><tr><td><p id=\"3\"></table></div>", .exp = 1 },
.{ .q = "p ~ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
.{ .q = "p + p", .html = "<p id=\"1\"></p> <!--comment--> <p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
.{ .q = "li, p", .html = "<ul><li></li><li></li></ul><p>", .exp = 1 },
.{ .q = "p +/*This is a comment*/ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
// .{ .q = "p:contains(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
// .{ .q = "p:containsOwn(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 0 },
// .{ .q = ":containsOwn(\"inner\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
// .{ .q = "p:containsOwn(\"block\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
// .{ .q = "div:has(#p1)", .html = "<div id=\"d1\"><p id=\"p1\"><span>text content</span></p></div><div id=\"d2\"/>", .exp = 1 },
// .{ .q = "div:has(:containsOwn(\"2\"))", .html = "<div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p>contents <em>2</em></p></div>", .exp = 1 },
// .{ .q = "body :has(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
// .{ .q = "body :haschild(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
// .{ .q = "p:matches([\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches([a-z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches([a-zA-Z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches([^\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches(^(0|a))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches(^\\d+$)", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:not(:matches(^\\d+$))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "div :matchesOwn(^\\d+$)", .html = "<div><p id=\"p1\">01234<em>567</em>89</p><div>", .exp = 1 },
// .{ .q = "[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"></a> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"></a> <li><a id=\"a2\" href=\"http://finance.untrusted.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"/> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
// .{ .q = "[href#=(^https:\\/\\/[^\\/]*\\/?news)]", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"/> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"></a> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
.{ .q = ":input", .html = "<form> <label>Username <input type=\"text\" name=\"username\" /></label> <label>Password <input type=\"password\" name=\"password\" /></label> <label>Country <select name=\"country\"> <option value=\"ca\">Canada</option> <option value=\"us\">United States</option> </select> </label> <label>Bio <textarea name=\"bio\"></textarea></label> <button>Sign up</button> </form>", .exp = 1 },
.{ .q = ":root", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "html:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root:first-child", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "a:not(:root)", .html = "<html><head></head><body><a href=\"http://www.foo.com\"></a></body></html>", .exp = 1 },
.{ .q = "body > *:nth-child(3n+2)", .html = "<html><head></head><body><p></p><div></div><span></span><a></a><form></form></body></html>", .exp = 1 },
.{ .q = "input:disabled", .html = "<html><head></head><body><fieldset disabled><legend id=\"1\"><input id=\"i1\"/></legend><legend id=\"2\"><input id=\"i2\"/></legend></fieldset></body></html>", .exp = 1 },
.{ .q = ":disabled", .html = "<html><head></head><body><fieldset disabled></fieldset></body></html>", .exp = 1 },
.{ .q = ":enabled", .html = "<html><head></head><body><fieldset></fieldset></body></html>", .exp = 1 },
.{ .q = "div.class1, div.class2", .html = "<div class=class1></div><div class=class2></div><div class=class3></div>", .exp = 1 },
};
for (testcases) |tc| {
matcher.reset();
const doc = try parser.documentHTMLParseFromStr(tc.html);
defer parser.documentHTMLClose(doc) catch {};
const s = css.parse(alloc, tc.q, .{}) catch |e| {
std.debug.print("parse, query: {s}\n", .{tc.q});
return e;
};
defer s.deinit(alloc);
const node = Node{ .node = parser.documentHTMLToNode(doc) };
_ = css.matchFirst(s, node, &matcher) catch |e| {
std.debug.print("match, query: {s}\n", .{tc.q});
return e;
};
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
std.debug.print("expectation, query: {s}\n", .{tc.q});
return e;
};
}
}
test "matchAll" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
const testcases = [_]struct {
q: []const u8,
html: []const u8,
exp: usize,
}{
.{ .q = "address", .html = "<body><address>This address...</address></body>", .exp = 1 },
.{ .q = "*", .html = "<!-- comment --><html><head></head><body>text</body></html>", .exp = 3 },
.{ .q = "*", .html = "<html><head></head><body></body></html>", .exp = 3 },
.{ .q = "#foo", .html = "<p id=\"foo\"><p id=\"bar\">", .exp = 1 },
.{ .q = "li#t1", .html = "<ul><li id=\"t1\"><p id=\"t1\">", .exp = 1 },
.{ .q = ".t3", .html = "<ul><li class=\"t1\"><li class=\"t2 t3\">", .exp = 1 },
.{ .q = "*#t4", .html = "<ol><li id=\"t4\"><li id=\"t44\">", .exp = 1 },
.{ .q = ".t1", .html = "<ul><li class=\"t1\"><li class=\"t2\">", .exp = 1 },
.{ .q = "p.t1", .html = "<p class=\"t1 t2\">", .exp = 1 },
.{ .q = "div.teST", .html = "<div class=\"test\">", .exp = 0 },
.{ .q = ".t1.fail", .html = "<p class=\"t1 t2\">", .exp = 0 },
.{ .q = "p.t1.t2", .html = "<p class=\"t1 t2\">", .exp = 1 },
.{ .q = "p.--t1", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
.{ .q = "p.--t1.--t2", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
.{ .q = "p[title]", .html = "<p><p title=\"title\">", .exp = 1 },
.{ .q = "div[class=\"red\" i]", .html = "<div><div class=\"Red\">", .exp = 1 },
.{ .q = "address[title=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
.{ .q = "address[title=\"FoOIgnoRECaSe\" i]", .html = "<address><address title=\"fooIgnoreCase\"><address title=\"bar\">", .exp = 1 },
.{ .q = "address[title!=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 2 },
.{ .q = "address[title!=\"foo\" i]", .html = "<address><address title=\"FOO\"><address title=\"bar\">", .exp = 2 },
.{ .q = "p[title!=\"FooBarUFoo\" i]", .html = "<p title=\"fooBARuFOO\"><p title=\"varfoo\">", .exp = 1 },
.{ .q = "[ title ~= foo ]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
.{ .q = "p[title~=\"FOO\" i]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
.{ .q = "p[title~=toofoo i]", .html = "<p title=\"tot foo bar\">", .exp = 0 },
.{ .q = "[title~=\"hello world\"]", .html = "<p title=\"hello world\">", .exp = 0 },
.{ .q = "[title~=\"hello\" i]", .html = "<p title=\"HELLO world\">", .exp = 1 },
.{ .q = "[title~=\"hello\" I]", .html = "<p title=\"HELLO world\">", .exp = 1 },
.{ .q = "[lang|=\"en\"]", .html = "<p lang=\"en\"><p lang=\"en-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
.{ .q = "[title^=\"foo\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title^=\"foo\" i]", .html = "<p title=\"FooBAR\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title$=\"bar\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title$=\"BAR\" i]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
.{ .q = "[title*=\"bar\"]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "[title*=\"BaRu\" i]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "[title*=\"BaRu\" I]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
.{ .q = "p[class$=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class$=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class^=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class^=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class*=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "p[class*=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
.{ .q = "input[name=Sex][value=F]", .html = "<input type=\"radio\" name=\"Sex\" value=\"F\"/>", .exp = 1 },
.{ .q = "table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]", .html = "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout: fixed; width: 100%; border: 0 dashed; border-color: #FFFFFF\"><tr style=\"height:64px\">aaa</tr></table>", .exp = 1 },
.{ .q = ".t1:not(.t2)", .html = "<p class=\"t1 t2\">", .exp = 0 },
.{ .q = "div:not(.t1)", .html = "<div class=\"t3\">", .exp = 1 },
.{ .q = "div:not([class=\"t2\"])", .html = "<div><div class=\"t2\"><div class=\"t3\">", .exp = 2 },
.{ .q = "li:nth-child(odd)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 2 },
.{ .q = "li:nth-child(even)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 2 },
.{ .q = "li:nth-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
.{ .q = "li:nth-last-child(odd)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
.{ .q = "li:nth-last-child(even)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
.{ .q = "li:nth-last-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
.{ .q = "li:nth-last-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
.{ .q = "span:first-child", .html = "<p>some text <span id=\"1\">and a span</span><span id=\"2\"> and another</span></p>", .exp = 1 },
.{ .q = "span:last-child", .html = "<span>a span</span> and some text", .exp = 1 },
.{ .q = "p:nth-of-type(2)", .html = "<address></address><p id=1><p id=2>", .exp = 1 },
.{ .q = "p:nth-last-of-type(2)", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:last-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:first-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
.{ .q = "p:only-child", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p></div>", .exp = 1 },
.{ .q = "p:only-of-type", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p><p id=\"3\"></p></div>", .exp = 1 },
.{ .q = ":empty", .html = "<p id=\"1\"><!-- --><p id=\"2\">Hello<p id=\"3\"><span>", .exp = 3 },
.{ .q = "div p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 2 },
.{ .q = "div table p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
.{ .q = "div > p", .html = "<div><p id=\"1\"><div><p id=\"2\"></div><table><tr><td><p id=\"3\"></table></div>", .exp = 2 },
.{ .q = "p ~ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 2 },
.{ .q = "p + p", .html = "<p id=\"1\"></p> <!--comment--> <p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
.{ .q = "li, p", .html = "<ul><li></li><li></li></ul><p>", .exp = 3 },
.{ .q = "p +/*This is a comment*/ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
// .{ .q = "p:contains(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
// .{ .q = "p:containsOwn(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 0 },
// .{ .q = ":containsOwn(\"inner\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
// .{ .q = "p:containsOwn(\"block\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
.{ .q = "div:has(#p1)", .html = "<div id=\"d1\"><p id=\"p1\"><span>text content</span></p></div><div id=\"d2\"/>", .exp = 1 },
// .{ .q = "div:has(:containsOwn(\"2\"))", .html = "<div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p>contents <em>2</em></p></div>", .exp = 1 },
// .{ .q = "body :has(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 2 },
// .{ .q = "body :haschild(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
// .{ .q = "p:matches([\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
// .{ .q = "p:matches([a-z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:matches([a-zA-Z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
// .{ .q = "p:matches([^\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
// .{ .q = "p:matches(^(0|a))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 3 },
// .{ .q = "p:matches(^\\d+$)", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
// .{ .q = "p:not(:matches(^\\d+$))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
// .{ .q = "div :matchesOwn(^\\d+$)", .html = "<div><p id=\"p1\">01234<em>567</em>89</p><div>", .exp = 2 },
// .{ .q = "[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"></a> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"></a> <li><a id=\"a2\" href=\"http://finance.untrusted.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"/> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 2 },
// .{ .q = "[href#=(^https:\\/\\/[^\\/]*\\/?news)]", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"/> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"></a> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
.{ .q = ":input", .html = "<form> <label>Username <input type=\"text\" name=\"username\" /></label> <label>Password <input type=\"password\" name=\"password\" /></label> <label>Country <select name=\"country\"> <option value=\"ca\">Canada</option> <option value=\"us\">United States</option> </select> </label> <label>Bio <textarea name=\"bio\"></textarea></label> <button>Sign up</button> </form>", .exp = 5 },
.{ .q = ":root", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "html:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root:first-child", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "*:root:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
.{ .q = "a:not(:root)", .html = "<html><head></head><body><a href=\"http://www.foo.com\"></a></body></html>", .exp = 1 },
.{ .q = "body > *:nth-child(3n+2)", .html = "<html><head></head><body><p></p><div></div><span></span><a></a><form></form></body></html>", .exp = 2 },
.{ .q = "input:disabled", .html = "<html><head></head><body><fieldset disabled><legend id=\"1\"><input id=\"i1\"/></legend><legend id=\"2\"><input id=\"i2\"/></legend></fieldset></body></html>", .exp = 1 },
.{ .q = ":disabled", .html = "<html><head></head><body><fieldset disabled></fieldset></body></html>", .exp = 1 },
.{ .q = ":enabled", .html = "<html><head></head><body><fieldset></fieldset></body></html>", .exp = 1 },
.{ .q = "div.class1, div.class2", .html = "<div class=class1></div><div class=class2></div><div class=class3></div>", .exp = 2 },
};
for (testcases) |tc| {
matcher.reset();
const doc = try parser.documentHTMLParseFromStr(tc.html);
defer parser.documentHTMLClose(doc) catch {};
const s = css.parse(alloc, tc.q, .{}) catch |e| {
std.debug.print("parse, query: {s}\n", .{tc.q});
return e;
};
defer s.deinit(alloc);
const node = Node{ .node = parser.documentHTMLToNode(doc) };
_ = css.matchAll(s, node, &matcher) catch |e| {
std.debug.print("match, query: {s}\n", .{tc.q});
return e;
};
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
std.debug.print("expectation, query: {s}\n", .{tc.q});
return e;
};
}
}

View File

@@ -1,587 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const css = @import("css.zig");
// Node mock implementation for test only.
pub const Node = struct {
child: ?*const Node = null,
last: ?*const Node = null,
sibling: ?*const Node = null,
prev: ?*const Node = null,
par: ?*const Node = null,
name: []const u8 = "",
att: ?[]const u8 = null,
pub fn firstChild(n: *const Node) !?*const Node {
return n.child;
}
pub fn lastChild(n: *const Node) !?*const Node {
return n.last;
}
pub fn nextSibling(n: *const Node) !?*const Node {
return n.sibling;
}
pub fn prevSibling(n: *const Node) !?*const Node {
return n.prev;
}
pub fn parent(n: *const Node) !?*const Node {
return n.par;
}
pub fn isElement(_: *const Node) bool {
return true;
}
pub fn isDocument(_: *const Node) bool {
return false;
}
pub fn isComment(_: *const Node) bool {
return false;
}
pub fn isText(_: *const Node) bool {
return false;
}
pub fn isEmptyText(_: *const Node) !bool {
return false;
}
pub fn tag(n: *const Node) ![]const u8 {
return n.name;
}
pub fn attr(n: *const Node, _: []const u8) !?[]const u8 {
return n.att;
}
pub fn eql(a: *const Node, b: *const Node) bool {
return a == b;
}
};
const Matcher = struct {
const Nodes = std.ArrayList(*const Node);
nodes: Nodes,
fn init(alloc: std.mem.Allocator) Matcher {
return .{ .nodes = Nodes.init(alloc) };
}
fn deinit(m: *Matcher) void {
m.nodes.deinit();
}
fn reset(m: *Matcher) void {
m.nodes.clearRetainingCapacity();
}
pub fn match(m: *Matcher, n: *const Node) !void {
try m.nodes.append(n);
}
};
test "matchFirst" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
const testcases = [_]struct {
q: []const u8,
n: Node,
exp: usize,
}{
.{
.q = "address",
.n = .{ .child = &.{ .name = "body", .child = &.{ .name = "address" } } },
.exp = 1,
},
.{
.q = "#foo",
.n = .{ .child = &.{ .name = "p", .att = "foo", .child = &.{ .name = "p" } } },
.exp = 1,
},
.{
.q = ".t1",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "t1" } } },
.exp = 1,
},
.{
.q = ".t1",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "foo t1" } } },
.exp = 1,
},
.{
.q = "[foo]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p" } } },
.exp = 0,
},
.{
.q = "[foo]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 0,
},
.{
.q = "[foo!=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo!=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo~=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "baz bar" } } },
.exp = 1,
},
.{
.q = "[foo~=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 0,
},
.{
.q = "[foo^=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo$=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo*=rb]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar-baz" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "ba" } } },
.exp = 0,
},
.{
.q = "strong, a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .name = "p" } }, .sibling = &.{ .name = "a" } } },
.exp = 1,
},
.{
.q = "p a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "span", .child = &.{
.name = "a",
.par = &.{ .name = "span", .par = &.{ .name = "p" } },
} } } },
.exp = 1,
},
.{
.q = ":not(p)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p:has(a)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p:has(strong)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 0,
},
.{
.q = "p:haschild(a)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p:haschild(strong)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 0,
},
.{
.q = "p:lang(en)",
.n = .{ .child = &.{ .name = "p", .att = "en-US", .child = &.{ .name = "a" } } },
.exp = 1,
},
.{
.q = "a:lang(en)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .att = "en-US" } } } },
.exp = 1,
},
};
for (testcases) |tc| {
matcher.reset();
const s = try css.parse(alloc, tc.q, .{});
defer s.deinit(alloc);
_ = css.matchFirst(s, &tc.n, &matcher) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
}
}
test "matchAll" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
const testcases = [_]struct {
q: []const u8,
n: Node,
exp: usize,
}{
.{
.q = "address",
.n = .{ .child = &.{ .name = "body", .child = &.{ .name = "address" } } },
.exp = 1,
},
.{
.q = "#foo",
.n = .{ .child = &.{ .name = "p", .att = "foo", .child = &.{ .name = "p" } } },
.exp = 1,
},
.{
.q = ".t1",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "t1" } } },
.exp = 1,
},
.{
.q = ".t1",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "foo t1" } } },
.exp = 1,
},
.{
.q = "[foo]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p" } } },
.exp = 0,
},
.{
.q = "[foo]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 0,
},
.{
.q = "[foo!=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo!=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 2,
},
.{
.q = "[foo~=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "baz bar" } } },
.exp = 1,
},
.{
.q = "[foo~=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 0,
},
.{
.q = "[foo^=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo$=baz]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo*=rb]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar-baz" } } },
.exp = 1,
},
.{
.q = "[foo|=bar]",
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "ba" } } },
.exp = 0,
},
.{
.q = "strong, a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 2,
},
.{
.q = "p a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .name = "p" } }, .sibling = &.{ .name = "a" } } },
.exp = 1,
},
.{
.q = "p a",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "span", .child = &.{
.name = "a",
.par = &.{ .name = "span", .par = &.{ .name = "p" } },
} } } },
.exp = 1,
},
.{
.q = ":not(p)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 2,
},
.{
.q = "p:has(a)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p:has(strong)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 0,
},
.{
.q = "p:haschild(a)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 1,
},
.{
.q = "p:haschild(strong)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
.exp = 0,
},
.{
.q = "p:lang(en)",
.n = .{ .child = &.{ .name = "p", .att = "en-US", .child = &.{ .name = "a" } } },
.exp = 1,
},
.{
.q = "a:lang(en)",
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .att = "en-US" } } } },
.exp = 1,
},
};
for (testcases) |tc| {
matcher.reset();
const s = try css.parse(alloc, tc.q, .{});
defer s.deinit(alloc);
css.matchAll(s, &tc.n, &matcher) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
}
}
test "pseudo class" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
var p1: Node = .{ .name = "p" };
var p2: Node = .{ .name = "p" };
var a1: Node = .{ .name = "a" };
p1.sibling = &p2;
p2.prev = &p1;
p2.sibling = &a1;
a1.prev = &p2;
var root: Node = .{ .child = &p1, .last = &a1 };
p1.par = &root;
p2.par = &root;
a1.par = &root;
const testcases = [_]struct {
q: []const u8,
n: Node,
exp: ?*const Node,
}{
.{ .q = "p:only-child", .n = root, .exp = null },
.{ .q = "a:only-of-type", .n = root, .exp = &a1 },
};
for (testcases) |tc| {
matcher.reset();
const s = try css.parse(alloc, tc.q, .{});
defer s.deinit(alloc);
css.matchAll(s, &tc.n, &matcher) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
if (tc.exp) |exp_n| {
const exp: usize = 1;
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
std.testing.expectEqual(exp_n, matcher.nodes.items[0]) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
continue;
}
const exp: usize = 0;
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
}
}
test "nth pseudo class" {
const alloc = std.testing.allocator;
var matcher = Matcher.init(alloc);
defer matcher.deinit();
var p1: Node = .{ .name = "p" };
var p2: Node = .{ .name = "p" };
p1.sibling = &p2;
p2.prev = &p1;
var root: Node = .{ .child = &p1, .last = &p2 };
p1.par = &root;
p2.par = &root;
const testcases = [_]struct {
q: []const u8,
n: Node,
exp: ?*const Node,
}{
.{ .q = "a:nth-of-type(1)", .n = root, .exp = null },
.{ .q = "p:nth-of-type(1)", .n = root, .exp = &p1 },
.{ .q = "p:nth-of-type(2)", .n = root, .exp = &p2 },
.{ .q = "p:nth-of-type(0)", .n = root, .exp = null },
.{ .q = "p:nth-of-type(2n)", .n = root, .exp = &p2 },
.{ .q = "p:nth-last-child(1)", .n = root, .exp = &p2 },
.{ .q = "p:nth-last-child(2)", .n = root, .exp = &p1 },
.{ .q = "p:nth-child(1)", .n = root, .exp = &p1 },
.{ .q = "p:nth-child(2)", .n = root, .exp = &p2 },
.{ .q = "p:nth-child(odd)", .n = root, .exp = &p1 },
.{ .q = "p:nth-child(even)", .n = root, .exp = &p2 },
.{ .q = "p:nth-child(n+2)", .n = root, .exp = &p2 },
};
for (testcases) |tc| {
matcher.reset();
const s = try css.parse(alloc, tc.q, .{});
defer s.deinit(alloc);
css.matchAll(s, &tc.n, &matcher) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
if (tc.exp) |exp_n| {
const exp: usize = 1;
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
std.testing.expectEqual(exp_n, matcher.nodes.items[0]) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
continue;
}
const exp: usize = 0;
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
return e;
};
}
}

View File

@@ -1,951 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// CSS Selector parser
// This file is a rewrite in Zig of Cascadia CSS Selector parser.
// see https://github.com/andybalholm/cascadia
// see https://github.com/andybalholm/cascadia/blob/master/parser.go
const std = @import("std");
const ascii = std.ascii;
const selector = @import("selector.zig");
const Selector = selector.Selector;
const PseudoClass = selector.PseudoClass;
const AttributeOP = selector.AttributeOP;
const Combinator = selector.Combinator;
const REPLACEMENT_CHARACTER = &.{ 239, 191, 189 };
pub const ParseError = error{
ExpectedSelector,
ExpectedIdentifier,
ExpectedName,
ExpectedIDSelector,
ExpectedClassSelector,
ExpectedAttributeSelector,
ExpectedString,
ExpectedRegexp,
ExpectedPseudoClassSelector,
ExpectedParenthesis,
ExpectedParenthesisClose,
ExpectedNthExpression,
ExpectedInteger,
InvalidEscape,
EscapeLineEndingOutsideString,
InvalidUnicode,
UnicodeIsNotHandled,
WriteError,
PseudoElementNotAtSelectorEnd,
PseudoElementNotUnique,
PseudoElementDisabled,
InvalidAttributeOperator,
InvalidAttributeSelector,
InvalidString,
InvalidRegexp,
InvalidPseudoClassSelector,
EmptyPseudoClassSelector,
InvalidPseudoClass,
InvalidPseudoElement,
UnmatchParenthesis,
NotHandled,
UnknownPseudoSelector,
InvalidNthExpression,
} || PseudoClass.Error || Combinator.Error || std.mem.Allocator.Error;
pub const ParseOptions = struct {
accept_pseudo_elts: bool = true,
};
pub const Parser = struct {
s: []const u8, // string to parse
i: usize = 0, // current position
opts: ParseOptions,
pub fn parse(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
return p.parseSelectorGroup(alloc);
}
// skipWhitespace consumes whitespace characters and comments.
// It returns true if there was actually anything to skip.
fn skipWhitespace(p: *Parser) bool {
var i = p.i;
while (i < p.s.len) {
const c = p.s[i];
// Whitespaces.
if (ascii.isWhitespace(c)) {
i += 1;
continue;
}
// Comments.
if (c == '/') {
if (std.mem.startsWith(u8, p.s[i..], "/*")) {
if (std.mem.indexOf(u8, p.s[i..], "*/")) |end| {
i += end + "*/".len;
continue;
}
}
}
break;
}
if (i > p.i) {
p.i = i;
return true;
}
return false;
}
// parseSimpleSelectorSequence parses a selector sequence that applies to
// a single element.
fn parseSimpleSelectorSequence(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
if (p.i >= p.s.len) {
return ParseError.ExpectedSelector;
}
var buf = std.ArrayList(Selector).init(alloc);
defer buf.deinit();
switch (p.s[p.i]) {
'*' => {
// It's the universal selector. Just skip over it, since it
// doesn't affect the meaning.
p.i += 1;
// other version of universal selector
if (p.i + 2 < p.s.len and std.mem.eql(u8, "|*", p.s[p.i .. p.i + 2])) {
p.i += 2;
}
},
'#', '.', '[', ':' => {
// There's no type selector. Wait to process the other till the
// main loop.
},
else => try buf.append(try p.parseTypeSelector(alloc)),
}
var pseudo_elt: ?PseudoClass = null;
loop: while (p.i < p.s.len) {
var ns: Selector = switch (p.s[p.i]) {
'#' => try p.parseIDSelector(alloc),
'.' => try p.parseClassSelector(alloc),
'[' => try p.parseAttributeSelector(alloc),
':' => try p.parsePseudoclassSelector(alloc),
else => break :loop,
};
errdefer ns.deinit(alloc);
// From https://drafts.csswg.org/selectors-3/#pseudo-elements :
// "Only one pseudo-element may appear per selector, and if present
// it must appear after the sequence of simple selectors that
// represents the subjects of the selector.""
switch (ns) {
.pseudo_element => |e| {
// We found a pseudo-element.
// Only one pseudo-element is accepted per selector.
if (pseudo_elt != null) return ParseError.PseudoElementNotUnique;
if (!p.opts.accept_pseudo_elts) return ParseError.PseudoElementDisabled;
pseudo_elt = e;
ns.deinit(alloc);
},
else => {
if (pseudo_elt != null) return ParseError.PseudoElementNotAtSelectorEnd;
try buf.append(ns);
},
}
}
// no need wrap the selectors in compoundSelector
if (buf.items.len == 1 and pseudo_elt == null) return buf.items[0];
return .{ .compound = .{ .selectors = try buf.toOwnedSlice(), .pseudo_elt = pseudo_elt } };
}
// parseTypeSelector parses a type selector (one that matches by tag name).
fn parseTypeSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseIdentifier(buf.writer());
return .{ .tag = try buf.toOwnedSlice() };
}
// parseIdentifier parses an identifier.
fn parseIdentifier(p: *Parser, w: anytype) ParseError!void {
const prefix = '-';
var numPrefix: usize = 0;
while (p.s.len > p.i and p.s[p.i] == prefix) {
p.i += 1;
numPrefix += 1;
}
if (p.s.len <= p.i) {
return ParseError.ExpectedSelector;
}
const c = p.s[p.i];
if (!nameStart(c) or c == '\\') {
return ParseError.ExpectedSelector;
}
var ii: usize = 0;
while (ii < numPrefix) {
w.writeByte(prefix) catch return ParseError.WriteError;
ii += 1;
}
try parseName(p, w);
}
// parseName parses a name (which is like an identifier, but doesn't have
// extra restrictions on the first character).
fn parseName(p: *Parser, w: anytype) ParseError!void {
const sel = p.s;
const sel_len = sel.len;
var i = p.i;
var ok = false;
while (i < sel_len) {
const c = sel[i];
if (nameChar(c)) {
const start = i;
while (i < sel_len and nameChar(sel[i])) i += 1;
w.writeAll(sel[start..i]) catch return ParseError.WriteError;
ok = true;
} else if (c == '\\') {
p.i = i;
try p.parseEscape(w);
i = p.i;
ok = true;
} else if (c == 0) {
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
i += 1;
if (i == sel_len) {
ok = true;
}
} else {
// default:
break;
}
}
if (!ok) return ParseError.ExpectedName;
p.i = i;
}
// parseEscape parses a backslash escape.
// The returned string is owned by the caller.
fn parseEscape(p: *Parser, w: anytype) ParseError!void {
const sel = p.s;
const sel_len = sel.len;
if (sel_len < p.i + 2 or sel[p.i] != '\\') {
p.i += 1;
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
return;
}
const start = p.i + 1;
const c = sel[start];
// unicode escape (hex)
if (ascii.isHex(c)) {
var i: usize = start;
while (i < start + 6 and i < sel_len and ascii.isHex(sel[i])) {
i += 1;
}
const v = std.fmt.parseUnsigned(u21, sel[start..i], 16) catch {
p.i = i;
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
return;
};
if (sel_len >= i) {
if (sel_len > i) {
switch (sel[i]) {
'\r' => {
i += 1;
if (sel_len > i and sel[i] == '\n') i += 1;
},
' ', '\t', '\n', std.ascii.control_code.ff => i += 1,
else => {},
}
}
p.i = i;
if (v == 0) {
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
return;
}
var buf: [4]u8 = undefined;
const ln = std.unicode.utf8Encode(v, &buf) catch {
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
return;
};
w.writeAll(buf[0..ln]) catch return ParseError.WriteError;
return;
}
}
// Return the literal character after the backslash.
p.i += 2;
w.writeByte(sel[start]) catch return ParseError.WriteError;
}
// parseIDSelector parses a selector that matches by id attribute.
fn parseIDSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
if (p.i >= p.s.len) return ParseError.ExpectedIDSelector;
if (p.s[p.i] != '#') return ParseError.ExpectedIDSelector;
p.i += 1;
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseName(buf.writer());
return .{ .id = try buf.toOwnedSlice() };
}
// parseClassSelector parses a selector that matches by class attribute.
fn parseClassSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
if (p.i >= p.s.len) return ParseError.ExpectedClassSelector;
if (p.s[p.i] != '.') return ParseError.ExpectedClassSelector;
p.i += 1;
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseIdentifier(buf.writer());
return .{ .class = try buf.toOwnedSlice() };
}
// parseAttributeSelector parses a selector that matches by attribute value.
fn parseAttributeSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
if (p.s[p.i] != '[') return ParseError.ExpectedAttributeSelector;
p.i += 1;
_ = p.skipWhitespace();
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseIdentifier(buf.writer());
const key = try buf.toOwnedSlice();
errdefer alloc.free(key);
lowerstr(key);
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
if (p.s[p.i] == ']') {
p.i += 1;
return .{ .attribute = .{ .key = key } };
}
if (p.i + 2 >= p.s.len) return ParseError.ExpectedAttributeSelector;
const op = try parseAttributeOP(p.s[p.i .. p.i + 2]);
p.i += op.len();
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
buf.clearRetainingCapacity();
var is_val: bool = undefined;
if (op == .regexp) {
is_val = false;
try p.parseRegex(buf.writer());
} else {
is_val = true;
switch (p.s[p.i]) {
'\'', '"' => try p.parseString(buf.writer()),
else => try p.parseIdentifier(buf.writer()),
}
}
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
// check if the attribute contains an ignore case flag
var ci = false;
if (p.s[p.i] == 'i' or p.s[p.i] == 'I') {
ci = true;
p.i += 1;
}
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
if (p.s[p.i] != ']') return ParseError.InvalidAttributeSelector;
p.i += 1;
return .{ .attribute = .{
.key = key,
.val = if (is_val) try buf.toOwnedSlice() else null,
.regexp = if (!is_val) try buf.toOwnedSlice() else null,
.op = op,
.ci = ci,
} };
}
// parseString parses a single- or double-quoted string.
fn parseString(p: *Parser, writer: anytype) ParseError!void {
const sel = p.s;
const sel_len = sel.len;
var i = p.i;
if (sel_len < i + 2) return ParseError.ExpectedString;
const quote = sel[i];
i += 1;
loop: while (i < sel_len) {
switch (sel[i]) {
'\\' => {
if (sel_len > i + 1) {
const c = sel[i + 1];
switch (c) {
'\r' => {
if (sel_len > i + 2 and sel[i + 2] == '\n') {
i += 3;
continue :loop;
}
i += 2;
continue :loop;
},
'\n', std.ascii.control_code.ff => {
i += 2;
continue :loop;
},
else => {},
}
}
p.i = i;
try p.parseEscape(writer);
i = p.i;
},
'\r', '\n', std.ascii.control_code.ff => return ParseError.InvalidString,
else => |c| {
if (c == quote) break :loop;
const start = i;
while (i < sel_len) {
const cc = sel[i];
if (cc == quote or cc == '\\' or c == '\r' or c == '\n' or c == std.ascii.control_code.ff) break;
i += 1;
}
writer.writeAll(sel[start..i]) catch return ParseError.WriteError;
},
}
}
if (i >= sel_len) return ParseError.InvalidString;
// Consume the final quote.
i += 1;
p.i = i;
}
// parseRegex parses a regular expression; the end is defined by encountering an
// unmatched closing ')' or ']' which is not consumed
fn parseRegex(p: *Parser, writer: anytype) ParseError!void {
var i = p.i;
if (p.s.len < i + 2) return ParseError.ExpectedRegexp;
// number of open parens or brackets;
// when it becomes negative, finished parsing regex
var open: isize = 0;
loop: while (i < p.s.len) {
switch (p.s[i]) {
'(', '[' => open += 1,
')', ']' => {
open -= 1;
if (open < 0) break :loop;
},
else => {},
}
i += 1;
}
if (i >= p.s.len) return ParseError.InvalidRegexp;
writer.writeAll(p.s[p.i..i]) catch return ParseError.WriteError;
p.i = i;
}
// parsePseudoclassSelector parses a pseudoclass selector like :not(p) or a pseudo-element
// For backwards compatibility, both ':' and '::' prefix are allowed for pseudo-elements.
// https://drafts.csswg.org/selectors-3/#pseudo-elements
fn parsePseudoclassSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
if (p.i >= p.s.len) return ParseError.ExpectedPseudoClassSelector;
if (p.s[p.i] != ':') return ParseError.ExpectedPseudoClassSelector;
p.i += 1;
var must_pseudo_elt: bool = false;
if (p.i >= p.s.len) return ParseError.EmptyPseudoClassSelector;
if (p.s[p.i] == ':') { // we found a pseudo-element
must_pseudo_elt = true;
p.i += 1;
}
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseIdentifier(buf.writer());
const pseudo_class = try PseudoClass.parse(buf.items);
// reset the buffer to reuse it.
buf.clearRetainingCapacity();
if (must_pseudo_elt and !pseudo_class.isPseudoElement()) return ParseError.InvalidPseudoElement;
switch (pseudo_class) {
.not, .has, .haschild => {
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
const sel = try p.parseSelectorGroup(alloc);
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
const s = try alloc.create(Selector);
errdefer alloc.destroy(s);
s.* = sel;
return .{ .pseudo_class_relative = .{ .pseudo_class = pseudo_class, .match = s } };
},
.contains, .containsown => {
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
if (p.i == p.s.len) return ParseError.UnmatchParenthesis;
switch (p.s[p.i]) {
'\'', '"' => try p.parseString(buf.writer()),
else => try p.parseString(buf.writer()),
}
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.InvalidPseudoClass;
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
const val = try buf.toOwnedSlice();
errdefer alloc.free(val);
lowerstr(val);
return .{ .pseudo_class_contains = .{ .own = pseudo_class == .containsown, .val = val } };
},
.matches, .matchesown => {
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
try p.parseRegex(buf.writer());
if (p.i >= p.s.len) return ParseError.InvalidPseudoClassSelector;
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
return .{ .pseudo_class_regexp = .{ .own = pseudo_class == .matchesown, .regexp = try buf.toOwnedSlice() } };
},
.nth_child, .nth_last_child, .nth_of_type, .nth_last_of_type => {
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
const nth = try p.parseNth(alloc);
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
const last = pseudo_class == .nth_last_child or pseudo_class == .nth_last_of_type;
const of_type = pseudo_class == .nth_of_type or pseudo_class == .nth_last_of_type;
return .{ .pseudo_class_nth = .{ .a = nth[0], .b = nth[1], .of_type = of_type, .last = last } };
},
.first_child => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = false, .last = false } },
.last_child => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = false, .last = true } },
.first_of_type => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = true, .last = false } },
.last_of_type => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = true, .last = true } },
.only_child => return .{ .pseudo_class_only_child = false },
.only_of_type => return .{ .pseudo_class_only_child = true },
.input, .empty, .root, .link => return .{ .pseudo_class = pseudo_class },
.enabled, .disabled, .checked => return .{ .pseudo_class = pseudo_class },
.lang => {
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
if (p.i == p.s.len) return ParseError.UnmatchParenthesis;
try p.parseIdentifier(buf.writer());
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.InvalidPseudoClass;
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
const val = try buf.toOwnedSlice();
errdefer alloc.free(val);
lowerstr(val);
return .{ .pseudo_class_lang = val };
},
.visited, .hover, .active, .focus, .target => {
// Not applicable in a static context: never match.
return .{ .never_match = pseudo_class };
},
.after, .backdrop, .before, .cue, .first_letter => return .{ .pseudo_element = pseudo_class },
.first_line, .grammar_error, .marker, .placeholder => return .{ .pseudo_element = pseudo_class },
.selection, .spelling_error => return .{ .pseudo_element = pseudo_class },
.modal => return .{ .pseudo_element = pseudo_class },
}
}
// consumeParenthesis consumes an opening parenthesis and any following
// whitespace. It returns true if there was actually a parenthesis to skip.
fn consumeParenthesis(p: *Parser) bool {
if (p.i < p.s.len and p.s[p.i] == '(') {
p.i += 1;
_ = p.skipWhitespace();
return true;
}
return false;
}
// parseSelectorGroup parses a group of selectors, separated by commas.
fn parseSelectorGroup(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
const s = try p.parseSelector(alloc);
var buf = std.ArrayList(Selector).init(alloc);
defer buf.deinit();
try buf.append(s);
while (p.i < p.s.len) {
if (p.s[p.i] != ',') break;
p.i += 1;
const ss = try p.parseSelector(alloc);
try buf.append(ss);
}
if (buf.items.len == 1) return buf.items[0];
return .{ .group = try buf.toOwnedSlice() };
}
// parseSelector parses a selector that may include combinators.
fn parseSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
_ = p.skipWhitespace();
var s = try p.parseSimpleSelectorSequence(alloc);
while (true) {
var combinator: Combinator = .empty;
if (p.skipWhitespace()) {
combinator = .descendant;
}
if (p.i >= p.s.len) {
return s;
}
switch (p.s[p.i]) {
'+', '>', '~' => {
combinator = try Combinator.parse(p.s[p.i]);
p.i += 1;
_ = p.skipWhitespace();
},
// These characters can't begin a selector, but they can legally occur after one.
',', ')' => {
return s;
},
else => {},
}
if (combinator == .empty) {
return s;
}
const c = try p.parseSimpleSelectorSequence(alloc);
const first = try alloc.create(Selector);
errdefer alloc.destroy(first);
first.* = s;
const second = try alloc.create(Selector);
errdefer alloc.destroy(second);
second.* = c;
s = Selector{ .combined = .{ .first = first, .second = second, .combinator = combinator } };
}
return s;
}
// consumeClosingParenthesis consumes a closing parenthesis and any preceding
// whitespace. It returns true if there was actually a parenthesis to skip.
fn consumeClosingParenthesis(p: *Parser) bool {
const i = p.i;
_ = p.skipWhitespace();
if (p.i < p.s.len and p.s[p.i] == ')') {
p.i += 1;
return true;
}
p.i = i;
return false;
}
// parseInteger parses a decimal integer.
fn parseInteger(p: *Parser) ParseError!isize {
var i = p.i;
const start = i;
while (i < p.s.len and '0' <= p.s[i] and p.s[i] <= '9') i += 1;
if (i == start) return ParseError.ExpectedInteger;
p.i = i;
return std.fmt.parseUnsigned(isize, p.s[start..i], 10) catch ParseError.ExpectedInteger;
}
fn parseNthReadN(p: *Parser, a: isize) ParseError![2]isize {
_ = p.skipWhitespace();
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
return switch (p.s[p.i]) {
'+' => {
p.i += 1;
_ = p.skipWhitespace();
const b = try p.parseInteger();
return .{ a, b };
},
'-' => {
p.i += 1;
_ = p.skipWhitespace();
const b = try p.parseInteger();
return .{ a, -b };
},
else => .{ a, 0 },
};
}
fn parseNthReadA(p: *Parser, a: isize) ParseError![2]isize {
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
return switch (p.s[p.i]) {
'n', 'N' => {
p.i += 1;
return p.parseNthReadN(a);
},
else => .{ 0, a },
};
}
fn parseNthNegativeA(p: *Parser) ParseError![2]isize {
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
const c = p.s[p.i];
if (std.ascii.isDigit(c)) {
const a = try p.parseInteger() * -1;
return p.parseNthReadA(a);
}
if (c == 'n' or c == 'N') {
p.i += 1;
return p.parseNthReadN(-1);
}
return ParseError.InvalidNthExpression;
}
fn parseNthPositiveA(p: *Parser) ParseError![2]isize {
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
const c = p.s[p.i];
if (std.ascii.isDigit(c)) {
const a = try p.parseInteger();
return p.parseNthReadA(a);
}
if (c == 'n' or c == 'N') {
p.i += 1;
return p.parseNthReadN(1);
}
return ParseError.InvalidNthExpression;
}
// parseNth parses the argument for :nth-child (normally of the form an+b).
fn parseNth(p: *Parser, alloc: std.mem.Allocator) ParseError![2]isize {
// initial state
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
return switch (p.s[p.i]) {
'-' => {
p.i += 1;
return p.parseNthNegativeA();
},
'+' => {
p.i += 1;
return p.parseNthPositiveA();
},
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => p.parseNthPositiveA(),
'n', 'N' => {
p.i += 1;
return p.parseNthReadN(1);
},
'o', 'O', 'e', 'E' => {
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
try p.parseName(buf.writer());
if (std.ascii.eqlIgnoreCase("odd", buf.items)) return .{ 2, 1 };
if (std.ascii.eqlIgnoreCase("even", buf.items)) return .{ 2, 0 };
return ParseError.InvalidNthExpression;
},
else => ParseError.InvalidNthExpression,
};
}
};
// nameStart returns whether c can be the first character of an identifier
// (not counting an initial hyphen, or an escape sequence).
fn nameStart(c: u8) bool {
return 'a' <= c and c <= 'z' or 'A' <= c and c <= 'Z' or c == '_' or c > 127;
}
// nameChar returns whether c can be a character within an identifier
// (not counting an escape sequence).
fn nameChar(c: u8) bool {
return 'a' <= c and c <= 'z' or 'A' <= c and c <= 'Z' or c == '_' or c > 127 or
c == '-' or '0' <= c and c <= '9';
}
fn lowerstr(str: []u8) void {
for (str, 0..) |c, i| {
str[i] = std.ascii.toLower(c);
}
}
// parseAttributeOP parses an AttributeOP from a string of 1 or 2 bytes.
fn parseAttributeOP(s: []const u8) ParseError!AttributeOP {
if (s.len < 1 or s.len > 2) return ParseError.InvalidAttributeOperator;
// if the first sign is equal, we don't check anything else.
if (s[0] == '=') return .eql;
if (s.len != 2 or s[1] != '=') return ParseError.InvalidAttributeOperator;
return switch (s[0]) {
'=' => .eql,
'!' => .not_eql,
'~' => .one_of,
'|' => .prefix_hyphen,
'^' => .prefix,
'$' => .suffix,
'*' => .contains,
'#' => .regexp,
else => ParseError.InvalidAttributeOperator,
};
}
test "parser.skipWhitespace" {
const testcases = [_]struct {
s: []const u8,
i: usize,
r: bool,
}{
.{ .s = "", .i = 0, .r = false },
.{ .s = "foo", .i = 0, .r = false },
.{ .s = " ", .i = 1, .r = true },
.{ .s = " foo", .i = 1, .r = true },
.{ .s = "/* foo */ bar", .i = 10, .r = true },
.{ .s = "/* foo", .i = 0, .r = false },
};
for (testcases) |tc| {
var p = Parser{ .s = tc.s, .opts = .{} };
const res = p.skipWhitespace();
try std.testing.expectEqual(tc.r, res);
try std.testing.expectEqual(tc.i, p.i);
}
}
test "parser.parseIdentifier" {
const alloc = std.testing.allocator;
const testcases = [_]struct {
s: []const u8, // given value
exp: []const u8, // expected value
err: bool = false,
}{
.{ .s = "x", .exp = "x" },
.{ .s = "96", .exp = "", .err = true },
.{ .s = "-x", .exp = "-x" },
.{ .s = "r\\e9 sumé", .exp = "résumé" },
.{ .s = "r\\0000e9 sumé", .exp = "résumé" },
.{ .s = "r\\0000e9sumé", .exp = "résumé" },
.{ .s = "a\\\"b", .exp = "a\"b" },
};
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
for (testcases) |tc| {
buf.clearRetainingCapacity();
var p = Parser{ .s = tc.s, .opts = .{} };
p.parseIdentifier(buf.writer()) catch |e| {
// if error was expected, continue.
if (tc.err) continue;
std.debug.print("test case {s}\n", .{tc.s});
return e;
};
std.testing.expectEqualDeep(tc.exp, buf.items) catch |e| {
std.debug.print("test case {s} : {s}\n", .{ tc.s, buf.items });
return e;
};
}
}
test "parser.parseString" {
const alloc = std.testing.allocator;
const testcases = [_]struct {
s: []const u8, // given value
exp: []const u8, // expected value
err: bool = false,
}{
.{ .s = "\"x\"", .exp = "x" },
.{ .s = "'x'", .exp = "x" },
.{ .s = "'x", .exp = "", .err = true },
.{ .s = "'x\\\r\nx'", .exp = "xx" },
.{ .s = "\"r\\e9 sumé\"", .exp = "résumé" },
.{ .s = "\"r\\0000e9 sumé\"", .exp = "résumé" },
.{ .s = "\"r\\0000e9sumé\"", .exp = "résumé" },
.{ .s = "\"a\\\"b\"", .exp = "a\"b" },
.{ .s = "\"\\\n\"", .exp = "" },
.{ .s = "\"hello world\"", .exp = "hello world" },
};
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
for (testcases) |tc| {
buf.clearRetainingCapacity();
var p = Parser{ .s = tc.s, .opts = .{} };
p.parseString(buf.writer()) catch |e| {
// if error was expected, continue.
if (tc.err) continue;
std.debug.print("test case {s}\n", .{tc.s});
return e;
};
std.testing.expectEqualDeep(tc.exp, buf.items) catch |e| {
std.debug.print("test case {s} : {s}\n", .{ tc.s, buf.items });
return e;
};
}
}

View File

@@ -1,769 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
pub const AttributeOP = enum {
eql, // =
not_eql, // !=
one_of, // ~=
prefix_hyphen, // |=
prefix, // ^=
suffix, // $=
contains, // *=
regexp, // #=
pub fn len(op: AttributeOP) u2 {
if (op == .eql) return 1;
return 2;
}
};
pub const Combinator = enum {
empty,
descendant, // space
child, // >
next_sibling, // +
subsequent_sibling, // ~
pub const Error = error{
InvalidCombinator,
};
pub fn parse(c: u8) Error!Combinator {
return switch (c) {
' ' => .descendant,
'>' => .child,
'+' => .next_sibling,
'~' => .subsequent_sibling,
else => Error.InvalidCombinator,
};
}
};
pub const PseudoClass = enum {
not,
has,
haschild,
contains,
containsown,
matches,
matchesown,
nth_child,
nth_last_child,
nth_of_type,
nth_last_of_type,
first_child,
last_child,
first_of_type,
last_of_type,
only_child,
only_of_type,
input,
empty,
root,
link,
lang,
enabled,
disabled,
checked,
visited,
hover,
active,
focus,
target,
after,
backdrop,
before,
cue,
first_letter,
first_line,
grammar_error,
marker,
placeholder,
selection,
spelling_error,
modal,
pub const Error = error{
InvalidPseudoClass,
};
pub fn isPseudoElement(pc: PseudoClass) bool {
return switch (pc) {
.after, .backdrop, .before, .cue, .first_letter => true,
.first_line, .grammar_error, .marker, .placeholder => true,
.selection, .spelling_error => true,
else => false,
};
}
pub fn parse(s: []const u8) Error!PseudoClass {
if (std.ascii.eqlIgnoreCase(s, "not")) return .not;
if (std.ascii.eqlIgnoreCase(s, "has")) return .has;
if (std.ascii.eqlIgnoreCase(s, "haschild")) return .haschild;
if (std.ascii.eqlIgnoreCase(s, "contains")) return .contains;
if (std.ascii.eqlIgnoreCase(s, "containsown")) return .containsown;
if (std.ascii.eqlIgnoreCase(s, "matches")) return .matches;
if (std.ascii.eqlIgnoreCase(s, "matchesown")) return .matchesown;
if (std.ascii.eqlIgnoreCase(s, "nth-child")) return .nth_child;
if (std.ascii.eqlIgnoreCase(s, "nth-last-child")) return .nth_last_child;
if (std.ascii.eqlIgnoreCase(s, "nth-of-type")) return .nth_of_type;
if (std.ascii.eqlIgnoreCase(s, "nth-last-of-type")) return .nth_last_of_type;
if (std.ascii.eqlIgnoreCase(s, "first-child")) return .first_child;
if (std.ascii.eqlIgnoreCase(s, "last-child")) return .last_child;
if (std.ascii.eqlIgnoreCase(s, "first-of-type")) return .first_of_type;
if (std.ascii.eqlIgnoreCase(s, "last-of-type")) return .last_of_type;
if (std.ascii.eqlIgnoreCase(s, "only-child")) return .only_child;
if (std.ascii.eqlIgnoreCase(s, "only-of-type")) return .only_of_type;
if (std.ascii.eqlIgnoreCase(s, "input")) return .input;
if (std.ascii.eqlIgnoreCase(s, "empty")) return .empty;
if (std.ascii.eqlIgnoreCase(s, "root")) return .root;
if (std.ascii.eqlIgnoreCase(s, "link")) return .link;
if (std.ascii.eqlIgnoreCase(s, "lang")) return .lang;
if (std.ascii.eqlIgnoreCase(s, "enabled")) return .enabled;
if (std.ascii.eqlIgnoreCase(s, "disabled")) return .disabled;
if (std.ascii.eqlIgnoreCase(s, "checked")) return .checked;
if (std.ascii.eqlIgnoreCase(s, "visited")) return .visited;
if (std.ascii.eqlIgnoreCase(s, "hover")) return .hover;
if (std.ascii.eqlIgnoreCase(s, "active")) return .active;
if (std.ascii.eqlIgnoreCase(s, "focus")) return .focus;
if (std.ascii.eqlIgnoreCase(s, "target")) return .target;
if (std.ascii.eqlIgnoreCase(s, "after")) return .after;
if (std.ascii.eqlIgnoreCase(s, "backdrop")) return .backdrop;
if (std.ascii.eqlIgnoreCase(s, "before")) return .before;
if (std.ascii.eqlIgnoreCase(s, "cue")) return .cue;
if (std.ascii.eqlIgnoreCase(s, "first-letter")) return .first_letter;
if (std.ascii.eqlIgnoreCase(s, "first-line")) return .first_line;
if (std.ascii.eqlIgnoreCase(s, "grammar-error")) return .grammar_error;
if (std.ascii.eqlIgnoreCase(s, "marker")) return .marker;
if (std.ascii.eqlIgnoreCase(s, "placeholder")) return .placeholder;
if (std.ascii.eqlIgnoreCase(s, "selection")) return .selection;
if (std.ascii.eqlIgnoreCase(s, "spelling-error")) return .spelling_error;
if (std.ascii.eqlIgnoreCase(s, "modal")) return .modal;
return Error.InvalidPseudoClass;
}
};
pub const Selector = union(enum) {
pub const Error = error{
UnknownCombinedCombinator,
UnsupportedRelativePseudoClass,
UnsupportedContainsPseudoClass,
UnsupportedPseudoClass,
UnsupportedPseudoElement,
UnsupportedRegexpPseudoClass,
UnsupportedAttrRegexpOperator,
};
compound: struct {
selectors: []Selector,
pseudo_elt: ?PseudoClass,
},
group: []Selector,
tag: []const u8,
id: []const u8,
class: []const u8,
attribute: struct {
key: []const u8,
val: ?[]const u8 = null,
op: ?AttributeOP = null,
regexp: ?[]const u8 = null,
ci: bool = false,
},
combined: struct {
first: *Selector,
second: *Selector,
combinator: Combinator,
},
never_match: PseudoClass,
pseudo_class: PseudoClass,
pseudo_class_only_child: bool,
pseudo_class_lang: []const u8,
pseudo_class_relative: struct {
pseudo_class: PseudoClass,
match: *Selector,
},
pseudo_class_contains: struct {
own: bool,
val: []const u8,
},
pseudo_class_regexp: struct {
own: bool,
regexp: []const u8,
},
pseudo_class_nth: struct {
a: isize,
b: isize,
of_type: bool,
last: bool,
},
pseudo_element: PseudoClass,
// returns true if s is a whitespace-separated list that includes val.
fn word(haystack: []const u8, needle: []const u8, ci: bool) bool {
if (haystack.len == 0) return false;
var it = std.mem.splitAny(u8, haystack, " \t\r\n"); // TODO add \f
while (it.next()) |part| {
if (eql(part, needle, ci)) return true;
}
return false;
}
fn eql(a: []const u8, b: []const u8, ci: bool) bool {
if (ci) return std.ascii.eqlIgnoreCase(a, b);
return std.mem.eql(u8, a, b);
}
fn starts(haystack: []const u8, needle: []const u8, ci: bool) bool {
if (ci) return std.ascii.startsWithIgnoreCase(haystack, needle);
return std.mem.startsWith(u8, haystack, needle);
}
fn ends(haystack: []const u8, needle: []const u8, ci: bool) bool {
if (ci) return std.ascii.endsWithIgnoreCase(haystack, needle);
return std.mem.endsWith(u8, haystack, needle);
}
fn contains(haystack: []const u8, needle: []const u8, ci: bool) bool {
if (ci) return std.ascii.indexOfIgnoreCase(haystack, needle) != null;
return std.mem.indexOf(u8, haystack, needle) != null;
}
// match returns true if the node matches the selector query.
pub fn match(s: Selector, n: anytype) !bool {
return switch (s) {
.tag => |v| n.isElement() and std.ascii.eqlIgnoreCase(v, try n.tag()),
.id => |v| return n.isElement() and std.mem.eql(u8, v, try n.attr("id") orelse return false),
.class => |v| return n.isElement() and word(try n.attr("class") orelse return false, v, false),
.group => |v| {
for (v) |sel| {
if (try sel.match(n)) return true;
}
return false;
},
.compound => |v| {
if (v.selectors.len == 0) return n.isElement();
for (v.selectors) |sel| {
if (!try sel.match(n)) return false;
}
return true;
},
.combined => |v| {
return switch (v.combinator) {
.empty => try v.first.match(n),
.descendant => {
if (!try v.second.match(n)) return false;
// The first must match a ascendent.
var p = try n.parent();
while (p != null) {
if (try v.first.match(p.?)) {
return true;
}
p = try p.?.parent();
}
return false;
},
.child => {
const p = try n.parent();
if (p == null) return false;
return try v.second.match(n) and try v.first.match(p.?);
},
.next_sibling => {
if (!try v.second.match(n)) return false;
var c = try n.prevSibling();
while (c != null) {
if (c.?.isText() or c.?.isComment()) {
c = try c.?.prevSibling();
continue;
}
return try v.first.match(c.?);
}
return false;
},
.subsequent_sibling => {
if (!try v.second.match(n)) return false;
var c = try n.prevSibling();
while (c != null) {
if (try v.first.match(c.?)) return true;
c = try c.?.prevSibling();
}
return false;
},
};
},
.attribute => |v| {
var attr = try n.attr(v.key);
if (v.op == null) return attr != null;
if (v.val == null or v.val.?.len == 0) return false;
const val = v.val.?;
return switch (v.op.?) {
.eql => attr != null and eql(attr.?, val, v.ci),
.not_eql => attr == null or !eql(attr.?, val, v.ci),
.one_of => attr != null and word(attr.?, val, v.ci),
.prefix => {
if (attr == null) return false;
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
if (attr.?.len == 0) return false;
return starts(attr.?, val, v.ci);
},
.suffix => {
if (attr == null) return false;
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
if (attr.?.len == 0) return false;
return ends(attr.?, val, v.ci);
},
.contains => {
if (attr == null) return false;
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
if (attr.?.len == 0) return false;
return contains(attr.?, val, v.ci);
},
.prefix_hyphen => {
if (attr == null) return false;
if (eql(attr.?, val, v.ci)) return true;
if (attr.?.len <= val.len) return false;
if (!starts(attr.?, val, v.ci)) return false;
return attr.?[val.len] == '-';
},
.regexp => return Error.UnsupportedAttrRegexpOperator, // TODO handle regexp attribute operator.
};
},
.never_match => return false,
.pseudo_class_relative => |v| {
if (!n.isElement()) return false;
return switch (v.pseudo_class) {
.not => !try v.match.match(n),
.has => try hasDescendantMatch(v.match, n),
.haschild => try hasChildMatch(v.match, n),
else => Error.UnsupportedRelativePseudoClass,
};
},
.pseudo_class_contains => return Error.UnsupportedContainsPseudoClass, // TODO, need mem allocation.
.pseudo_class_regexp => return Error.UnsupportedRegexpPseudoClass, // TODO need mem allocation.
.pseudo_class_nth => |v| {
if (v.a == 0) {
if (v.last) {
return simpleNthLastChildMatch(v.b, v.of_type, n);
}
return simpleNthChildMatch(v.b, v.of_type, n);
}
return nthChildMatch(v.a, v.b, v.last, v.of_type, n);
},
.pseudo_class => |v| {
return switch (v) {
.input => {
if (!n.isElement()) return false;
const ntag = try n.tag();
return std.ascii.eqlIgnoreCase("input", ntag) or
std.ascii.eqlIgnoreCase("select", ntag) or
std.ascii.eqlIgnoreCase("button", ntag) or
std.ascii.eqlIgnoreCase("textarea", ntag);
},
.empty => {
if (!n.isElement()) return false;
var c = try n.firstChild();
while (c != null) {
if (c.?.isElement()) return false;
if (c.?.isText()) {
if (try c.?.isEmptyText()) continue;
return false;
}
c = try c.?.nextSibling();
}
return true;
},
.root => {
if (!n.isElement()) return false;
const p = try n.parent();
return (p != null and p.?.isDocument());
},
.link => {
const ntag = try n.tag();
return std.ascii.eqlIgnoreCase("a", ntag) or
std.ascii.eqlIgnoreCase("area", ntag) or
std.ascii.eqlIgnoreCase("link", ntag);
},
.enabled => {
if (!n.isElement()) return false;
const ntag = try n.tag();
if (std.ascii.eqlIgnoreCase("a", ntag) or
std.ascii.eqlIgnoreCase("area", ntag) or
std.ascii.eqlIgnoreCase("link", ntag))
{
return try n.attr("href") != null;
}
if (std.ascii.eqlIgnoreCase("optgroup", ntag) or
std.ascii.eqlIgnoreCase("menuitem", ntag) or
std.ascii.eqlIgnoreCase("fieldset", ntag))
{
return try n.attr("disabled") == null;
}
if (std.ascii.eqlIgnoreCase("input", ntag) or
std.ascii.eqlIgnoreCase("button", ntag) or
std.ascii.eqlIgnoreCase("select", ntag) or
std.ascii.eqlIgnoreCase("textarea", ntag) or
std.ascii.eqlIgnoreCase("option", ntag))
{
return try n.attr("disabled") == null and
!try inDisabledFieldset(n);
}
return false;
},
.disabled => {
if (!n.isElement()) return false;
const ntag = try n.tag();
if (std.ascii.eqlIgnoreCase("optgroup", ntag) or
std.ascii.eqlIgnoreCase("menuitem", ntag) or
std.ascii.eqlIgnoreCase("fieldset", ntag))
{
return try n.attr("disabled") != null;
}
if (std.ascii.eqlIgnoreCase("input", ntag) or
std.ascii.eqlIgnoreCase("button", ntag) or
std.ascii.eqlIgnoreCase("select", ntag) or
std.ascii.eqlIgnoreCase("textarea", ntag) or
std.ascii.eqlIgnoreCase("option", ntag))
{
return try n.attr("disabled") != null or
try inDisabledFieldset(n);
}
return false;
},
.checked => {
if (!n.isElement()) return false;
const ntag = try n.tag();
if (std.ascii.eqlIgnoreCase("intput", ntag)) {
const ntype = try n.attr("type");
if (ntype == null) return false;
if (std.mem.eql(u8, ntype.?, "checkbox") or
std.mem.eql(u8, ntype.?, "radio"))
{
return try n.attr("checked") != null;
}
return false;
}
if (std.ascii.eqlIgnoreCase("option", ntag)) {
return try n.attr("selected") != null;
}
return false;
},
.visited => return false,
.hover => return false,
.active => return false,
.focus => return false,
// TODO implement using the url fragment.
// see https://developer.mozilla.org/en-US/docs/Web/CSS/:target
.target => return false,
// all others pseudo class are handled by specialized
// pseudo_class_X selectors.
else => return Error.UnsupportedPseudoClass,
};
},
.pseudo_class_only_child => |v| onlyChildMatch(v, n),
.pseudo_class_lang => |v| langMatch(v, n),
// pseudo elements doesn't make sense in the matching process.
// > A CSS pseudo-element is a keyword added to a selector that
// > lets you style a specific part of the selected element(s).
// https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements
.pseudo_element => return Error.UnsupportedPseudoElement,
};
}
fn hasLegendInPreviousSiblings(n: anytype) anyerror!bool {
var c = try n.prevSibling();
while (c != null) {
const ctag = try c.?.tag();
if (std.ascii.eqlIgnoreCase("legend", ctag)) return true;
c = try c.?.prevSibling();
}
return false;
}
fn inDisabledFieldset(n: anytype) anyerror!bool {
const p = try n.parent();
if (p == null) return false;
const ntag = try n.tag();
const ptag = try p.?.tag();
if (std.ascii.eqlIgnoreCase("fieldset", ptag) and
try p.?.attr("disabled") != null and
(!std.ascii.eqlIgnoreCase("legend", ntag) or try hasLegendInPreviousSiblings(n)))
{
return true;
}
// TODO should we handle legend like cascadia does?
// The implemention below looks suspicious, I didn't find a test case
// in cascadia and I didn't find the reference about legend in the
// specs. For now I do prefer ignoring this part.
//
// ```
// (n.DataAtom != atom.Legend || hasLegendInPreviousSiblings(n)) {
// ```
// https://github.com/andybalholm/cascadia/blob/master/pseudo_classes.go#L434
return try inDisabledFieldset(p.?);
}
fn langMatch(lang: []const u8, n: anytype) anyerror!bool {
if (try n.attr("lang")) |own| {
if (std.mem.eql(u8, own, lang)) return true;
// check if the lang attr starts with lang+'-'
if (std.mem.startsWith(u8, own, lang)) {
if (own.len > lang.len and own[lang.len] == '-') return true;
}
}
// if the tag doesn't match, try the parent.
const p = try n.parent();
if (p == null) return false;
return langMatch(lang, p.?);
}
// onlyChildMatch implements :only-child
// If `ofType` is true, it implements :only-of-type instead.
fn onlyChildMatch(of_type: bool, n: anytype) anyerror!bool {
if (!n.isElement()) return false;
const p = try n.parent();
if (p == null) return false;
const ntag = try n.tag();
var count: usize = 0;
var c = try p.?.firstChild();
// loop hover all n siblings.
while (c != null) {
// ignore non elements or others tags if of-type is true.
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
c = try c.?.nextSibling();
continue;
}
count += 1;
if (count > 1) return false;
c = try c.?.nextSibling();
}
return count == 1;
}
// simpleNthLastChildMatch implements :nth-last-child(b).
// If ofType is true, implements :nth-last-of-type instead.
fn simpleNthLastChildMatch(b: isize, of_type: bool, n: anytype) anyerror!bool {
if (!n.isElement()) return false;
const p = try n.parent();
if (p == null) return false;
const ntag = try n.tag();
var count: isize = 0;
var c = try p.?.lastChild();
// loop hover all n siblings.
while (c != null) {
// ignore non elements or others tags if of-type is true.
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
c = try c.?.prevSibling();
continue;
}
count += 1;
if (n.eql(c.?)) return count == b;
if (count >= b) return false;
c = try c.?.prevSibling();
}
return false;
}
// simpleNthChildMatch implements :nth-child(b).
// If ofType is true, implements :nth-of-type instead.
fn simpleNthChildMatch(b: isize, of_type: bool, n: anytype) anyerror!bool {
if (!n.isElement()) return false;
const p = try n.parent();
if (p == null) return false;
const ntag = try n.tag();
var count: isize = 0;
var c = try p.?.firstChild();
// loop hover all n siblings.
while (c != null) {
// ignore non elements or others tags if of-type is true.
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
c = try c.?.nextSibling();
continue;
}
count += 1;
if (n.eql(c.?)) return count == b;
if (count >= b) return false;
c = try c.?.nextSibling();
}
return false;
}
// nthChildMatch implements :nth-child(an+b).
// If last is true, implements :nth-last-child instead.
// If ofType is true, implements :nth-of-type instead.
fn nthChildMatch(a: isize, b: isize, last: bool, of_type: bool, n: anytype) anyerror!bool {
if (!n.isElement()) return false;
const p = try n.parent();
if (p == null) return false;
const ntag = try n.tag();
var i: isize = -1;
var count: isize = 0;
var c = try p.?.firstChild();
// loop hover all n siblings.
while (c != null) {
// ignore non elements or others tags if of-type is true.
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
c = try c.?.nextSibling();
continue;
}
count += 1;
if (n.eql(c.?)) {
i = count;
if (!last) break;
}
c = try c.?.nextSibling();
}
if (i == -1) return false;
if (last) i = count - i + 1;
i -= b;
if (a == 0) return i == 0;
return @mod(i, a) == 0 and @divTrunc(i, a) >= 0;
}
fn hasDescendantMatch(s: *const Selector, n: anytype) anyerror!bool {
var c = try n.firstChild();
while (c != null) {
if (try s.match(c.?)) return true;
if (c.?.isElement() and try hasDescendantMatch(s, c.?)) return true;
c = try c.?.nextSibling();
}
return false;
}
fn hasChildMatch(s: *const Selector, n: anytype) anyerror!bool {
var c = try n.firstChild();
while (c != null) {
if (try s.match(c.?)) return true;
c = try c.?.nextSibling();
}
return false;
}
pub fn deinit(sel: Selector, alloc: std.mem.Allocator) void {
switch (sel) {
.group => |v| {
for (v) |vv| vv.deinit(alloc);
alloc.free(v);
},
.compound => |v| {
for (v.selectors) |vv| vv.deinit(alloc);
alloc.free(v.selectors);
},
.tag, .id, .class, .pseudo_class_lang => |v| alloc.free(v),
.attribute => |att| {
alloc.free(att.key);
if (att.val) |v| alloc.free(v);
if (att.regexp) |v| alloc.free(v);
},
.combined => |c| {
c.first.deinit(alloc);
alloc.destroy(c.first);
c.second.deinit(alloc);
alloc.destroy(c.second);
},
.pseudo_class_relative => |v| {
v.match.deinit(alloc);
alloc.destroy(v.match);
},
.pseudo_class_contains => |v| alloc.free(v.val),
.pseudo_class_regexp => |v| alloc.free(v.regexp),
.pseudo_class, .pseudo_element, .never_match => {},
.pseudo_class_nth, .pseudo_class_only_child => {},
}
}
};

View File

@@ -1,291 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const CSSConstants = struct {
const IMPORTANT = "!important";
const URL_PREFIX = "url(";
};
pub const CSSParserState = enum {
seek_name,
in_name,
seek_colon,
seek_value,
in_value,
in_quoted_value,
in_single_quoted_value,
in_url,
in_important,
};
pub const CSSDeclaration = struct {
name: []const u8,
value: []const u8,
is_important: bool,
};
pub const CSSParser = struct {
state: CSSParserState,
name_start: usize,
name_end: usize,
value_start: usize,
position: usize,
paren_depth: usize,
escape_next: bool,
pub fn init() CSSParser {
return .{
.state = .seek_name,
.name_start = 0,
.name_end = 0,
.value_start = 0,
.position = 0,
.paren_depth = 0,
.escape_next = false,
};
}
pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration {
var parser = init();
var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty;
while (parser.position < text.len) {
const c = text[parser.position];
switch (parser.state) {
.seek_name => {
if (!std.ascii.isWhitespace(c)) {
parser.name_start = parser.position;
parser.state = .in_name;
continue;
}
},
.in_name => {
if (c == ':') {
parser.name_end = parser.position;
parser.state = .seek_value;
} else if (std.ascii.isWhitespace(c)) {
parser.name_end = parser.position;
parser.state = .seek_colon;
}
},
.seek_colon => {
if (c == ':') {
parser.state = .seek_value;
} else if (!std.ascii.isWhitespace(c)) {
parser.state = .seek_name;
continue;
}
},
.seek_value => {
if (!std.ascii.isWhitespace(c)) {
parser.value_start = parser.position;
if (c == '"') {
parser.state = .in_quoted_value;
} else if (c == '\'') {
parser.state = .in_single_quoted_value;
} else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) {
parser.state = .in_url;
parser.paren_depth = 1;
parser.position += 3;
} else {
parser.state = .in_value;
continue;
}
}
},
.in_value => {
if (parser.escape_next) {
parser.escape_next = false;
} else if (c == '\\') {
parser.escape_next = true;
} else if (c == '(') {
parser.paren_depth += 1;
} else if (c == ')' and parser.paren_depth > 0) {
parser.paren_depth -= 1;
} else if (c == ';' and parser.paren_depth == 0) {
try parser.finishDeclaration(arena, &declarations, text);
parser.state = .seek_name;
}
},
.in_quoted_value => {
if (parser.escape_next) {
parser.escape_next = false;
} else if (c == '\\') {
parser.escape_next = true;
} else if (c == '"') {
parser.state = .in_value;
}
},
.in_single_quoted_value => {
if (parser.escape_next) {
parser.escape_next = false;
} else if (c == '\\') {
parser.escape_next = true;
} else if (c == '\'') {
parser.state = .in_value;
}
},
.in_url => {
if (parser.escape_next) {
parser.escape_next = false;
} else if (c == '\\') {
parser.escape_next = true;
} else if (c == '(') {
parser.paren_depth += 1;
} else if (c == ')') {
parser.paren_depth -= 1;
if (parser.paren_depth == 0) {
parser.state = .in_value;
}
}
},
.in_important => {},
}
parser.position += 1;
}
try parser.finalize(arena, &declarations, text);
return declarations.items;
}
fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace);
if (name.len == 0) return;
const raw_value = text[self.value_start..self.position];
const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace);
var final_value = value;
var is_important = false;
if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) {
is_important = true;
final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace);
}
try declarations.append(arena, .{
.name = name,
.value = final_value,
.is_important = is_important,
});
}
fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
if (self.state != .in_value) {
return;
}
return self.finishDeclaration(arena, declarations, text);
}
};
const testing = @import("../../testing.zig");
test "CSSParser - Simple property" {
defer testing.reset();
const text = "color: red;";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expectEqual(1, declarations.len);
try testing.expectEqual("color", declarations[0].name);
try testing.expectEqual("red", declarations[0].value);
try testing.expectEqual(false, declarations[0].is_important);
}
test "CSSParser - Property with !important" {
defer testing.reset();
const text = "margin: 10px !important;";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expectEqual(1, declarations.len);
try testing.expectEqual("margin", declarations[0].name);
try testing.expectEqual("10px", declarations[0].value);
try testing.expectEqual(true, declarations[0].is_important);
}
test "CSSParser - Multiple properties" {
defer testing.reset();
const text = "color: red; font-size: 12px; margin: 5px !important;";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expect(declarations.len == 3);
try testing.expectEqual("color", declarations[0].name);
try testing.expectEqual("red", declarations[0].value);
try testing.expectEqual(false, declarations[0].is_important);
try testing.expectEqual("font-size", declarations[1].name);
try testing.expectEqual("12px", declarations[1].value);
try testing.expectEqual(false, declarations[1].is_important);
try testing.expectEqual("margin", declarations[2].name);
try testing.expectEqual("5px", declarations[2].value);
try testing.expectEqual(true, declarations[2].is_important);
}
test "CSSParser - Quoted value with semicolon" {
defer testing.reset();
const text = "content: \"Hello; world!\";";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expectEqual(1, declarations.len);
try testing.expectEqual("content", declarations[0].name);
try testing.expectEqual("\"Hello; world!\"", declarations[0].value);
try testing.expectEqual(false, declarations[0].is_important);
}
test "CSSParser - URL value" {
defer testing.reset();
const text = "background-image: url(\"test.png\");";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expectEqual(1, declarations.len);
try testing.expectEqual("background-image", declarations[0].name);
try testing.expectEqual("url(\"test.png\")", declarations[0].value);
try testing.expectEqual(false, declarations[0].is_important);
}
test "CSSParser - Whitespace handling" {
defer testing.reset();
const text = " color : purple ; margin : 10px ; ";
const allocator = testing.arena_allocator;
const declarations = try CSSParser.parseDeclarations(allocator, text);
try testing.expectEqual(2, declarations.len);
try testing.expectEqual("color", declarations[0].name);
try testing.expectEqual("purple", declarations[0].value);
try testing.expectEqual("margin", declarations[1].name);
try testing.expectEqual("10px", declarations[1].value);
}

View File

@@ -1,247 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const CSSParser = @import("./css_parser.zig").CSSParser;
const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer;
const Page = @import("../page.zig").Page;
pub const Interfaces = .{
CSSStyleDeclaration,
CSSRule,
};
const CSSRule = struct {};
pub const CSSStyleDeclaration = struct {
store: std.StringHashMapUnmanaged(Property),
order: std.ArrayListUnmanaged([]const u8),
pub const empty: CSSStyleDeclaration = .{
.store = .empty,
.order = .empty,
};
const Property = struct {
value: []const u8,
priority: bool,
};
pub fn get_cssFloat(self: *const CSSStyleDeclaration) []const u8 {
return self._getPropertyValue("float");
}
pub fn set_cssFloat(self: *CSSStyleDeclaration, value: ?[]const u8, page: *Page) !void {
const final_value = value orelse "";
return self._setProperty("float", final_value, null, page);
}
pub fn get_cssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 {
var buffer: std.ArrayListUnmanaged(u8) = .empty;
const writer = buffer.writer(page.call_arena);
for (self.order.items) |name| {
const prop = self.store.get(name).?;
const escaped = try CSSValueAnalyzer.escapeCSSValue(page.call_arena, prop.value);
try writer.print("{s}: {s}", .{ name, escaped });
if (prop.priority) try writer.writeAll(" !important");
try writer.writeAll("; ");
}
return buffer.items;
}
// TODO Propagate also upward to parent node
pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void {
self.store.clearRetainingCapacity();
self.order.clearRetainingCapacity();
// call_arena is safe here, because _setProperty will dupe the name
// using the page's longer-living arena.
const declarations = try CSSParser.parseDeclarations(page.call_arena, text);
for (declarations) |decl| {
if (!CSSValueAnalyzer.isValidPropertyName(decl.name)) continue;
const priority: ?[]const u8 = if (decl.is_important) "important" else null;
try self._setProperty(decl.name, decl.value, priority, page);
}
}
pub fn get_length(self: *const CSSStyleDeclaration) usize {
return self.order.items.len;
}
pub fn get_parentRule(_: *const CSSStyleDeclaration) ?CSSRule {
return null;
}
pub fn _getPropertyPriority(self: *const CSSStyleDeclaration, name: []const u8) []const u8 {
return if (self.store.get(name)) |prop| (if (prop.priority) "important" else "") else "";
}
// TODO should handle properly shorthand properties and canonical forms
pub fn _getPropertyValue(self: *const CSSStyleDeclaration, name: []const u8) []const u8 {
if (self.store.get(name)) |prop| {
return prop.value;
}
// default to everything being visible (unless it's been explicitly set)
if (std.mem.eql(u8, name, "visibility")) {
return "visible";
}
return "";
}
pub fn _item(self: *const CSSStyleDeclaration, index: usize) []const u8 {
return if (index < self.order.items.len) self.order.items[index] else "";
}
pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8) ![]const u8 {
const prop = self.store.fetchRemove(name) orelse return "";
for (self.order.items, 0..) |item, i| {
if (std.mem.eql(u8, item, name)) {
_ = self.order.orderedRemove(i);
break;
}
}
// safe to return, since it's in our page.arena
return prop.value.value;
}
pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, page: *Page) !void {
const owned_value = try page.arena.dupe(u8, value);
const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important");
const gop = try self.store.getOrPut(page.arena, name);
if (!gop.found_existing) {
const owned_name = try page.arena.dupe(u8, name);
gop.key_ptr.* = owned_name;
try self.order.append(page.arena, owned_name);
}
gop.value_ptr.* = .{ .value = owned_value, .priority = is_important };
}
pub fn named_get(self: *const CSSStyleDeclaration, name: []const u8, _: *bool) []const u8 {
return self._getPropertyValue(name);
}
};
const testing = @import("../../testing.zig");
test "CSSOM.CSSStyleDeclaration" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let style = document.getElementById('content').style", "undefined" },
.{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" },
.{ "style.length", "3" },
}, .{});
try runner.testCases(&.{
.{ "style.getPropertyValue('color')", "red" },
.{ "style.getPropertyValue('font-size')", "12px" },
.{ "style.getPropertyValue('unknown-property')", "" },
.{ "style.getPropertyPriority('margin')", "important" },
.{ "style.getPropertyPriority('color')", "" },
.{ "style.getPropertyPriority('unknown-property')", "" },
.{ "style.item(0)", "color" },
.{ "style.item(1)", "font-size" },
.{ "style.item(2)", "margin" },
.{ "style.item(3)", "" },
}, .{});
try runner.testCases(&.{
.{ "style.setProperty('background-color', 'blue')", "undefined" },
.{ "style.getPropertyValue('background-color')", "blue" },
.{ "style.length", "4" },
.{ "style.setProperty('color', 'green')", "undefined" },
.{ "style.getPropertyValue('color')", "green" },
.{ "style.length", "4" },
.{ "style.color", "green" },
.{ "style.setProperty('padding', '10px', 'important')", "undefined" },
.{ "style.getPropertyValue('padding')", "10px" },
.{ "style.getPropertyPriority('padding')", "important" },
.{ "style.setProperty('border', '1px solid black', 'IMPORTANT')", "undefined" },
.{ "style.getPropertyPriority('border')", "important" },
}, .{});
try runner.testCases(&.{
.{ "style.removeProperty('color')", "green" },
.{ "style.getPropertyValue('color')", "" },
.{ "style.length", "5" },
.{ "style.removeProperty('unknown-property')", "" },
}, .{});
try runner.testCases(&.{
.{ "style.cssText.includes('font-size: 12px;')", "true" },
.{ "style.cssText.includes('margin: 5px !important;')", "true" },
.{ "style.cssText.includes('padding: 10px !important;')", "true" },
.{ "style.cssText.includes('border: 1px solid black !important;')", "true" },
.{ "style.cssText = 'color: purple; text-align: center;'", "color: purple; text-align: center;" },
.{ "style.length", "2" },
.{ "style.getPropertyValue('color')", "purple" },
.{ "style.getPropertyValue('text-align')", "center" },
.{ "style.getPropertyValue('font-size')", "" },
.{ "style.setProperty('cont', 'Hello; world!')", "undefined" },
.{ "style.getPropertyValue('cont')", "Hello; world!" },
.{ "style.cssText = 'content: \"Hello; world!\"; background-image: url(\"test.png\");'", "content: \"Hello; world!\"; background-image: url(\"test.png\");" },
.{ "style.getPropertyValue('content')", "\"Hello; world!\"" },
.{ "style.getPropertyValue('background-image')", "url(\"test.png\")" },
}, .{});
try runner.testCases(&.{
.{ "style.cssFloat", "" },
.{ "style.cssFloat = 'left'", "left" },
.{ "style.cssFloat", "left" },
.{ "style.getPropertyValue('float')", "left" },
.{ "style.cssFloat = 'right'", "right" },
.{ "style.cssFloat", "right" },
.{ "style.cssFloat = null", "null" },
.{ "style.cssFloat", "" },
}, .{});
try runner.testCases(&.{
.{ "style.setProperty('display', '')", "undefined" },
.{ "style.getPropertyValue('display')", "" },
.{ "style.cssText = ' color : purple ; margin : 10px ; '", " color : purple ; margin : 10px ; " },
.{ "style.getPropertyValue('color')", "purple" },
.{ "style.getPropertyValue('margin')", "10px" },
.{ "style.setProperty('border-bottom-left-radius', '5px')", "undefined" },
.{ "style.getPropertyValue('border-bottom-left-radius')", "5px" },
}, .{});
try runner.testCases(&.{
.{ "style.visibility", "visible" },
.{ "style.getPropertyValue('visibility')", "visible" },
}, .{});
}

View File

@@ -1,811 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
pub const CSSValueAnalyzer = struct {
pub fn isNumericWithUnit(value: []const u8) bool {
if (value.len == 0) return false;
if (!std.ascii.isDigit(value[0]) and
value[0] != '+' and value[0] != '-' and value[0] != '.')
{
return false;
}
var i: usize = 0;
var has_digit = false;
var decimal_point = false;
while (i < value.len) : (i += 1) {
const c = value[i];
if (std.ascii.isDigit(c)) {
has_digit = true;
} else if (c == '.' and !decimal_point) {
decimal_point = true;
} else if ((c == 'e' or c == 'E') and has_digit) {
if (i + 1 >= value.len) return false;
if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break;
i += 1;
if (value[i] == '+' or value[i] == '-') {
i += 1;
}
var has_exp_digits = false;
while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) {
has_exp_digits = true;
}
if (!has_exp_digits) return false;
break;
} else if (c != '-' and c != '+') {
break;
}
}
if (!has_digit) return false;
if (i == value.len) return true;
const unit = value[i..];
return CSSKeywords.isValidUnit(unit);
}
pub fn isHexColor(value: []const u8) bool {
if (!std.mem.startsWith(u8, value, "#")) return false;
const hex_part = value[1..];
if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) return false;
for (hex_part) |c| {
if (!std.ascii.isHex(c)) return false;
}
return true;
}
pub fn isMultiValueProperty(value: []const u8) bool {
var parts = std.mem.splitAny(u8, value, " ");
var multi_value_parts: usize = 0;
var all_parts_valid = true;
while (parts.next()) |part| {
if (part.len == 0) continue;
multi_value_parts += 1;
const is_numeric = isNumericWithUnit(part);
const is_hex_color = isHexColor(part);
const is_known_keyword = CSSKeywords.isKnownKeyword(part);
const is_function = CSSKeywords.startsWithFunction(part);
if (!is_numeric and !is_hex_color and !is_known_keyword and !is_function) {
all_parts_valid = false;
break;
}
}
return multi_value_parts >= 2 and all_parts_valid;
}
pub fn isAlreadyQuoted(value: []const u8) bool {
return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or
(value[0] == '\'' and value[value.len - 1] == '\''));
}
pub fn isValidPropertyName(name: []const u8) bool {
if (name.len == 0) return false;
if (std.mem.startsWith(u8, name, "--")) {
if (name.len == 2) return false;
for (name[2..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') {
return false;
}
}
return true;
}
const first_char = name[0];
if (!std.ascii.isAlphabetic(first_char) and first_char != '-') {
return false;
}
if (first_char == '-') {
if (name.len < 2) return false;
if (!std.ascii.isAlphabetic(name[1])) {
return false;
}
for (name[2..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-') {
return false;
}
}
} else {
for (name[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-') {
return false;
}
}
}
return true;
}
pub fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } {
const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace);
if (std.mem.endsWith(u8, trimmed, "!important")) {
const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace);
return .{ .value = clean_value, .is_important = true };
}
return .{ .value = trimmed, .is_important = false };
}
pub fn needsQuotes(value: []const u8) bool {
if (value.len == 0) return true;
if (isAlreadyQuoted(value)) return false;
if (CSSKeywords.containsSpecialChar(value)) {
return true;
}
if (std.mem.indexOfScalar(u8, value, ' ') == null) {
return false;
}
const is_url = std.mem.startsWith(u8, value, "url(");
const is_function = CSSKeywords.startsWithFunction(value);
return !isMultiValueProperty(value) and
!is_url and
!is_function;
}
pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 {
if (!needsQuotes(value)) {
return value;
}
var out: std.ArrayListUnmanaged(u8) = .empty;
// We'll need at least this much space, +2 for the quotes
try out.ensureTotalCapacity(arena, value.len + 2);
const writer = out.writer(arena);
try writer.writeByte('"');
for (value, 0..) |c, i| {
switch (c) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\A "),
'\r' => try writer.writeAll("\\D "),
'\t' => try writer.writeAll("\\9 "),
0...8, 11, 12, 14...31, 127 => {
try writer.print("\\{x}", .{c});
if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) {
try writer.writeByte(' ');
}
},
else => try writer.writeByte(c),
}
}
try writer.writeByte('"');
return out.items;
}
pub fn isKnownKeyword(value: []const u8) bool {
return CSSKeywords.isKnownKeyword(value);
}
pub fn containsSpecialChar(value: []const u8) bool {
return CSSKeywords.containsSpecialChar(value);
}
};
const CSSKeywords = struct {
const border_styles = [_][]const u8{
"none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset",
};
const color_names = [_][]const u8{
"black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent",
"currentColor", "inherit",
};
const position_keywords = [_][]const u8{
"auto", "center", "left", "right", "top", "bottom",
};
const background_repeat = [_][]const u8{
"repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round",
};
const font_styles = [_][]const u8{
"normal", "italic", "oblique", "bold", "bolder", "lighter",
};
const font_sizes = [_][]const u8{
"xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large",
"smaller", "larger",
};
const font_families = [_][]const u8{
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
};
const css_global = [_][]const u8{
"initial", "inherit", "unset", "revert",
};
const display_values = [_][]const u8{
"block", "inline", "inline-block", "flex", "grid", "none",
};
const length_units = [_][]const u8{
"px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm",
"ex", "ch", "fr",
};
const angle_units = [_][]const u8{
"deg", "rad", "grad", "turn",
};
const time_units = [_][]const u8{
"s", "ms",
};
const frequency_units = [_][]const u8{
"Hz", "kHz",
};
const resolution_units = [_][]const u8{
"dpi", "dpcm", "dppx",
};
const special_chars = [_]u8{
'"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F',
};
const functions = [_][]const u8{
"rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(",
"linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(",
};
pub fn isKnownKeyword(value: []const u8) bool {
const all_categories = [_][]const []const u8{
&border_styles, &color_names, &position_keywords, &background_repeat,
&font_styles, &font_sizes, &font_families, &css_global,
&display_values,
};
for (all_categories) |category| {
for (category) |keyword| {
if (std.ascii.eqlIgnoreCase(value, keyword)) {
return true;
}
}
}
return false;
}
pub fn containsSpecialChar(value: []const u8) bool {
for (value) |c| {
for (special_chars) |special| {
if (c == special) {
return true;
}
}
}
return false;
}
pub fn isValidUnit(unit: []const u8) bool {
const all_units = [_][]const []const u8{
&length_units, &angle_units, &time_units, &frequency_units, &resolution_units,
};
for (all_units) |category| {
for (category) |valid_unit| {
if (std.ascii.eqlIgnoreCase(unit, valid_unit)) {
return true;
}
}
}
return false;
}
pub fn startsWithFunction(value: []const u8) bool {
const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false;
if (pos == 0) return false;
if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) {
return false;
}
const function_name = value[0..pos];
return isValidFunctionName(function_name);
}
fn isValidFunctionName(name: []const u8) bool {
if (name.len == 0) return false;
const first = name[0];
if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') {
return false;
}
for (name[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') {
return false;
}
}
return true;
}
};
const testing = @import("../../testing.zig");
test "CSSValueAnalyzer: isNumericWithUnit - valid numbers with units" {
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw"));
}
test "CSSValueAnalyzer: isNumericWithUnit - scientific notation" {
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px"));
}
test "CSSValueAnalyzer: isNumericWithUnit - edge cases and invalid inputs" {
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("--px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(".px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1epx"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1.2.3px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10xyz"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("5invalid"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5"));
}
test "CSSValueAnalyzer: isHexColor - valid hex colors" {
try testing.expect(CSSValueAnalyzer.isHexColor("#000"));
try testing.expect(CSSValueAnalyzer.isHexColor("#fff"));
try testing.expect(CSSValueAnalyzer.isHexColor("#123456"));
try testing.expect(CSSValueAnalyzer.isHexColor("#abcdef"));
try testing.expect(CSSValueAnalyzer.isHexColor("#ABCDEF"));
try testing.expect(CSSValueAnalyzer.isHexColor("#12345678"));
}
test "CSSValueAnalyzer: isHexColor - invalid hex colors" {
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
try testing.expect(!CSSValueAnalyzer.isHexColor("#"));
try testing.expect(!CSSValueAnalyzer.isHexColor("000"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#00"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#00000"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000000"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#000000000"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#gggggg"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz"));
}
test "CSSValueAnalyzer: isMultiValueProperty - valid multi-value properties" {
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid"));
}
test "CSSValueAnalyzer: isMultiValueProperty - invalid multi-value properties" {
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px"));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown"));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid"));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" "));
}
test "CSSValueAnalyzer: isAlreadyQuoted - various quoting scenarios" {
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\""));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'"));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\""));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("''"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\""));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello'"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello\""));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
}
test "CSSValueAnalyzer: isValidPropertyName - valid property names" {
try testing.expect(CSSValueAnalyzer.isValidPropertyName("color"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("font-size"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("margin-top"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("z-index"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height"));
}
test "CSSValueAnalyzer: isValidPropertyName - invalid property names" {
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color space"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("@color"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color.test"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test"));
}
test "CSSValueAnalyzer: extractImportant - with and without !important" {
var result = CSSValueAnalyzer.extractImportant("red !important");
try testing.expect(result.is_important);
try testing.expectEqual("red", result.value);
result = CSSValueAnalyzer.extractImportant("blue");
try testing.expect(!result.is_important);
try testing.expectEqual("blue", result.value);
result = CSSValueAnalyzer.extractImportant(" green !important ");
try testing.expect(result.is_important);
try testing.expectEqual("green", result.value);
result = CSSValueAnalyzer.extractImportant("!important");
try testing.expect(result.is_important);
try testing.expectEqual("", result.value);
result = CSSValueAnalyzer.extractImportant("important");
try testing.expect(!result.is_important);
try testing.expectEqual("important", result.value);
}
test "CSSValueAnalyzer: needsQuotes - various scenarios" {
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
try testing.expect(CSSValueAnalyzer.needsQuotes("hello world"));
try testing.expect(CSSValueAnalyzer.needsQuotes("test;"));
try testing.expect(CSSValueAnalyzer.needsQuotes("a{b}"));
try testing.expect(CSSValueAnalyzer.needsQuotes("test\"quote"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("\"already quoted\""));
try testing.expect(!CSSValueAnalyzer.needsQuotes("'already quoted'"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(image.png)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("10px 20px"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("simple"));
}
test "CSSValueAnalyzer: escapeCSSValue - escaping various characters" {
const allocator = testing.arena_allocator;
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple");
try testing.expectEqual("simple", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "\"already quoted\"");
try testing.expectEqual("\"already quoted\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote");
try testing.expectEqual("\"test\\\"quote\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\nline");
try testing.expectEqual("\"test\\A line\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\\back");
try testing.expectEqual("\"test\\\\back\"", result);
}
test "CSSValueAnalyzer: CSSKeywords.isKnownKeyword - case sensitivity" {
try testing.expect(CSSKeywords.isKnownKeyword("red"));
try testing.expect(CSSKeywords.isKnownKeyword("solid"));
try testing.expect(CSSKeywords.isKnownKeyword("center"));
try testing.expect(CSSKeywords.isKnownKeyword("inherit"));
try testing.expect(CSSKeywords.isKnownKeyword("RED"));
try testing.expect(CSSKeywords.isKnownKeyword("Red"));
try testing.expect(CSSKeywords.isKnownKeyword("SOLID"));
try testing.expect(CSSKeywords.isKnownKeyword("Center"));
try testing.expect(!CSSKeywords.isKnownKeyword("invalid"));
try testing.expect(!CSSKeywords.isKnownKeyword("unknown"));
try testing.expect(!CSSKeywords.isKnownKeyword(""));
}
test "CSSValueAnalyzer: CSSKeywords.containsSpecialChar - various special characters" {
try testing.expect(CSSKeywords.containsSpecialChar("test\"quote"));
try testing.expect(CSSKeywords.containsSpecialChar("test'quote"));
try testing.expect(CSSKeywords.containsSpecialChar("test;end"));
try testing.expect(CSSKeywords.containsSpecialChar("test{brace"));
try testing.expect(CSSKeywords.containsSpecialChar("test}brace"));
try testing.expect(CSSKeywords.containsSpecialChar("test\\back"));
try testing.expect(CSSKeywords.containsSpecialChar("test<angle"));
try testing.expect(CSSKeywords.containsSpecialChar("test>angle"));
try testing.expect(CSSKeywords.containsSpecialChar("test/slash"));
try testing.expect(!CSSKeywords.containsSpecialChar("normal-text"));
try testing.expect(!CSSKeywords.containsSpecialChar("text123"));
try testing.expect(!CSSKeywords.containsSpecialChar(""));
}
test "CSSValueAnalyzer: CSSKeywords.isValidUnit - various units" {
try testing.expect(CSSKeywords.isValidUnit("px"));
try testing.expect(CSSKeywords.isValidUnit("em"));
try testing.expect(CSSKeywords.isValidUnit("rem"));
try testing.expect(CSSKeywords.isValidUnit("%"));
try testing.expect(CSSKeywords.isValidUnit("deg"));
try testing.expect(CSSKeywords.isValidUnit("rad"));
try testing.expect(CSSKeywords.isValidUnit("s"));
try testing.expect(CSSKeywords.isValidUnit("ms"));
try testing.expect(CSSKeywords.isValidUnit("PX"));
try testing.expect(!CSSKeywords.isValidUnit("invalid"));
try testing.expect(!CSSKeywords.isValidUnit(""));
}
test "CSSValueAnalyzer: CSSKeywords.startsWithFunction - function detection" {
try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)"));
try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)"));
try testing.expect(CSSKeywords.startsWithFunction("url(image.png)"));
try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)"));
try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)"));
try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)"));
try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)"));
try testing.expect(CSSKeywords.startsWithFunction("unknown(test)"));
try testing.expect(!CSSKeywords.startsWithFunction("not-a-function"));
try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)"));
try testing.expect(!CSSKeywords.startsWithFunction("missing-close("));
try testing.expect(!CSSKeywords.startsWithFunction(""));
try testing.expect(!CSSKeywords.startsWithFunction("rgb"));
}
test "CSSValueAnalyzer: isNumericWithUnit - whitespace handling" {
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px"));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px "));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px "));
}
test "CSSValueAnalyzer: extractImportant - whitespace edge cases" {
var result = CSSValueAnalyzer.extractImportant(" ");
try testing.expect(!result.is_important);
try testing.expectEqual("", result.value);
result = CSSValueAnalyzer.extractImportant("\t\n\r !important\t\n");
try testing.expect(result.is_important);
try testing.expectEqual("", result.value);
result = CSSValueAnalyzer.extractImportant("red\t!important");
try testing.expect(result.is_important);
try testing.expectEqual("red", result.value);
}
test "CSSValueAnalyzer: isHexColor - mixed case handling" {
try testing.expect(CSSValueAnalyzer.isHexColor("#AbC"));
try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc"));
try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF"));
try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF"));
}
test "CSSValueAnalyzer: edge case - very long inputs" {
const long_valid = "a" ** 1000 ++ "px";
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric
const long_property = "a-" ** 100 ++ "property";
try testing.expect(CSSValueAnalyzer.isValidPropertyName(long_property));
const long_hex = "#" ++ "a" ** 20;
try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex));
}
test "CSSValueAnalyzer: boundary conditions - numeric parsing" {
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("999999999px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1.7976931348623157e+308px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.000000001px"));
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px"));
}
test "CSSValueAnalyzer: extractImportant - malformed important declarations" {
var result = CSSValueAnalyzer.extractImportant("red ! important");
try testing.expect(!result.is_important);
try testing.expectEqual("red ! important", result.value);
result = CSSValueAnalyzer.extractImportant("red !Important");
try testing.expect(!result.is_important);
try testing.expectEqual("red !Important", result.value);
result = CSSValueAnalyzer.extractImportant("red !IMPORTANT");
try testing.expect(!result.is_important);
try testing.expectEqual("red !IMPORTANT", result.value);
result = CSSValueAnalyzer.extractImportant("!importantred");
try testing.expect(!result.is_important);
try testing.expectEqual("!importantred", result.value);
result = CSSValueAnalyzer.extractImportant("red !important !important");
try testing.expect(result.is_important);
try testing.expectEqual("red !important", result.value);
}
test "CSSValueAnalyzer: isMultiValueProperty - complex spacing scenarios" {
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty(" 10px 20px "));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\t20px"));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\n20px"));
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px"));
}
test "CSSValueAnalyzer: isAlreadyQuoted - edge cases with quotes" {
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\""));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'"));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\\\"world\""));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'hello\\'world'"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello'"));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"a\""));
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'"));
}
test "CSSValueAnalyzer: needsQuotes - function and URL edge cases" {
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(path with spaces.jpg)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("linear-gradient(to right, red, blue)"));
try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0"));
}
test "CSSValueAnalyzer: escapeCSSValue - control characters and Unicode" {
const allocator = testing.arena_allocator;
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab");
try testing.expectEqual("\"test\\9 tab\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\rreturn");
try testing.expectEqual("\"test\\D return\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x00null");
try testing.expectEqual("\"test\\0null\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x7Fdel");
try testing.expectEqual("\"test\\7f del\"", result);
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back");
try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result);
}
test "CSSValueAnalyzer: isValidPropertyName - CSS custom properties and vendor prefixes" {
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-moz-border-radius"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-ms-filter"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-o-transition"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-123invalid"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("--"));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-"));
}
test "CSSValueAnalyzer: startsWithFunction - case sensitivity and partial matches" {
try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)"));
try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)"));
try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)"));
try testing.expect(CSSKeywords.startsWithFunction("rg(something)"));
try testing.expect(CSSKeywords.startsWithFunction("ur(something)"));
try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)"));
try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)"));
try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)"));
try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)"));
try testing.expect(!CSSKeywords.startsWithFunction("123function(test)"));
}
test "CSSValueAnalyzer: isHexColor - Unicode and invalid characters" {
try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#АВС"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#1234567g"));
try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678"));
}
test "CSSValueAnalyzer: complex integration scenarios" {
const allocator = testing.arena_allocator;
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
const result = try CSSValueAnalyzer.escapeCSSValue(allocator, "fake(function with spaces");
try testing.expectEqual("\"fake(function with spaces\"", result);
const important_result = CSSValueAnalyzer.extractImportant("rgb(255,0,0) !important");
try testing.expect(important_result.is_important);
try testing.expectEqual("rgb(255,0,0)", important_result.value);
}
test "CSSValueAnalyzer: performance edge cases - empty and minimal inputs" {
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
try testing.expect(!CSSKeywords.isKnownKeyword(""));
try testing.expect(!CSSKeywords.containsSpecialChar(""));
try testing.expect(!CSSKeywords.isValidUnit(""));
try testing.expect(!CSSKeywords.startsWithFunction(""));
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("a"));
try testing.expect(!CSSValueAnalyzer.isHexColor("a"));
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("a"));
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("a"));
try testing.expect(CSSValueAnalyzer.isValidPropertyName("a"));
try testing.expect(!CSSValueAnalyzer.needsQuotes("a"));
}

View File

@@ -1,79 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
// Represents https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
pub const DataURI = struct {
was_base64_encoded: bool,
// The contents in the uri. It will be base64 decoded but not prepared in
// any way for mime.charset.
data: []const u8,
// Parses data:[<media-type>][;base64],<data>
pub fn parse(allocator: Allocator, src: []const u8) !?DataURI {
if (!std.mem.startsWith(u8, src, "data:")) {
return null;
}
const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
// Extract the encoding.
var metadata = uri[0..data_starts];
var base64_encoded = false;
if (std.mem.endsWith(u8, metadata, ";base64")) {
base64_encoded = true;
metadata = metadata[0 .. metadata.len - 7];
}
// TODO: Extract mime type. This not trivial because Mime.parse requires
// a []u8 and might mutate the src. And, the DataURI.parse references atm
// do not have deinit calls.
// Prepare the data.
var data = uri[data_starts + 1 ..];
if (base64_encoded) {
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 .{
.was_base64_encoded = base64_encoded,
.data = data,
};
}
pub fn deinit(self: *const DataURI, allocator: Allocator) void {
if (self.was_base64_encoded) {
allocator.free(self.data);
}
}
};
const testing = std.testing;
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 {
const data_uri = try DataURI.parse(std.testing.allocator, uri) orelse return error.TestFailed;
defer data_uri.deinit(testing.allocator);
try testing.expectEqualStrings(expected, data_uri.data);
}
fn test_cannot_parse(uri: []const u8) !void {
try testing.expectEqual(null, DataURI.parse(std.testing.allocator, uri));
}

View File

@@ -1,95 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#attr
pub const Attr = struct {
pub const Self = parser.Attribute;
pub const prototype = *Node;
pub const subtype = .node;
pub fn get_namespaceURI(self: *parser.Attribute) !?[]const u8 {
return try parser.nodeGetNamespace(parser.attributeToNode(self));
}
pub fn get_prefix(self: *parser.Attribute) !?[]const u8 {
return try parser.nodeGetPrefix(parser.attributeToNode(self));
}
pub fn get_localName(self: *parser.Attribute) ![]const u8 {
return try parser.nodeLocalName(parser.attributeToNode(self));
}
pub fn get_name(self: *parser.Attribute) ![]const u8 {
return try parser.attributeGetName(self);
}
pub fn get_value(self: *parser.Attribute) !?[]const u8 {
return try parser.attributeGetValue(self);
}
pub fn set_value(self: *parser.Attribute, v: []const u8) !?[]const u8 {
try parser.attributeSetValue(self, v);
return v;
}
pub fn get_ownerElement(self: *parser.Attribute) !?*parser.Element {
return try parser.attributeGetOwnerElement(self);
}
pub fn get_specified(_: *parser.Attribute) bool {
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Attribute" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let a = document.createAttributeNS('foo', 'bar')", "undefined" },
.{ "a.namespaceURI", "foo" },
.{ "a.prefix", "null" },
.{ "a.localName", "bar" },
.{ "a.name", "bar" },
.{ "a.value", "" },
// TODO: libdom has a bug here: the created attr has no parent, it
// causes a panic w/ libdom when setting the value.
//.{ "a.value = 'nok'", "nok" },
.{ "a.ownerElement", "null" },
}, .{});
try runner.testCases(&.{
.{ "let b = document.getElementById('link').getAttributeNode('class')", "undefined" },
.{ "b.name", "class" },
.{ "b.value", "ok" },
.{ "b.value = 'nok'", "nok" },
.{ "b.value", "nok" },
.{ "b.value = null", "null" },
.{ "b.value", "null" },
.{ "b.value = 'ok'", "ok" },
.{ "b.ownerElement.id", "link" },
}, .{});
}

View File

@@ -1,196 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
const Comment = @import("comment.zig").Comment;
const Text = @import("text.zig");
const ProcessingInstruction = @import("processing_instruction.zig").ProcessingInstruction;
const HTMLElem = @import("../html/elements.zig");
// CharacterData interfaces
pub const Interfaces = .{
Comment,
Text.Text,
Text.Interfaces,
ProcessingInstruction,
};
// CharacterData implementation
pub const CharacterData = struct {
pub const Self = parser.CharacterData;
pub const prototype = *Node;
pub const subtype = .node;
// JS funcs
// --------
// Read attributes
pub fn get_length(self: *parser.CharacterData) !u32 {
return try parser.characterDataLength(self);
}
pub fn get_nextElementSibling(self: *parser.CharacterData) !?HTMLElem.Union {
const res = try parser.nodeNextElementSibling(parser.characterDataToNode(self));
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
}
pub fn get_previousElementSibling(self: *parser.CharacterData) !?HTMLElem.Union {
const res = try parser.nodePreviousElementSibling(parser.characterDataToNode(self));
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
}
// Read/Write attributes
pub fn get_data(self: *parser.CharacterData) ![]const u8 {
return try parser.characterDataData(self);
}
pub fn set_data(self: *parser.CharacterData, data: []const u8) !void {
return try parser.characterDataSetData(self, data);
}
// JS methods
// ----------
pub fn _appendData(self: *parser.CharacterData, data: []const u8) !void {
return try parser.characterDataAppendData(self, data);
}
pub fn _deleteData(self: *parser.CharacterData, offset: u32, count: u32) !void {
return try parser.characterDataDeleteData(self, offset, count);
}
pub fn _insertData(self: *parser.CharacterData, offset: u32, data: []const u8) !void {
return try parser.characterDataInsertData(self, offset, data);
}
pub fn _replaceData(self: *parser.CharacterData, offset: u32, count: u32, data: []const u8) !void {
return try parser.characterDataReplaceData(self, offset, count, data);
}
pub fn _substringData(self: *parser.CharacterData, offset: u32, count: u32) ![]const u8 {
return try parser.characterDataSubstringData(self, offset, count);
}
// netsurf's CharacterData (text, comment) doesn't implement the
// dom_node_get_attributes and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.CharacterData, other_node: *parser.Node) !bool {
if (try parser.nodeType(@alignCast(@ptrCast(self))) != try parser.nodeType(other_node)) {
return false;
}
const other: *parser.CharacterData = @ptrCast(other_node);
if (std.mem.eql(u8, try get_data(self), try get_data(other)) == false) {
return false;
}
return true;
}
pub fn _before(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
const ref_node = parser.characterDataToNode(self);
return Node.before(ref_node, nodes);
}
pub fn _after(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
const ref_node = parser.characterDataToNode(self);
return Node.after(ref_node, nodes);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.CharacterData" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let link = document.getElementById('link')", "undefined" },
.{ "let cdata = link.firstChild", "undefined" },
.{ "cdata.data", "OK" },
}, .{});
try runner.testCases(&.{
.{ "cdata.data = 'OK modified'", "OK modified" },
.{ "cdata.data === 'OK modified'", "true" },
.{ "cdata.data = 'OK'", "OK" },
}, .{});
try runner.testCases(&.{
.{ "cdata.length === 2", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.nextElementSibling === null", "true" },
// create a next element
.{ "let next = document.createElement('a')", "undefined" },
.{ "link.appendChild(next, cdata) !== undefined", "true" },
.{ "cdata.nextElementSibling.localName === 'a' ", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.previousElementSibling === null", "true" },
// create a prev element
.{ "let prev = document.createElement('div')", "undefined" },
.{ "link.insertBefore(prev, cdata) !== undefined", "true" },
.{ "cdata.previousElementSibling.localName === 'div' ", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.appendData(' modified')", "undefined" },
.{ "cdata.data === 'OK modified' ", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.deleteData('OK'.length, ' modified'.length)", "undefined" },
.{ "cdata.data == 'OK'", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.insertData('OK'.length-1, 'modified')", "undefined" },
.{ "cdata.data == 'OmodifiedK'", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", "undefined" },
.{ "cdata.data == 'OreplacedK'", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
}, .{});
}

View File

@@ -1,54 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const CharacterData = @import("character_data.zig").CharacterData;
const Page = @import("../page.zig").Page;
// https://dom.spec.whatwg.org/#interface-comment
pub const Comment = struct {
pub const Self = parser.Comment;
pub const prototype = *CharacterData;
pub const subtype = .node;
pub fn constructor(data: ?[]const u8, page: *const Page) !*parser.Comment {
return parser.documentCreateComment(
parser.documentHTMLToDocument(page.window.document),
data orelse "",
);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Comment" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let comment = new Comment('foo')", "undefined" },
.{ "comment.data", "foo" },
.{ "let emptycomment = new Comment()", "undefined" },
.{ "emptycomment.data", "" },
}, .{});
}

View File

@@ -1,80 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const css = @import("../css/css.zig");
const Node = @import("../css/libdom.zig").Node;
const NodeList = @import("nodelist.zig").NodeList;
const MatchFirst = struct {
n: ?*parser.Node = null,
pub fn match(m: *MatchFirst, n: Node) !void {
m.n = n.node;
}
};
pub fn querySelector(alloc: std.mem.Allocator, n: *parser.Node, selector: []const u8) !?*parser.Node {
const ps = try css.parse(alloc, selector, .{ .accept_pseudo_elts = true });
defer ps.deinit(alloc);
var m = MatchFirst{};
_ = try css.matchFirst(ps, Node{ .node = n }, &m);
return m.n;
}
const MatchAll = struct {
alloc: std.mem.Allocator,
nl: NodeList,
fn init(alloc: std.mem.Allocator) MatchAll {
return .{
.alloc = alloc,
.nl = .{},
};
}
fn deinit(m: *MatchAll) void {
m.nl.deinit(m.alloc);
}
pub fn match(m: *MatchAll, n: Node) !void {
try m.nl.append(m.alloc, n.node);
}
fn toOwnedList(m: *MatchAll) NodeList {
// reset it.
defer m.nl = .{};
return m.nl;
}
};
pub fn querySelectorAll(alloc: std.mem.Allocator, n: *parser.Node, selector: []const u8) !NodeList {
const ps = try css.parse(alloc, selector, .{ .accept_pseudo_elts = true });
defer ps.deinit(alloc);
var m = MatchAll.init(alloc);
defer m.deinit();
try css.matchAll(ps, Node{ .node = n }, &m);
return m.toOwnedList();
}

View File

@@ -1,482 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Node = @import("node.zig").Node;
const NodeList = @import("nodelist.zig").NodeList;
const NodeUnion = @import("node.zig").Union;
const collection = @import("html_collection.zig");
const css = @import("css.zig");
const Element = @import("element.zig").Element;
const ElementUnion = @import("element.zig").Union;
const TreeWalker = @import("tree_walker.zig").TreeWalker;
const Env = @import("../env.zig").Env;
const DOMImplementation = @import("implementation.zig").DOMImplementation;
// WEB IDL https://dom.spec.whatwg.org/#document
pub const Document = struct {
pub const Self = parser.Document;
pub const prototype = *Node;
pub const subtype = .node;
pub fn constructor(page: *const Page) !*parser.DocumentHTML {
const doc = try parser.documentCreateDocument(
try parser.documentHTMLGetTitle(page.window.document),
);
// we have to work w/ document instead of html document.
const ddoc = parser.documentHTMLToDocument(doc);
const ccur = parser.documentHTMLToDocument(page.window.document);
try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
return doc;
}
// JS funcs
// --------
pub fn get_implementation(_: *parser.Document) DOMImplementation {
return DOMImplementation{};
}
pub fn get_documentElement(self: *parser.Document) !?ElementUnion {
const e = try parser.documentGetDocumentElement(self);
if (e == null) return null;
return try Element.toInterface(e.?);
}
pub fn get_documentURI(self: *parser.Document) ![]const u8 {
return try parser.documentGetDocumentURI(self);
}
pub fn get_URL(self: *parser.Document) ![]const u8 {
return try get_documentURI(self);
}
// TODO implement contentType
pub fn get_contentType(self: *parser.Document) []const u8 {
_ = self;
return "text/html";
}
// TODO implement compactMode
pub fn get_compatMode(self: *parser.Document) []const u8 {
_ = self;
return "CSS1Compat";
}
pub fn get_characterSet(self: *parser.Document) ![]const u8 {
return try parser.documentGetInputEncoding(self);
}
// alias of get_characterSet
pub fn get_charset(self: *parser.Document) ![]const u8 {
return try get_characterSet(self);
}
// alias of get_characterSet
pub fn get_inputEncoding(self: *parser.Document) ![]const u8 {
return try get_characterSet(self);
}
pub fn get_doctype(self: *parser.Document) !?*parser.DocumentType {
return try parser.documentGetDoctype(self);
}
pub fn _createEvent(_: *parser.Document, eventCstr: []const u8) !*parser.Event {
// TODO: for now only "Event" constructor is supported
// see table on https://dom.spec.whatwg.org/#dom-document-createevent $2
if (std.ascii.eqlIgnoreCase(eventCstr, "Event") or std.ascii.eqlIgnoreCase(eventCstr, "Events")) {
return try parser.eventCreate();
}
return parser.DOMError.NotSupported;
}
pub fn _getElementById(self: *parser.Document, id: []const u8) !?ElementUnion {
const e = try parser.documentGetElementById(self, id) orelse return null;
return try Element.toInterface(e);
}
const CreateElementResult = union(enum) {
element: ElementUnion,
custom: Env.JsObject,
};
pub fn _createElement(self: *parser.Document, tag_name: []const u8, page: *Page) !CreateElementResult {
if (try page.window.custom_elements.newInstance(tag_name)) |ce| {
return .{ .custom = ce };
}
const e = try parser.documentCreateElement(self, tag_name);
return .{ .element = try Element.toInterface(e) };
}
pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
const e = try parser.documentCreateElementNS(self, ns, tag_name);
return try Element.toInterface(e);
}
// We can't simply use libdom dom_document_get_elements_by_tag_name here.
// Indeed, netsurf implemented a previous dom spec when
// getElementsByTagName returned a NodeList.
// But since
// https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749
// the spec changed to return an HTMLCollection instead.
// That's why we reimplemented getElementsByTagName by using an
// HTMLCollection in zig here.
pub fn _getElementsByTagName(
self: *parser.Document,
tag_name: []const u8,
page: *Page,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(page.arena, parser.documentToNode(self), tag_name, true);
}
pub fn _getElementsByClassName(
self: *parser.Document,
classNames: []const u8,
page: *Page,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByClassName(page.arena, parser.documentToNode(self), classNames, true);
}
pub fn _createDocumentFragment(self: *parser.Document) !*parser.DocumentFragment {
return try parser.documentCreateDocumentFragment(self);
}
pub fn _createTextNode(self: *parser.Document, data: []const u8) !*parser.Text {
return try parser.documentCreateTextNode(self, data);
}
pub fn _createCDATASection(self: *parser.Document, data: []const u8) !*parser.CDATASection {
return try parser.documentCreateCDATASection(self, data);
}
pub fn _createComment(self: *parser.Document, data: []const u8) !*parser.Comment {
return try parser.documentCreateComment(self, data);
}
pub fn _createProcessingInstruction(self: *parser.Document, target: []const u8, data: []const u8) !*parser.ProcessingInstruction {
return try parser.documentCreateProcessingInstruction(self, target, data);
}
pub fn _importNode(self: *parser.Document, node: *parser.Node, deep: ?bool) !NodeUnion {
const n = try parser.documentImportNode(self, node, deep orelse false);
return try Node.toInterface(n);
}
pub fn _adoptNode(self: *parser.Document, node: *parser.Node) !NodeUnion {
const n = try parser.documentAdoptNode(self, node);
return try Node.toInterface(n);
}
pub fn _createAttribute(self: *parser.Document, name: []const u8) !*parser.Attribute {
return try parser.documentCreateAttribute(self, name);
}
pub fn _createAttributeNS(self: *parser.Document, ns: []const u8, qname: []const u8) !*parser.Attribute {
return try parser.documentCreateAttributeNS(self, ns, qname);
}
// ParentNode
// https://dom.spec.whatwg.org/#parentnode
pub fn get_children(self: *parser.Document) !collection.HTMLCollection {
return try collection.HTMLCollectionChildren(parser.documentToNode(self), false);
}
pub fn get_firstElementChild(self: *parser.Document) !?ElementUnion {
const elt = try parser.documentGetDocumentElement(self) orelse return null;
return try Element.toInterface(elt);
}
pub fn get_lastElementChild(self: *parser.Document) !?ElementUnion {
const elt = try parser.documentGetDocumentElement(self) orelse return null;
return try Element.toInterface(elt);
}
pub fn get_childElementCount(self: *parser.Document) !u32 {
_ = try parser.documentGetDocumentElement(self) orelse return 0;
return 1;
}
pub fn _querySelector(self: *parser.Document, selector: []const u8, page: *Page) !?ElementUnion {
if (selector.len == 0) return null;
const n = try css.querySelector(page.arena, parser.documentToNode(self), selector);
if (n == null) return null;
return try Element.toInterface(parser.nodeToElement(n.?));
}
pub fn _querySelectorAll(self: *parser.Document, selector: []const u8, page: *Page) !NodeList {
return css.querySelectorAll(page.arena, parser.documentToNode(self), selector);
}
pub fn _prepend(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
return Node.prepend(parser.documentToNode(self), nodes);
}
pub fn _append(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
return Node.append(parser.documentToNode(self), nodes);
}
pub fn _replaceChildren(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
return Node.replaceChildren(parser.documentToNode(self), nodes);
}
pub fn _createTreeWalker(_: *parser.Document, root: *parser.Node, what_to_show: ?u32, filter: ?TreeWalker.TreeWalkerOpts) !TreeWalker {
return try TreeWalker.init(root, what_to_show, filter);
}
pub fn getActiveElement(self: *parser.Document, page: *Page) !?*parser.Element {
if (page.getNodeState(@alignCast(@ptrCast(self)))) |state| {
if (state.active_element) |ae| {
return ae;
}
}
if (try parser.documentHTMLBody(page.window.document)) |body| {
return @alignCast(@ptrCast(body));
}
return try parser.documentGetDocumentElement(self);
}
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
const ae = (try getActiveElement(self, page)) orelse return null;
return try Element.toInterface(ae);
}
// TODO: some elements can't be focused, like if they're disabled
// but there doesn't seem to be a generic way to check this. For example
// we could look for the "disabled" attribute, but that's only meaningful
// on certain types, and libdom's vtable doesn't seem to expose this.
pub fn setFocus(self: *parser.Document, e: *parser.ElementHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.active_element = @ptrCast(e);
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.Document" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{
.url = "about:blank",
});
defer runner.deinit();
try runner.testCases(&.{
.{ "document.__proto__.__proto__.constructor.name", "Document" },
.{ "document.__proto__.__proto__.__proto__.constructor.name", "Node" },
.{ "document.__proto__.__proto__.__proto__.__proto__.constructor.name", "EventTarget" },
.{ "let newdoc = new Document()", "undefined" },
.{ "newdoc.documentElement", "null" },
.{ "newdoc.children.length", "0" },
.{ "newdoc.getElementsByTagName('*').length", "0" },
.{ "newdoc.getElementsByTagName('*').item(0)", "null" },
.{ "newdoc.inputEncoding === document.inputEncoding", "true" },
.{ "newdoc.documentURI === document.documentURI", "true" },
.{ "newdoc.URL === document.URL", "true" },
.{ "newdoc.compatMode === document.compatMode", "true" },
.{ "newdoc.characterSet === document.characterSet", "true" },
.{ "newdoc.charset === document.charset", "true" },
.{ "newdoc.contentType === document.contentType", "true" },
}, .{});
try runner.testCases(&.{
.{ "let getElementById = document.getElementById('content')", "undefined" },
.{ "getElementById.constructor.name", "HTMLDivElement" },
.{ "getElementById.localName", "div" },
}, .{});
try runner.testCases(&.{
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
.{ "getElementsByTagName.length", "2" },
.{ "getElementsByTagName.item(0).localName", "p" },
.{ "getElementsByTagName.item(1).localName", "p" },
.{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
.{ "getElementsByTagNameAll.length", "8" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(7).localName", "p" },
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
}, .{});
try runner.testCases(&.{
.{ "let ok = document.getElementsByClassName('ok')", "undefined" },
.{ "ok.length", "2" },
.{ "let empty = document.getElementsByClassName('empty')", "undefined" },
.{ "empty.length", "1" },
.{ "let emptyok = document.getElementsByClassName('empty ok')", "undefined" },
.{ "emptyok.length", "1" },
}, .{});
try runner.testCases(&.{
.{ "let e = document.documentElement", "undefined" },
.{ "e.localName", "html" },
}, .{});
try runner.testCases(&.{
.{ "document.characterSet", "UTF-8" },
.{ "document.charset", "UTF-8" },
.{ "document.inputEncoding", "UTF-8" },
}, .{});
try runner.testCases(&.{
.{ "document.compatMode", "CSS1Compat" },
}, .{});
try runner.testCases(&.{
.{ "document.contentType", "text/html" },
}, .{});
try runner.testCases(&.{
.{ "document.documentURI", "about:blank" },
.{ "document.URL", "about:blank" },
}, .{});
try runner.testCases(&.{
.{ "let impl = document.implementation", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let d = new Document()", "undefined" },
.{ "d.characterSet", "UTF-8" },
.{ "d.URL", "about:blank" },
.{ "d.documentURI", "about:blank" },
.{ "d.compatMode", "CSS1Compat" },
.{ "d.contentType", "text/html" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createDocumentFragment()", "undefined" },
.{ "v.nodeName", "#document-fragment" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createTextNode('foo')", "undefined" },
.{ "v.nodeName", "#text" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createCDATASection('foo')", "undefined" },
.{ "v.nodeName", "#cdata-section" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createComment('foo')", "undefined" },
.{ "v.nodeName", "#comment" },
.{ "let v2 = v.cloneNode()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
.{ "pi.target", "foo" },
.{ "let pi2 = pi.cloneNode()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let nimp = document.getElementById('content')", "undefined" },
.{ "var v = document.importNode(nimp)", "undefined" },
.{ "v.nodeName", "DIV" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createAttribute('foo')", "undefined" },
.{ "v.nodeName", "foo" },
}, .{});
try runner.testCases(&.{
.{ "document.children.length", "1" },
.{ "document.children.item(0).nodeName", "HTML" },
.{ "document.firstElementChild.nodeName", "HTML" },
.{ "document.lastElementChild.nodeName", "HTML" },
.{ "document.childElementCount", "1" },
.{ "let nd = new Document()", "undefined" },
.{ "nd.children.length", "0" },
.{ "nd.children.item(0)", "null" },
.{ "nd.firstElementChild", "null" },
.{ "nd.lastElementChild", "null" },
.{ "nd.childElementCount", "0" },
.{ "let emptydoc = document.createElement('html')", "undefined" },
.{ "emptydoc.prepend(document.createElement('html'))", "undefined" },
.{ "let emptydoc2 = document.createElement('html')", "undefined" },
.{ "emptydoc2.append(document.createElement('html'))", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "document.querySelector('')", "null" },
.{ "document.querySelector('*').nodeName", "HTML" },
.{ "document.querySelector('#content').id", "content" },
.{ "document.querySelector('#para').id", "para" },
.{ "document.querySelector('.ok').id", "link" },
.{ "document.querySelector('a ~ p').id", "para-empty" },
.{ "document.querySelector(':root').nodeName", "HTML" },
.{ "document.querySelectorAll('p').length", "2" },
.{
\\ Array.from(document.querySelectorAll('#content > p#para-empty'))
\\ .map(row => row.querySelector('span').textContent)
\\ .length;
,
"1",
},
}, .{});
try runner.testCases(&.{
.{ "document.activeElement === document.body", "true" },
.{ "document.getElementById('link').focus()", "undefined" },
.{ "document.activeElement === document.getElementById('link')", "true" },
}, .{});
// this test breaks the doc structure, keep it at the end of the test
// suite.
try runner.testCases(&.{
.{ "let nadop = document.getElementById('content')", "undefined" },
.{ "var v = document.adoptNode(nadop)", "undefined" },
.{ "v.nodeName", "DIV" },
}, .{});
const Case = testing.JsRunner.Case;
const tags = comptime parser.Tag.all();
var createElements: [(tags.len) * 2]Case = undefined;
inline for (tags, 0..) |tag, i| {
const tag_name = @tagName(tag);
createElements[i * 2] = Case{
"var " ++ tag_name ++ "Elem = document.createElement('" ++ tag_name ++ "')",
"undefined",
};
createElements[(i * 2) + 1] = Case{
tag_name ++ "Elem.localName",
tag_name,
};
}
try runner.testCases(&createElements, .{});
}

View File

@@ -1,74 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#documentfragment
pub const DocumentFragment = struct {
pub const Self = parser.DocumentFragment;
pub const prototype = *Node;
pub const subtype = .node;
pub fn constructor(page: *const Page) !*parser.DocumentFragment {
return parser.documentCreateDocumentFragment(
parser.documentHTMLToDocument(page.window.document),
);
}
pub fn _isEqualNode(self: *parser.DocumentFragment, other_node: *parser.Node) !bool {
const other_type = try parser.nodeType(other_node);
if (other_type != .document_fragment) {
return false;
}
_ = self;
return true;
}
pub fn _prepend(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
return Node.prepend(parser.documentFragmentToNode(self), nodes);
}
pub fn _append(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
return Node.append(parser.documentFragmentToNode(self), nodes);
}
pub fn _replaceChildren(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
return Node.replaceChildren(parser.documentFragmentToNode(self), nodes);
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.DocumentFragment" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const dc = new DocumentFragment()", "undefined" },
.{ "dc.constructor.name", "DocumentFragment" },
}, .{});
try runner.testCases(&.{
.{ "const dc1 = new DocumentFragment()", "undefined" },
.{ "const dc2 = new DocumentFragment()", "undefined" },
.{ "dc1.isEqualNode(dc1)", "true" },
.{ "dc1.isEqualNode(dc2)", "true" },
}, .{});
}

View File

@@ -1,80 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#documenttype
pub const DocumentType = struct {
pub const Self = parser.DocumentType;
pub const prototype = *Node;
pub const subtype = .node;
pub fn get_name(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetName(self);
}
pub fn get_publicId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetPublicId(self);
}
pub fn get_systemId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetSystemId(self);
}
// netsurf's DocumentType doesn't implement the dom_node_get_attributes
// and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.DocumentType, other_node: *parser.Node) !bool {
if (try parser.nodeType(other_node) != .document_type) {
return false;
}
const other: *parser.DocumentType = @ptrCast(other_node);
if (std.mem.eql(u8, try get_name(self), try get_name(other)) == false) {
return false;
}
if (std.mem.eql(u8, try get_publicId(self), try get_publicId(other)) == false) {
return false;
}
if (std.mem.eql(u8, try get_systemId(self), try get_systemId(other)) == false) {
return false;
}
return true;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.DocumentType" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let dt1 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
.{ "let dt2 = document.implementation.createDocumentType('qname2', 'pid2', 'sys2');", "undefined" },
.{ "let dt3 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
.{ "dt1.isEqualNode(dt1)", "true" },
.{ "dt1.isEqualNode(dt3)", "true" },
.{ "dt1.isEqualNode(dt2)", "false" },
.{ "dt2.isEqualNode(dt3)", "false" },
.{ "dt1.isEqualNode(document)", "false" },
.{ "document.isEqualNode(dt1)", "false" },
}, .{});
}

View File

@@ -1,47 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const DOMException = @import("exceptions.zig").DOMException;
const EventTarget = @import("event_target.zig").EventTarget;
const DOMImplementation = @import("implementation.zig").DOMImplementation;
const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
const DOMTokenList = @import("token_list.zig");
const NodeList = @import("nodelist.zig");
const Node = @import("node.zig");
const MutationObserver = @import("mutation_observer.zig");
const IntersectionObserver = @import("intersection_observer.zig");
const DOMParser = @import("dom_parser.zig").DOMParser;
const TreeWalker = @import("tree_walker.zig").TreeWalker;
const NodeFilter = @import("node_filter.zig").NodeFilter;
pub const Interfaces = .{
DOMException,
EventTarget,
DOMImplementation,
NamedNodeMap,
NamedNodeMap.Iterator,
DOMTokenList.Interfaces,
NodeList.Interfaces,
Node.Node,
Node.Interfaces,
MutationObserver.Interfaces,
IntersectionObserver.Interfaces,
DOMParser,
TreeWalker,
NodeFilter,
};

View File

@@ -1,47 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/DOMParser
pub const DOMParser = struct {
pub fn constructor() !DOMParser {
return .{};
}
pub fn _parseFromString(_: *DOMParser, string: []const u8, mime_type: []const u8) !*parser.DocumentHTML {
if (!std.mem.eql(u8, mime_type, "text/html")) {
// TODO: Support XML
return error.TypeError;
}
return try parser.documentHTMLParseFromStr(string);
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.DOMParser" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const dp = new DOMParser()", "undefined" },
.{ "dp.parseFromString('<div>abc</div>', 'text/html')", "[object HTMLDocument]" },
}, .{});
}

View File

@@ -1,669 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const css = @import("css.zig");
const log = @import("../../log.zig");
const dump = @import("../dump.zig");
const collection = @import("html_collection.zig");
const Node = @import("node.zig").Node;
const Walker = @import("walker.zig").WalkerDepthFirst;
const NodeList = @import("nodelist.zig").NodeList;
const HTMLElem = @import("../html/elements.zig");
pub const Union = @import("../html/elements.zig").Union;
// WEB IDL https://dom.spec.whatwg.org/#element
pub const Element = struct {
pub const Self = parser.Element;
pub const prototype = *Node;
pub const subtype = .node;
pub const DOMRect = struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
pub fn toInterface(e: *parser.Element) !Union {
return try HTMLElem.toInterface(Union, e);
// SVGElement and MathML are not supported yet.
}
// JS funcs
// --------
pub fn get_namespaceURI(self: *parser.Element) !?[]const u8 {
return try parser.nodeGetNamespace(parser.elementToNode(self));
}
pub fn get_prefix(self: *parser.Element) !?[]const u8 {
return try parser.nodeGetPrefix(parser.elementToNode(self));
}
pub fn get_localName(self: *parser.Element) ![]const u8 {
return try parser.nodeLocalName(parser.elementToNode(self));
}
pub fn get_tagName(self: *parser.Element) ![]const u8 {
return try parser.nodeName(parser.elementToNode(self));
}
pub fn get_id(self: *parser.Element) ![]const u8 {
return try parser.elementGetAttribute(self, "id") orelse "";
}
pub fn set_id(self: *parser.Element, id: []const u8) !void {
return try parser.elementSetAttribute(self, "id", id);
}
pub fn get_className(self: *parser.Element) ![]const u8 {
return try parser.elementGetAttribute(self, "class") orelse "";
}
pub fn set_className(self: *parser.Element, class: []const u8) !void {
return try parser.elementSetAttribute(self, "class", class);
}
pub fn get_slot(self: *parser.Element) ![]const u8 {
return try parser.elementGetAttribute(self, "slot") orelse "";
}
pub fn set_slot(self: *parser.Element, slot: []const u8) !void {
return try parser.elementSetAttribute(self, "slot", slot);
}
pub fn get_classList(self: *parser.Element) !*parser.TokenList {
return try parser.tokenListCreate(self, "class");
}
pub fn get_attributes(self: *parser.Element) !*parser.NamedNodeMap {
// An element must have non-nil attributes.
return try parser.nodeGetAttributes(parser.elementToNode(self)) orelse unreachable;
}
pub fn get_innerHTML(self: *parser.Element, page: *Page) ![]const u8 {
var buf = std.ArrayList(u8).init(page.arena);
try dump.writeChildren(parser.elementToNode(self), buf.writer());
return buf.items;
}
pub fn get_outerHTML(self: *parser.Element, page: *Page) ![]const u8 {
var buf = std.ArrayList(u8).init(page.arena);
try dump.writeNode(parser.elementToNode(self), buf.writer());
return buf.items;
}
pub fn set_innerHTML(self: *parser.Element, str: []const u8) !void {
const node = parser.elementToNode(self);
const doc = try parser.nodeOwnerDocument(node) orelse return parser.DOMError.WrongDocument;
// parse the fragment
const fragment = try parser.documentParseFragmentFromStr(doc, str);
// remove existing children
try Node.removeChildren(node);
// get fragment body children
const children = try parser.documentFragmentBodyChildren(fragment) orelse return;
// append children to the node
const ln = try parser.nodeListLength(children);
for (0..ln) |_| {
// always index 0, because ndoeAppendChild moves the node out of
// the nodeList and into the new tree
const child = try parser.nodeListItem(children, 0) orelse continue;
_ = try parser.nodeAppendChild(node, child);
}
}
// The closest() method of the Element interface traverses the element and its parents (heading toward the document root) until it finds a node that matches the specified CSS selector.
// Returns the closest ancestor Element or itself, which matches the selectors. If there are no such element, null.
pub fn _closest(self: *parser.Element, selector: []const u8, page: *Page) !?*parser.Element {
const cssParse = @import("../css/css.zig").parse;
const CssNodeWrap = @import("../css/libdom.zig").Node;
const select = try cssParse(page.call_arena, selector, .{});
var current: CssNodeWrap = .{ .node = parser.elementToNode(self) };
while (true) {
if (try select.match(current)) {
if (!current.isElement()) {
log.err(.browser, "closest invalid type", .{ .type = try current.tag() });
return null;
}
return parser.nodeToElement(current.node);
}
current = try current.parent() orelse return null;
}
}
pub fn _hasAttributes(self: *parser.Element) !bool {
return try parser.nodeHasAttributes(parser.elementToNode(self));
}
pub fn _getAttribute(self: *parser.Element, qname: []const u8) !?[]const u8 {
return try parser.elementGetAttribute(self, qname);
}
pub fn _getAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !?[]const u8 {
return try parser.elementGetAttributeNS(self, ns, qname);
}
pub fn _setAttribute(self: *parser.Element, qname: []const u8, value: []const u8) !void {
return try parser.elementSetAttribute(self, qname, value);
}
pub fn _setAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8, value: []const u8) !void {
return try parser.elementSetAttributeNS(self, ns, qname, value);
}
pub fn _removeAttribute(self: *parser.Element, qname: []const u8) !void {
return try parser.elementRemoveAttribute(self, qname);
}
pub fn _removeAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !void {
return try parser.elementRemoveAttributeNS(self, ns, qname);
}
pub fn _hasAttribute(self: *parser.Element, qname: []const u8) !bool {
return try parser.elementHasAttribute(self, qname);
}
pub fn _hasAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !bool {
return try parser.elementHasAttributeNS(self, ns, qname);
}
// https://dom.spec.whatwg.org/#dom-element-toggleattribute
pub fn _toggleAttribute(self: *parser.Element, qname: []u8, force: ?bool) !bool {
_ = std.ascii.lowerString(qname, qname);
const exists = try parser.elementHasAttribute(self, qname);
// If attribute is null, then:
if (!exists) {
// If force is not given or is true, create an attribute whose
// local name is qualifiedName, value is the empty string and node
// document is thiss node document, then append this attribute to
// this, and then return true.
if (force == null or force.?) {
try parser.elementSetAttribute(self, qname, "");
return true;
}
if (try parser.validateName(qname) == false) {
return parser.DOMError.InvalidCharacter;
}
// Return false.
return false;
}
// Otherwise, if force is not given or is false, remove an attribute
// given qualifiedName and this, and then return false.
if (force == null or !force.?) {
try parser.elementRemoveAttribute(self, qname);
return false;
}
// Return true.
return true;
}
pub fn _getAttributeNode(self: *parser.Element, name: []const u8) !?*parser.Attribute {
return try parser.elementGetAttributeNode(self, name);
}
pub fn _getAttributeNodeNS(self: *parser.Element, ns: []const u8, name: []const u8) !?*parser.Attribute {
return try parser.elementGetAttributeNodeNS(self, ns, name);
}
pub fn _setAttributeNode(self: *parser.Element, attr: *parser.Attribute) !?*parser.Attribute {
return try parser.elementSetAttributeNode(self, attr);
}
pub fn _setAttributeNodeNS(self: *parser.Element, attr: *parser.Attribute) !?*parser.Attribute {
return try parser.elementSetAttributeNodeNS(self, attr);
}
pub fn _removeAttributeNode(self: *parser.Element, attr: *parser.Attribute) !*parser.Attribute {
return try parser.elementRemoveAttributeNode(self, attr);
}
pub fn _getElementsByTagName(
self: *parser.Element,
tag_name: []const u8,
page: *Page,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(
page.arena,
parser.elementToNode(self),
tag_name,
false,
);
}
pub fn _getElementsByClassName(
self: *parser.Element,
classNames: []const u8,
page: *Page,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByClassName(
page.arena,
parser.elementToNode(self),
classNames,
false,
);
}
// ParentNode
// https://dom.spec.whatwg.org/#parentnode
pub fn get_children(self: *parser.Element) !collection.HTMLCollection {
return try collection.HTMLCollectionChildren(parser.elementToNode(self), false);
}
pub fn get_firstElementChild(self: *parser.Element) !?Union {
var children = try get_children(self);
return try children._item(0);
}
pub fn get_lastElementChild(self: *parser.Element) !?Union {
// TODO we could check the last child node first, if it's an element,
// we can return it directly instead of looping twice over the
// children.
var children = try get_children(self);
const ln = try children.get_length();
if (ln == 0) return null;
return try children._item(ln - 1);
}
pub fn get_childElementCount(self: *parser.Element) !u32 {
var children = try get_children(self);
return try children.get_length();
}
// NonDocumentTypeChildNode
// https://dom.spec.whatwg.org/#interface-nondocumenttypechildnode
pub fn get_previousElementSibling(self: *parser.Element) !?Union {
const res = try parser.nodePreviousElementSibling(parser.elementToNode(self));
if (res == null) return null;
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
}
pub fn get_nextElementSibling(self: *parser.Element) !?Union {
const res = try parser.nodeNextElementSibling(parser.elementToNode(self));
if (res == null) return null;
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
}
fn getElementById(self: *parser.Element, id: []const u8) !?*parser.Node {
// walk over the node tree fo find the node by id.
const root = parser.elementToNode(self);
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = try walker.get_next(root, next) orelse return null;
// ignore non-element nodes.
if (try parser.nodeType(next.?) != .element) {
continue;
}
const e = parser.nodeToElement(next.?);
if (std.mem.eql(u8, id, try get_id(e))) return next;
}
}
pub fn _querySelector(self: *parser.Element, selector: []const u8, page: *Page) !?Union {
if (selector.len == 0) return null;
const n = try css.querySelector(page.arena, parser.elementToNode(self), selector);
if (n == null) return null;
return try toInterface(parser.nodeToElement(n.?));
}
pub fn _querySelectorAll(self: *parser.Element, selector: []const u8, page: *Page) !NodeList {
return css.querySelectorAll(page.arena, parser.elementToNode(self), selector);
}
pub fn _prepend(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
return Node.prepend(parser.elementToNode(self), nodes);
}
pub fn _append(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
return Node.append(parser.elementToNode(self), nodes);
}
pub fn _before(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
const ref_node = parser.elementToNode(self);
return Node.before(ref_node, nodes);
}
pub fn _after(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
const ref_node = parser.elementToNode(self);
return Node.after(ref_node, nodes);
}
pub fn _replaceChildren(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
return Node.replaceChildren(parser.elementToNode(self), nodes);
}
// A DOMRect object providing information about the size of an element and its position relative to the viewport.
// Returns a 0 DOMRect object if the element is eventually detached from the main window
pub fn _getBoundingClientRect(self: *parser.Element, page: *Page) !DOMRect {
// Since we are lazy rendering we need to do this check. We could store the renderer in a viewport such that it could cache these, but it would require tracking changes.
if (!try page.isNodeAttached(parser.elementToNode(self))) {
return DOMRect{ .x = 0, .y = 0, .width = 0, .height = 0 };
}
return page.renderer.getRect(self);
}
// Returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
// We do not render so it only always return the element's bounding rect.
// Returns an empty array if the element is eventually detached from the main window
pub fn _getClientRects(self: *parser.Element, page: *Page) ![]DOMRect {
if (!try page.isNodeAttached(parser.elementToNode(self))) {
return &.{};
}
const heap_ptr = try page.call_arena.create(DOMRect);
heap_ptr.* = try page.renderer.getRect(self);
return heap_ptr[0..1];
}
pub fn get_clientWidth(_: *parser.Element, page: *Page) u32 {
return page.renderer.width();
}
pub fn get_clientHeight(_: *parser.Element, page: *Page) u32 {
return page.renderer.height();
}
pub fn _matches(self: *parser.Element, selectors: []const u8, page: *Page) !bool {
const cssParse = @import("../css/css.zig").parse;
const CssNodeWrap = @import("../css/libdom.zig").Node;
const s = try cssParse(page.call_arena, selectors, .{});
return s.match(CssNodeWrap{ .node = parser.elementToNode(self) });
}
pub fn _scrollIntoViewIfNeeded(_: *parser.Element, center_if_needed: ?bool) void {
_ = center_if_needed;
}
const CheckVisibilityOpts = struct {
contentVisibilityAuto: bool,
opacityProperty: bool,
visibilityProperty: bool,
};
pub fn _checkVisibility(self: *parser.Element, opts: ?CheckVisibilityOpts) bool {
_ = self;
_ = opts;
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Element" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let g = document.getElementById('content')", "undefined" },
.{ "g.namespaceURI", "http://www.w3.org/1999/xhtml" },
.{ "g.prefix", "null" },
.{ "g.localName", "div" },
.{ "g.tagName", "DIV" },
}, .{});
try runner.testCases(&.{
.{ "let gs = document.getElementById('content')", "undefined" },
.{ "gs.id", "content" },
.{ "gs.id = 'foo'", "foo" },
.{ "gs.id", "foo" },
.{ "gs.id = 'content'", "content" },
.{ "gs.className", "" },
.{ "let gs2 = document.getElementById('para-empty')", "undefined" },
.{ "gs2.className", "ok empty" },
.{ "gs2.className = 'foo bar baz'", "foo bar baz" },
.{ "gs2.className", "foo bar baz" },
.{ "gs2.className = 'ok empty'", "ok empty" },
.{ "let cl = gs2.classList", "undefined" },
.{ "cl.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "const el2 = document.createElement('div');", "undefined" },
.{ "el2.id = 'closest'; el2.className = 'ok';", "ok" },
.{ "el2.closest('#closest')", "[object HTMLDivElement]" },
.{ "el2.closest('.ok')", "[object HTMLDivElement]" },
.{ "el2.closest('#9000')", "null" },
.{ "el2.closest('.notok')", "null" },
.{ "const sp = document.createElement('span');", "undefined" },
.{ "el2.appendChild(sp);", "[object HTMLSpanElement]" },
.{ "sp.closest('#closest')", "[object HTMLDivElement]" },
.{ "sp.closest('#9000')", "null" },
}, .{});
try runner.testCases(&.{
.{ "let a = document.getElementById('content')", "undefined" },
.{ "a.hasAttributes()", "true" },
.{ "a.attributes.length", "1" },
.{ "a.getAttribute('id')", "content" },
.{ "a.attributes['id'].value", "content" },
.{
\\ let x = '';
\\ for (const attr of a.attributes) {
\\ x += attr.name + '=' + attr.value;
\\ }
\\ x;
,
"id=content",
},
.{ "a.hasAttribute('foo')", "false" },
.{ "a.getAttribute('foo')", "null" },
.{ "a.setAttribute('foo', 'bar')", "undefined" },
.{ "a.hasAttribute('foo')", "true" },
.{ "a.getAttribute('foo')", "bar" },
.{ "a.setAttribute('foo', 'baz')", "undefined" },
.{ "a.hasAttribute('foo')", "true" },
.{ "a.getAttribute('foo')", "baz" },
.{ "a.removeAttribute('foo')", "undefined" },
.{ "a.hasAttribute('foo')", "false" },
.{ "a.getAttribute('foo')", "null" },
}, .{});
try runner.testCases(&.{
.{ "let b = document.getElementById('content')", "undefined" },
.{ "b.toggleAttribute('foo')", "true" },
.{ "b.hasAttribute('foo')", "true" },
.{ "b.getAttribute('foo')", "" },
.{ "b.toggleAttribute('foo')", "false" },
.{ "b.hasAttribute('foo')", "false" },
}, .{});
try runner.testCases(&.{
.{ "let c = document.getElementById('content')", "undefined" },
.{ "c.children.length", "3" },
.{ "c.firstElementChild.nodeName", "A" },
.{ "c.lastElementChild.nodeName", "P" },
.{ "c.childElementCount", "3" },
.{ "c.prepend(document.createTextNode('foo'))", "undefined" },
.{ "c.append(document.createTextNode('bar'))", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let d = document.getElementById('para')", "undefined" },
.{ "d.previousElementSibling.nodeName", "P" },
.{ "d.nextElementSibling", "null" },
}, .{});
try runner.testCases(&.{
.{ "let e = document.getElementById('content')", "undefined" },
.{ "e.querySelector('foo')", "null" },
.{ "e.querySelector('#foo')", "null" },
.{ "e.querySelector('#link').id", "link" },
.{ "e.querySelector('#para').id", "para" },
.{ "e.querySelector('*').id", "link" },
.{ "e.querySelector('')", "null" },
.{ "e.querySelector('*').id", "link" },
.{ "e.querySelector('#content')", "null" },
.{ "e.querySelector('#para').id", "para" },
.{ "e.querySelector('.ok').id", "link" },
.{ "e.querySelector('a ~ p').id", "para-empty" },
.{ "e.querySelectorAll('foo').length", "0" },
.{ "e.querySelectorAll('#foo').length", "0" },
.{ "e.querySelectorAll('#link').length", "1" },
.{ "e.querySelectorAll('#link').item(0).id", "link" },
.{ "e.querySelectorAll('#para').length", "1" },
.{ "e.querySelectorAll('#para').item(0).id", "para" },
.{ "e.querySelectorAll('*').length", "4" },
.{ "e.querySelectorAll('p').length", "2" },
.{ "e.querySelectorAll('.ok').item(0).id", "link" },
}, .{});
try runner.testCases(&.{
.{ "let f = document.getElementById('content')", "undefined" },
.{ "let ff = document.createAttribute('foo')", "undefined" },
.{ "f.setAttributeNode(ff)", "null" },
.{ "f.getAttributeNode('foo').name", "foo" },
.{ "f.removeAttributeNode(ff).name", "foo" },
.{ "f.getAttributeNode('bar')", "null" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('para').innerHTML", " And" },
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
.{ "let h = document.getElementById('para-empty')", "undefined" },
.{ "const prev = h.innerHTML", "undefined" },
.{ "h.innerHTML = '<p id=\"hello\">hello world</p>'", "<p id=\"hello\">hello world</p>" },
.{ "h.innerHTML", "<p id=\"hello\">hello world</p>" },
.{ "h.firstChild.nodeName", "P" },
.{ "h.firstChild.id", "hello" },
.{ "h.firstChild.textContent", "hello world" },
.{ "h.innerHTML = prev; true", "true" },
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('para').outerHTML", "<p id=\"para\"> And</p>" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('para').clientWidth", "1" },
.{ "document.getElementById('para').clientHeight", "1" },
.{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
.{ "r1.x", "0" },
.{ "r1.y", "0" },
.{ "r1.width", "1" },
.{ "r1.height", "1" },
.{ "let r2 = document.getElementById('content').getBoundingClientRect()", "undefined" },
.{ "r2.x", "1" },
.{ "r2.y", "0" },
.{ "r2.width", "1" },
.{ "r2.height", "1" },
.{ "let r3 = document.getElementById('para').getBoundingClientRect()", "undefined" },
.{ "r3.x", "0" },
.{ "r3.y", "0" },
.{ "r3.width", "1" },
.{ "r3.height", "1" },
.{ "document.getElementById('para').clientWidth", "2" },
.{ "document.getElementById('para').clientHeight", "1" },
.{ "let r4 = document.createElement('div').getBoundingClientRect()", null },
.{ "r4.x", "0" },
.{ "r4.y", "0" },
.{ "r4.width", "0" },
.{ "r4.height", "0" },
// Test setup causes WrongDocument or HierarchyRequest error unlike in chrome/firefox
// .{ // An element of another document, even if created from the main document, is not rendered.
// \\ let div5 = document.createElement('div');
// \\ const newDoc = document.implementation.createHTMLDocument("New Document");
// \\ newDoc.body.appendChild(div5);
// \\ let r5 = div5.getBoundingClientRect();
// ,
// null,
// },
// .{ "r5.x", "0" },
// .{ "r5.y", "0" },
// .{ "r5.width", "0" },
// .{ "r5.height", "0" },
}, .{});
try runner.testCases(&.{
.{ "const el = document.createElement('div');", "undefined" },
.{ "el.id = 'matches'; el.className = 'ok';", "ok" },
.{ "el.matches('#matches')", "true" },
.{ "el.matches('.ok')", "true" },
.{ "el.matches('#9000')", "false" },
.{ "el.matches('.notok')", "false" },
}, .{});
try runner.testCases(&.{
.{ "const el3 = document.createElement('div');", "undefined" },
.{ "el3.scrollIntoViewIfNeeded();", "undefined" },
.{ "el3.scrollIntoViewIfNeeded(false);", "undefined" },
}, .{});
// before
try runner.testCases(&.{
.{ "const before_container = document.createElement('div');", "undefined" },
.{ "document.append(before_container);", "undefined" },
.{ "const b1 = document.createElement('div');", "undefined" },
.{ "before_container.append(b1);", "undefined" },
.{ "const b1_a = document.createElement('p');", "undefined" },
.{ "b1.before(b1_a, 'over 9000');", "undefined" },
.{ "before_container.innerHTML", "<p></p>over 9000<div></div>" },
}, .{});
// after
try runner.testCases(&.{
.{ "const after_container = document.createElement('div');", "undefined" },
.{ "document.append(after_container);", "undefined" },
.{ "const a1 = document.createElement('div');", "undefined" },
.{ "after_container.append(a1);", "undefined" },
.{ "const a1_a = document.createElement('p');", "undefined" },
.{ "a1.after('over 9000', a1_a);", "undefined" },
.{ "after_container.innerHTML", "<div></div>over 9000<p></p>" },
}, .{});
try runner.testCases(&.{
.{ "var div1 = document.createElement('div');", null },
.{ "div1.innerHTML = \" <link/><table></table><a href='/a'>a</a><input type='checkbox'/>\"", null },
.{ "div1.getElementsByTagName('a').length", "1" },
}, .{});
}

View File

@@ -1,237 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const Env = @import("../env.zig").Env;
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const EventHandler = @import("../events/event.zig").EventHandler;
const DOMException = @import("exceptions.zig").DOMException;
const Nod = @import("node.zig");
// EventTarget interfaces
pub const Union = Nod.Union;
// EventTarget implementation
pub const EventTarget = struct {
pub const Self = parser.EventTarget;
pub const Exception = DOMException;
pub fn toInterface(et: *parser.EventTarget, page: *Page) !Union {
// Not all targets are *parser.Nodes. page.zig emits a "load" event
// where the target is a Window, which cannot be cast directly to a node.
// Ideally, we'd remove this duality. Failing that, we'll need to embed
// data into the *parser.EventTarget should we need this for other types.
// For now, for the Window, which is a singleton, we can do this:
if (@intFromPtr(et) == @intFromPtr(&page.window.base)) {
return .{ .Window = &page.window };
}
return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
}
// JS funcs
// --------
pub fn _addEventListener(
self: *parser.EventTarget,
typ: []const u8,
listener: EventHandler.Listener,
opts: ?EventHandler.Opts,
page: *Page,
) !void {
_ = try EventHandler.register(page.arena, self, typ, listener, opts);
}
const RemoveEventListenerOpts = union(enum) {
opts: Opts,
capture: bool,
const Opts = struct {
capture: ?bool,
};
};
pub fn _removeEventListener(
self: *parser.EventTarget,
typ: []const u8,
listener: EventHandler.Listener,
opts_: ?RemoveEventListenerOpts,
) !void {
var capture = false;
if (opts_) |opts| {
capture = switch (opts) {
.capture => |c| c,
.opts => |o| o.capture orelse false,
};
}
const cbk = (try listener.callback(self)) orelse return;
// check if event target has already this listener
const lst = try parser.eventTargetHasListener(
self,
typ,
capture,
cbk.id,
);
if (lst == null) {
return;
}
// remove listener
try parser.eventTargetRemoveEventListener(
self,
typ,
lst.?,
capture,
);
}
pub fn _dispatchEvent(self: *parser.EventTarget, event: *parser.Event) !bool {
return try parser.eventTargetDispatchEvent(self, event);
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.EventTarget" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let para = document.getElementById('para')", "undefined" },
// NOTE: as some event properties will change during the event dispatching phases
// we need to copy thoses values in order to check them afterwards
.{
\\ var nb = 0; var evt; var phase; var cur;
\\ function cbk(event) {
\\ evt = event;
\\ phase = event.eventPhase;
\\ cur = event.currentTarget;
\\ nb ++;
\\ }
,
"undefined",
},
}, .{});
try runner.testCases(&.{
.{ "content.addEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "basic" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "0" }, // handler is not called, no capture, not the target, no bubbling
.{ "evt === undefined", "true" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.addEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.addEventListener('basic', cbk, true)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "2" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.removeEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.removeEventListener('basic', cbk, {capture: true})", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "0" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "content.addEventListener('capture', cbk, true)", "undefined" },
.{ "content.dispatchEvent(new Event('capture'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "capture" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('capture'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "capture" },
.{ "phase", "1" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "content.addEventListener('bubbles', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "bubbles" },
.{ "evt.bubbles", "true" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "bubbles" },
.{ "phase", "3" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "const obj1 = {calls: 0, handleEvent: function() { this.calls += 1; } };", null },
.{ "content.addEventListener('he', obj1);", null },
.{ "content.dispatchEvent(new Event('he'));", null },
.{ "obj1.calls", "1" },
.{ "content.removeEventListener('he', obj1);", null },
.{ "content.dispatchEvent(new Event('he'));", null },
.{ "obj1.calls", "1" },
}, .{});
// doesn't crash on null receiver
try runner.testCases(&.{
.{ "content.addEventListener('he2', null);", null },
.{ "content.dispatchEvent(new Event('he2'));", null },
}, .{});
}

View File

@@ -1,191 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const allocPrint = std.fmt.allocPrint;
const parser = @import("../netsurf.zig");
// https://webidl.spec.whatwg.org/#idl-DOMException
pub const DOMException = struct {
err: parser.DOMError,
str: []const u8,
pub const ErrorSet = parser.DOMError;
// static attributes
pub const _INDEX_SIZE_ERR = 1;
pub const _DOMSTRING_SIZE_ERR = 2;
pub const _HIERARCHY_REQUEST_ERR = 3;
pub const _WRONG_DOCUMENT_ERR = 4;
pub const _INVALID_CHARACTER_ERR = 5;
pub const _NO_DATA_ALLOWED_ERR = 6;
pub const _NO_MODIFICATION_ALLOWED_ERR = 7;
pub const _NOT_FOUND_ERR = 8;
pub const _NOT_SUPPORTED_ERR = 9;
pub const _INUSE_ATTRIBUTE_ERR = 10;
pub const _INVALID_STATE_ERR = 11;
pub const _SYNTAX_ERR = 12;
pub const _INVALID_MODIFICATION_ERR = 13;
pub const _NAMESPACE_ERR = 14;
pub const _INVALID_ACCESS_ERR = 15;
pub const _VALIDATION_ERR = 16;
pub const _TYPE_MISMATCH_ERR = 17;
pub const _SECURITY_ERR = 18;
pub const _NETWORK_ERR = 19;
pub const _ABORT_ERR = 20;
pub const _URL_MISMATCH_ERR = 21;
pub const _QUOTA_EXCEEDED_ERR = 22;
pub const _TIMEOUT_ERR = 23;
pub const _INVALID_NODE_TYPE_ERR = 24;
pub const _DATA_CLONE_ERR = 25;
// TODO: deinit
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
const errCast = @as(parser.DOMError, @errorCast(err));
const errName = DOMException.name(errCast);
const str = switch (errCast) {
error.HierarchyRequest => try allocPrint(
alloc,
"{s}: Failed to execute '{s}' on 'Node': The new child element contains the parent.",
.{ errName, callerName },
),
error.NoError => unreachable,
else => try allocPrint(
alloc,
"{s}: TODO message", // TODO: implement other messages
.{DOMException.name(errCast)},
),
};
return .{ .err = errCast, .str = str };
}
fn name(err: parser.DOMError) []const u8 {
return switch (err) {
error.IndexSize => "IndexSizeError",
error.StringSize => "StringSizeError",
error.HierarchyRequest => "HierarchyRequestError",
error.WrongDocument => "WrongDocumentError",
error.InvalidCharacter => "InvalidCharacterError",
error.NoDataAllowed => "NoDataAllowedError",
error.NoModificationAllowed => "NoModificationAllowedError",
error.NotFound => "NotFoundError",
error.NotSupported => "NotSupportedError",
error.InuseAttribute => "InuseAttributeError",
error.InvalidState => "InvalidStateError",
error.Syntax => "SyntaxError",
error.InvalidModification => "InvalidModificationError",
error.Namespace => "NamespaceError",
error.InvalidAccess => "InvalidAccessError",
error.Validation => "ValidationError",
error.TypeMismatch => "TypeMismatchError",
error.Security => "SecurityError",
error.Network => "NetworkError",
error.Abort => "AbortError",
error.URLismatch => "URLismatchError",
error.QuotaExceeded => "QuotaExceededError",
error.Timeout => "TimeoutError",
error.InvalidNodeType => "InvalidNodeTypeError",
error.DataClone => "DataCloneError",
error.NoError => unreachable,
// custom netsurf error
error.UnspecifiedEventType => "UnspecifiedEventTypeError",
error.DispatchRequest => "DispatchRequestError",
error.NoMemory => "NoMemoryError",
error.AttributeWrongType => "AttributeWrongTypeError",
};
}
// JS properties and methods
pub fn get_code(self: *const DOMException) u8 {
return switch (self.err) {
error.IndexSize => 1,
error.StringSize => 2,
error.HierarchyRequest => 3,
error.WrongDocument => 4,
error.InvalidCharacter => 5,
error.NoDataAllowed => 6,
error.NoModificationAllowed => 7,
error.NotFound => 8,
error.NotSupported => 9,
error.InuseAttribute => 10,
error.InvalidState => 11,
error.Syntax => 12,
error.InvalidModification => 13,
error.Namespace => 14,
error.InvalidAccess => 15,
error.Validation => 16,
error.TypeMismatch => 17,
error.Security => 18,
error.Network => 19,
error.Abort => 20,
error.URLismatch => 21,
error.QuotaExceeded => 22,
error.Timeout => 23,
error.InvalidNodeType => 24,
error.DataClone => 25,
error.NoError => unreachable,
// custom netsurf error
error.UnspecifiedEventType => 128,
error.DispatchRequest => 129,
error.NoMemory => 130,
error.AttributeWrongType => 131,
};
}
pub fn get_name(self: *const DOMException) []const u8 {
return DOMException.name(self.err);
}
pub fn get_message(self: *const DOMException) []const u8 {
const errName = DOMException.name(self.err);
return self.str[errName.len + 2 ..];
}
pub fn _toString(self: *const DOMException) []const u8 {
return self.str;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.Exception" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
const err = "Failed to execute 'appendChild' on 'Node': The new child element contains the parent.";
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let link = document.getElementById('link')", "undefined" },
// HierarchyRequestError
.{
\\ var he;
\\ try { link.appendChild(content) } catch (error) { he = error}
\\ he.name
,
"HierarchyRequestError",
},
.{ "he.code", "3" },
.{ "he.message", err },
.{ "he.toString()", "HierarchyRequestError: " ++ err },
.{ "he instanceof DOMException", "true" },
.{ "he instanceof Error", "true" },
}, .{});
}

View File

@@ -1,500 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const parser = @import("../netsurf.zig");
const Element = @import("element.zig").Element;
const Union = @import("element.zig").Union;
const JsThis = @import("../env.zig").JsThis;
const Walker = @import("walker.zig").Walker;
const Matcher = union(enum) {
matchByName: MatchByName,
matchByTagName: MatchByTagName,
matchByClassName: MatchByClassName,
matchByLinks: MatchByLinks,
matchByAnchors: MatchByAnchors,
matchTrue: struct {},
matchFalse: struct {},
pub fn match(self: Matcher, node: *parser.Node) !bool {
switch (self) {
.matchTrue => return true,
.matchFalse => return false,
.matchByLinks => return MatchByLinks.match(node),
.matchByAnchors => return MatchByAnchors.match(node),
inline else => |m| return m.match(node),
}
}
};
pub const MatchByTagName = struct {
// tag is used to select node against their name.
// tag comparison is case insensitive.
tag: []const u8,
is_wildcard: bool,
fn init(arena: Allocator, tag_name: []const u8) !MatchByTagName {
if (std.mem.eql(u8, tag_name, "*")) {
return .{ .tag = "*", .is_wildcard = true };
}
return .{
.tag = try arena.dupe(u8, tag_name),
.is_wildcard = false,
};
}
pub fn match(self: MatchByTagName, node: *parser.Node) !bool {
return self.is_wildcard or std.ascii.eqlIgnoreCase(self.tag, try parser.nodeName(node));
}
};
pub fn HTMLCollectionByTagName(
arena: Allocator,
root: ?*parser.Node,
tag_name: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByTagName = try MatchByTagName.init(arena, tag_name) },
.include_root = include_root,
};
}
pub const MatchByClassName = struct {
class_names: []const u8,
fn init(arena: Allocator, class_names: []const u8) !MatchByClassName {
return .{
.class_names = try arena.dupe(u8, class_names),
};
}
pub fn match(self: MatchByClassName, node: *parser.Node) !bool {
const e = parser.nodeToElement(node);
var it = std.mem.splitScalar(u8, self.class_names, ' ');
while (it.next()) |c| {
if (!try parser.elementHasClass(e, c)) {
return false;
}
}
return true;
}
};
pub fn HTMLCollectionByClassName(
arena: Allocator,
root: ?*parser.Node,
classNames: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByClassName = try MatchByClassName.init(arena, classNames) },
.include_root = include_root,
};
}
pub const MatchByName = struct {
name: []const u8,
fn init(arena: Allocator, name: []const u8) !MatchByName {
return .{
.name = try arena.dupe(u8, name),
};
}
pub fn match(self: MatchByName, node: *parser.Node) !bool {
const e = parser.nodeToElement(node);
const nname = try parser.elementGetAttribute(e, "name") orelse return false;
return std.mem.eql(u8, self.name, nname);
}
};
pub fn HTMLCollectionByName(
arena: Allocator,
root: ?*parser.Node,
name: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByName = try MatchByName.init(arena, name) },
.include_root = include_root,
};
}
// HTMLAllCollection is a special type: instances of it are falsy. It's the only
// object in the WebAPI that behaves like this - in fact, it's even a special
// case in the JavaScript spec.
// This is important, because a lot of browser detection rely on this behavior
// to determine what browser is running.
// It's also possible to use an instance like a function:
// document.all(3)
// document.all('some_id')
pub const HTMLAllCollection = struct {
pub const prototype = *HTMLCollection;
proto: HTMLCollection,
pub const mark_as_undetectable = true;
pub fn init(root: ?*parser.Node) HTMLAllCollection {
return .{ .proto = .{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchTrue = .{} },
.include_root = true,
} };
}
const CAllAsFunctionArg = union(enum) {
index: u32,
id: []const u8,
};
pub fn jsCallAsFunction(self: *HTMLAllCollection, arg: CAllAsFunctionArg) !?Union {
return switch (arg) {
.index => |i| self.proto._item(i),
.id => |id| self.proto._namedItem(id),
};
}
};
pub fn HTMLCollectionChildren(
root: ?*parser.Node,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerChildren = .{} },
.matcher = .{ .matchTrue = .{} },
.include_root = include_root,
};
}
pub fn HTMLCollectionEmpty() !HTMLCollection {
return HTMLCollection{
.root = null,
.walker = .{ .walkerNone = .{} },
.matcher = .{ .matchFalse = .{} },
.include_root = false,
};
}
// MatchByLinks matches the a and area elements in the Document that have href
// attributes.
// https://html.spec.whatwg.org/#dom-document-links
pub const MatchByLinks = struct {
pub fn match(node: *parser.Node) !bool {
const tag = try parser.nodeName(node);
if (!std.ascii.eqlIgnoreCase(tag, "a") and !std.ascii.eqlIgnoreCase(tag, "area")) {
return false;
}
const elem = @as(*parser.Element, @ptrCast(node));
return parser.elementHasAttribute(elem, "href");
}
};
pub fn HTMLCollectionByLinks(
root: ?*parser.Node,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByLinks = MatchByLinks{} },
.include_root = include_root,
};
}
// MatchByAnchors matches the a elements in the Document that have name
// attributes.
// https://html.spec.whatwg.org/#dom-document-anchors
pub const MatchByAnchors = struct {
pub fn match(node: *parser.Node) !bool {
const tag = try parser.nodeName(node);
if (!std.ascii.eqlIgnoreCase(tag, "a")) return false;
const elem = @as(*parser.Element, @ptrCast(node));
return parser.elementHasAttribute(elem, "name");
}
};
pub fn HTMLCollectionByAnchors(
root: ?*parser.Node,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByAnchors = MatchByAnchors{} },
.include_root = include_root,
};
}
pub const HTMLCollectionIterator = struct {
coll: *HTMLCollection,
index: u32 = 0,
pub const Return = struct {
value: ?Union,
done: bool,
};
pub fn _next(self: *HTMLCollectionIterator) !Return {
const e = try self.coll._item(self.index);
if (e == null) {
return Return{
.value = null,
.done = true,
};
}
self.index += 1;
return Return{
.value = e,
.done = false,
};
}
};
// WEB IDL https://dom.spec.whatwg.org/#htmlcollection
// HTMLCollection is re implemented in zig here because libdom
// dom_html_collection expects a comparison function callback as arguement.
// But we wanted a dynamically comparison here, according to the match tagname.
pub const HTMLCollection = struct {
matcher: Matcher,
walker: Walker,
root: ?*parser.Node,
// By default the HTMLCollection walk on the root's descendant only.
// But on somes cases, like for dom document, we want to walk over the root
// itself.
include_root: bool = false,
// save a state for the collection to improve the _item speed.
cur_idx: ?u32 = null,
cur_node: ?*parser.Node = null,
// start returns the first node to walk on.
fn start(self: *const HTMLCollection) !?*parser.Node {
if (self.root == null) return null;
if (self.include_root) {
return self.root.?;
}
return try self.walker.get_next(self.root.?, null);
}
pub fn _symbol_iterator(self: *HTMLCollection) HTMLCollectionIterator {
return HTMLCollectionIterator{
.coll = self,
};
}
/// get_length computes the collection's length dynamically according to
/// the current root structure.
// TODO: nodes retrieved must be de-referenced.
pub fn get_length(self: *HTMLCollection) !u32 {
if (self.root == null) return 0;
var len: u32 = 0;
var node = try self.start() orelse return 0;
while (true) {
if (try parser.nodeType(node) == .element) {
if (try self.matcher.match(node)) {
len += 1;
}
}
node = try self.walker.get_next(self.root.?, node) orelse break;
}
return len;
}
pub fn item(self: *HTMLCollection, index: u32) !?*parser.Node {
if (self.root == null) return null;
var i: u32 = 0;
var node: *parser.Node = undefined;
// Use the current state to improve speed if possible.
if (self.cur_idx != null and index >= self.cur_idx.?) {
i = self.cur_idx.?;
node = self.cur_node.?;
} else {
node = try self.start() orelse return null;
}
while (true) {
if (try parser.nodeType(node) == .element) {
if (try self.matcher.match(node)) {
// check if we found the searched element.
if (i == index) {
// save the current state
self.cur_node = node;
self.cur_idx = i;
return node;
}
i += 1;
}
}
node = try self.walker.get_next(self.root.?, node) orelse break;
}
return null;
}
pub fn _item(self: *HTMLCollection, index: u32) !?Union {
const node = try self.item(index) orelse return null;
const e = @as(*parser.Element, @ptrCast(node));
return try Element.toInterface(e);
}
pub fn _namedItem(self: *const HTMLCollection, name: []const u8) !?Union {
if (self.root == null) return null;
if (name.len == 0) return null;
var node = try self.start() orelse return null;
while (true) {
if (try parser.nodeType(node) == .element) {
if (try self.matcher.match(node)) {
const elem = @as(*parser.Element, @ptrCast(node));
var attr = try parser.elementGetAttribute(elem, "id");
// check if the node id corresponds to the name argument.
if (attr != null and std.mem.eql(u8, name, attr.?)) {
return try Element.toInterface(elem);
}
attr = try parser.elementGetAttribute(elem, "name");
// check if the node id corresponds to the name argument.
if (attr != null and std.mem.eql(u8, name, attr.?)) {
return try Element.toInterface(elem);
}
}
}
node = try self.walker.get_next(self.root.?, node) orelse break;
}
return null;
}
fn item_name(elt: *parser.Element) !?[]const u8 {
if (try parser.elementGetAttribute(elt, "id")) |v| {
return v;
}
if (try parser.elementGetAttribute(elt, "name")) |v| {
return v;
}
return null;
}
pub fn postAttach(self: *HTMLCollection, js_this: JsThis) !void {
const len = try self.get_length();
for (0..len) |i| {
const node = try self.item(@intCast(i)) orelse unreachable;
const e = @as(*parser.Element, @ptrCast(node));
const as_interface = try Element.toInterface(e);
try js_this.setIndex(@intCast(i), as_interface, .{});
if (try item_name(e)) |name| {
// Even though an entry might have an empty id, the spec says
// that namedItem("") should always return null
if (name.len > 0) {
// Named fields should not be enumerable (it is defined with
// the LegacyUnenumerableNamedProperties flag.)
try js_this.set(name, as_interface, .{ .DONT_ENUM = true });
}
}
}
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.HTMLCollection" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
.{ "getElementsByTagName.length", "2" },
.{ "let getElementsByTagNameCI = document.getElementsByTagName('P')", "undefined" },
.{ "getElementsByTagNameCI.length", "2" },
.{ "getElementsByTagName.item(0).localName", "p" },
.{ "getElementsByTagName.item(1).localName", "p" },
.{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
.{ "getElementsByTagNameAll.length", "8" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(1).localName", "head" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(2).localName", "body" },
.{ "getElementsByTagNameAll.item(3).localName", "div" },
.{ "getElementsByTagNameAll.item(7).localName", "p" },
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
// array like
.{ "getElementsByTagNameAll[0].localName", "html" },
.{ "getElementsByTagNameAll[7].localName", "p" },
.{ "getElementsByTagNameAll[8]", "undefined" },
.{ "getElementsByTagNameAll['para-empty-child'].localName", "span" },
.{ "getElementsByTagNameAll['foo']", "undefined" },
.{ "document.getElementById('content').getElementsByTagName('*').length", "4" },
.{ "document.getElementById('content').getElementsByTagName('p').length", "2" },
.{ "document.getElementById('content').getElementsByTagName('div').length", "0" },
.{ "document.children.length", "1" },
.{ "document.getElementById('content').children.length", "3" },
// check liveness
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let pe = document.getElementById('para-empty')", "undefined" },
.{ "let p = document.createElement('p')", "undefined" },
.{ "p.textContent = 'OK live'", "OK live" },
.{ "getElementsByTagName.item(1).textContent", " And" },
.{ "content.appendChild(p) != undefined", "true" },
.{ "getElementsByTagName.length", "3" },
.{ "getElementsByTagName.item(2).textContent", "OK live" },
.{ "content.insertBefore(p, pe) != undefined", "true" },
.{ "getElementsByTagName.item(0).textContent", "OK live" },
}, .{});
}

View File

@@ -1,72 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#domimplementation
pub const DOMImplementation = struct {
pub const Exception = DOMException;
pub fn _createDocumentType(
_: *DOMImplementation,
qname: [:0]const u8,
publicId: [:0]const u8,
systemId: [:0]const u8,
) !*parser.DocumentType {
return try parser.domImplementationCreateDocumentType(qname, publicId, systemId);
}
pub fn _createDocument(
_: *DOMImplementation,
namespace: ?[:0]const u8,
qname: ?[:0]const u8,
doctype: ?*parser.DocumentType,
) !*parser.Document {
return try parser.domImplementationCreateDocument(namespace, qname, doctype);
}
pub fn _createHTMLDocument(_: *DOMImplementation, title: ?[]const u8) !*parser.DocumentHTML {
return try parser.domImplementationCreateHTMLDocument(title);
}
pub fn _hasFeature(_: *DOMImplementation) bool {
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Implementation" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let impl = document.implementation", "undefined" },
.{ "impl.createHTMLDocument();", "[object HTMLDocument]" },
.{ "const doc = impl.createHTMLDocument('foo');", "undefined" },
.{ "doc", "[object HTMLDocument]" },
.{ "doc.title", "foo" },
.{ "doc.body", "[object HTMLBodyElement]" },
.{ "impl.createDocument(null, 'foo');", "[object Document]" },
.{ "impl.createDocumentType('foo', 'bar', 'baz')", "[object DocumentType]" },
.{ "impl.hasFeature()", "true" },
}, .{});
}

View File

@@ -1,290 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Env = @import("../env.zig").Env;
const Element = @import("element.zig").Element;
pub const Interfaces = .{
IntersectionObserver,
IntersectionObserverEntry,
};
// This is supposed to listen to change between the root and observation targets.
// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes.
// As such, there are no changes to intersections between the root and any target.
// Instead we keep a list of all entries that are being observed.
// The callback is called with all entries everytime a new entry is added(observed).
// Potentially we should also call the callback at a regular interval.
// The returned Entries are phony, they always indicate full intersection.
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
pub const IntersectionObserver = struct {
page: *Page,
callback: Env.Function,
options: IntersectionObserverOptions,
observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry),
// new IntersectionObserver(callback)
// new IntersectionObserver(callback, options) [not supported yet]
pub fn constructor(callback: Env.Function, options_: ?IntersectionObserverOptions, page: *Page) !IntersectionObserver {
var options = IntersectionObserverOptions{
.root = parser.documentToNode(parser.documentHTMLToDocument(page.window.document)),
.rootMargin = "0px 0px 0px 0px",
.threshold = .{ .single = 0.0 },
};
if (options_) |*o| {
if (o.root) |root| {
options.root = root;
} // Other properties are not used due to the way we render
}
return .{
.page = page,
.callback = callback,
.options = options,
.observed_entries = .{},
};
}
pub fn _disconnect(self: *IntersectionObserver) !void {
self.observed_entries = .{}; // We don't free as it is on an arena
}
pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void {
for (self.observed_entries.items) |*observer| {
if (observer.target == target_element) {
return; // Already observed
}
}
try self.observed_entries.append(self.page.arena, .{
.page = self.page,
.target = target_element,
.options = &self.options,
});
var result: Env.Function.Result = undefined;
self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "intersection observer",
});
};
}
pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void {
for (self.observed_entries.items, 0..) |*observer, index| {
if (observer.target == target) {
_ = self.observed_entries.swapRemove(index);
break;
}
}
}
pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry {
return self.observed_entries.items;
}
};
const IntersectionObserverOptions = struct {
root: ?*parser.Node, // Element or Document
rootMargin: ?[]const u8,
threshold: ?Threshold,
const Threshold = union(enum) {
single: f32,
list: []const f32,
};
};
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry
// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry
pub const IntersectionObserverEntry = struct {
page: *Page,
target: *parser.Element,
options: *IntersectionObserverOptions,
// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return Element._getBoundingClientRect(self.target, self.page);
}
// Returns the ratio of the intersectionRect to the boundingClientRect.
pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 {
return 1.0;
}
// Returns a DOMRectReadOnly representing the target's visible area.
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return Element._getBoundingClientRect(self.target, self.page);
}
// A Boolean value which is true if the target element intersects with the
// intersection observer's root. If this is true, then, the
// IntersectionObserverEntry describes a transition into a state of
// intersection; if it's false, then you know the transition is from
// intersecting to not-intersecting.
pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool {
return true;
}
// Returns a DOMRectReadOnly for the intersection observer's root.
pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect {
const root = self.options.root.?;
if (@intFromPtr(root) == @intFromPtr(self.page.window.document)) {
return self.page.renderer.boundingRect();
}
const root_type = try parser.nodeType(root);
var element: *parser.Element = undefined;
switch (root_type) {
.element => element = parser.nodeToElement(root),
.document => {
const doc = parser.nodeToDocument(root);
element = (try parser.documentGetDocumentElement(doc)).?;
},
else => return error.InvalidState,
}
return Element._getBoundingClientRect(element, self.page);
}
// The Element whose intersection with the root changed.
pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element {
return self.target;
}
// TODO: pub fn get_time(self: *const IntersectionObserverEntry)
};
const testing = @import("../../testing.zig");
test "Browser.DOM.IntersectionObserver" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "new IntersectionObserver(() => {}).observe(document.documentElement);", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let count_a = 0;", "undefined" },
.{ "const a1 = document.createElement('div');", "undefined" },
.{ "new IntersectionObserver(entries => {count_a += 1;}).observe(a1);", "undefined" },
.{ "count_a;", "1" },
}, .{});
// This test is documenting current behavior, not correct behavior.
// Currently every time observe is called, the callback is called with all entries.
try runner.testCases(&.{
.{ "let count_b = 0;", "undefined" },
.{ "let observer_b = new IntersectionObserver(entries => {count_b = entries.length;});", "undefined" },
.{ "const b1 = document.createElement('div');", "undefined" },
.{ "observer_b.observe(b1);", "undefined" },
.{ "count_b;", "1" },
.{ "const b2 = document.createElement('div');", "undefined" },
.{ "observer_b.observe(b2);", "undefined" },
.{ "count_b;", "2" },
}, .{});
// Re-observing is a no-op
try runner.testCases(&.{
.{ "let count_bb = 0;", "undefined" },
.{ "let observer_bb = new IntersectionObserver(entries => {count_bb = entries.length;});", "undefined" },
.{ "const bb1 = document.createElement('div');", "undefined" },
.{ "observer_bb.observe(bb1);", "undefined" },
.{ "count_bb;", "1" },
.{ "observer_bb.observe(bb1);", "undefined" },
.{ "count_bb;", "1" }, // Still 1, not 2
}, .{});
// Unobserve
try runner.testCases(&.{
.{ "let count_c = 0;", "undefined" },
.{ "let observer_c = new IntersectionObserver(entries => { count_c = entries.length;});", "undefined" },
.{ "const c1 = document.createElement('div');", "undefined" },
.{ "observer_c.observe(c1);", "undefined" },
.{ "count_c;", "1" },
.{ "observer_c.unobserve(c1);", "undefined" },
.{ "const c2 = document.createElement('div');", "undefined" },
.{ "observer_c.observe(c2);", "undefined" },
.{ "count_c;", "1" },
}, .{});
// Disconnect
try runner.testCases(&.{
.{ "let observer_d = new IntersectionObserver(entries => {});", "undefined" },
.{ "let d1 = document.createElement('div');", "undefined" },
.{ "observer_d.observe(d1);", "undefined" },
.{ "observer_d.disconnect();", "undefined" },
.{ "observer_d.takeRecords().length;", "0" },
}, .{});
// takeRecords
try runner.testCases(&.{
.{ "let observer_e = new IntersectionObserver(entries => {});", "undefined" },
.{ "let e1 = document.createElement('div');", "undefined" },
.{ "observer_e.observe(e1);", "undefined" },
.{ "const e2 = document.createElement('div');", "undefined" },
.{ "observer_e.observe(e2);", "undefined" },
.{ "observer_e.takeRecords().length;", "2" },
}, .{});
// Entry
try runner.testCases(&.{
.{ "let entry;", "undefined" },
.{ "let div1 = document.createElement('div')", null },
.{ "document.body.appendChild(div1);", null },
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1);", null },
.{ "entry.boundingClientRect.x;", "0" },
.{ "entry.intersectionRatio;", "1" },
.{ "entry.intersectionRect.x;", "0" },
.{ "entry.intersectionRect.y;", "0" },
.{ "entry.intersectionRect.width;", "1" },
.{ "entry.intersectionRect.height;", "1" },
.{ "entry.isIntersecting;", "true" },
.{ "entry.rootBounds.x;", "0" },
.{ "entry.rootBounds.y;", "0" },
.{ "entry.rootBounds.width;", "1" },
.{ "entry.rootBounds.height;", "1" },
.{ "entry.target;", "[object HTMLDivElement]" },
}, .{});
// Options
try runner.testCases(&.{
.{ "const new_root = document.createElement('span');", null },
.{ "document.body.appendChild(new_root);", null },
.{ "let new_entry;", "undefined" },
.{
\\ const new_observer = new IntersectionObserver(
\\ entries => { new_entry = entries[0]; },
\\ {root: new_root, rootMargin: '0px 0px 0px 0px', threshold: [0]});
,
"undefined",
},
.{ "new_observer.observe(document.createElement('div'));", "undefined" },
.{ "new_entry.rootBounds.x;", "1" },
}, .{});
}

View File

@@ -1,389 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Env = @import("../env.zig").Env;
const NodeList = @import("nodelist.zig").NodeList;
pub const Interfaces = .{
MutationObserver,
MutationRecord,
};
const Walker = @import("../dom/walker.zig").WalkerChildren;
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
pub const MutationObserver = struct {
cbk: Env.Function,
arena: Allocator,
// List of records which were observed. When the call scope ends, we need to
// execute our callback with it.
observed: std.ArrayListUnmanaged(*MutationRecord),
pub fn constructor(cbk: Env.Function, page: *Page) !MutationObserver {
return .{
.cbk = cbk,
.observed = .{},
.arena = page.arena,
};
}
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
const options = options_ orelse MutationObserverInit{};
const observer = try self.arena.create(Observer);
observer.* = .{
.node = node,
.options = options,
.mutation_observer = self,
.event_node = .{ .id = self.cbk.id, .func = Observer.handle },
};
// register node's events
if (options.childList or options.subtree) {
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMNodeInserted",
&observer.event_node,
false,
);
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMNodeRemoved",
&observer.event_node,
false,
);
}
if (options.attr()) {
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMAttrModified",
&observer.event_node,
false,
);
}
if (options.cdata()) {
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMCharacterDataModified",
&observer.event_node,
false,
);
}
if (options.subtree) {
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMSubtreeModified",
&observer.event_node,
false,
);
}
}
pub fn jsCallScopeEnd(self: *MutationObserver) void {
const record = self.observed.items;
if (record.len == 0) {
return;
}
defer self.observed.clearRetainingCapacity();
for (record) |r| {
const records = [_]MutationRecord{r.*};
var result: Env.Function.Result = undefined;
self.cbk.tryCall(void, .{records}, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "mutation observer",
});
};
}
}
// TODO
pub fn _disconnect(_: *MutationObserver) !void {
// TODO unregister listeners.
}
// TODO
pub fn _takeRecords(_: *const MutationObserver) ?[]const u8 {
return &[_]u8{};
}
};
pub const MutationRecord = struct {
type: []const u8,
target: *parser.Node,
added_nodes: NodeList = .{},
removed_nodes: NodeList = .{},
previous_sibling: ?*parser.Node = null,
next_sibling: ?*parser.Node = null,
attribute_name: ?[]const u8 = null,
attribute_namespace: ?[]const u8 = null,
old_value: ?[]const u8 = null,
pub fn get_type(self: *const MutationRecord) []const u8 {
return self.type;
}
pub fn get_addedNodes(self: *MutationRecord) *NodeList {
return &self.added_nodes;
}
pub fn get_removedNodes(self: *MutationRecord) *NodeList {
return &self.removed_nodes;
}
pub fn get_target(self: *const MutationRecord) *parser.Node {
return self.target;
}
pub fn get_attributeName(self: *const MutationRecord) ?[]const u8 {
return self.attribute_name;
}
pub fn get_attributeNamespace(self: *const MutationRecord) ?[]const u8 {
return self.attribute_namespace;
}
pub fn get_previousSibling(self: *const MutationRecord) ?*parser.Node {
return self.previous_sibling;
}
pub fn get_nextSibling(self: *const MutationRecord) ?*parser.Node {
return self.next_sibling;
}
pub fn get_oldValue(self: *const MutationRecord) ?[]const u8 {
return self.old_value;
}
};
const MutationObserverInit = struct {
childList: bool = false,
attributes: bool = false,
characterData: bool = false,
subtree: bool = false,
attributeOldValue: bool = false,
characterDataOldValue: bool = false,
// TODO
// attributeFilter: [][]const u8,
fn attr(self: MutationObserverInit) bool {
return self.attributes or self.attributeOldValue;
}
fn cdata(self: MutationObserverInit) bool {
return self.characterData or self.characterDataOldValue;
}
};
const Observer = struct {
node: *parser.Node,
options: MutationObserverInit,
// record of the mutation, all observed changes in 1 call are batched
record: ?MutationRecord = null,
// reference back to the MutationObserver so that we can access the arena
// and batch the mutation records.
mutation_observer: *MutationObserver,
event_node: parser.EventNode,
fn appliesTo(o: *const Observer, target: *parser.Node) bool {
// mutation on any target is always ok.
if (o.options.subtree) {
return true;
}
// if target equals node, alway ok.
if (target == o.node) {
return true;
}
// no subtree, no same target and no childlist, always noky.
if (!o.options.childList) {
return false;
}
// target must be a child of o.node
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = walker.get_next(o.node, next) catch break orelse break;
if (next.? == target) {
return true;
}
}
return false;
}
fn handle(en: *parser.EventNode, event: *parser.Event) void {
const self: *Observer = @fieldParentPtr("event_node", en);
self._handle(event) catch |err| {
log.err(.web_api, "handle error", .{ .err = err, .source = "mutation observer" });
};
}
fn _handle(self: *Observer, event: *parser.Event) !void {
var mutation_observer = self.mutation_observer;
const node = blk: {
const event_target = try parser.eventTarget(event) orelse return;
break :blk parser.eventTargetToNode(event_target);
};
if (self.appliesTo(node) == false) {
return;
}
const event_type = blk: {
const t = try parser.eventType(event);
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
};
const arena = mutation_observer.arena;
if (self.record == null) {
self.record = .{
.target = self.node,
.type = event_type.recordType(),
};
try mutation_observer.observed.append(arena, &self.record.?);
}
var record = &self.record.?;
const mutation_event = parser.eventToMutationEvent(event);
switch (event_type) {
.DOMAttrModified => {
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
if (self.options.attributeOldValue) {
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
}
},
.DOMCharacterDataModified => {
if (self.options.characterDataOldValue) {
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
}
},
.DOMNodeInserted => {
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
try record.added_nodes.append(arena, related_node);
}
},
.DOMNodeRemoved => {
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
try record.removed_nodes.append(arena, related_node);
}
},
}
}
};
const MutationEventType = enum {
DOMAttrModified,
DOMCharacterDataModified,
DOMNodeInserted,
DOMNodeRemoved,
fn recordType(self: MutationEventType) []const u8 {
return switch (self) {
.DOMAttrModified => "attributes",
.DOMCharacterDataModified => "characterData",
.DOMNodeInserted => "childList",
.DOMNodeRemoved => "childList",
};
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.MutationObserver" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "new MutationObserver(() => {}).observe(document, { childList: true });", "undefined" },
}, .{});
try runner.testCases(&.{
.{
\\ var nb = 0;
\\ var mrs;
\\ new MutationObserver((mu) => {
\\ mrs = mu;
\\ nb++;
\\ }).observe(document.firstElementChild, { attributes: true, attributeOldValue: true });
\\ document.firstElementChild.setAttribute("foo", "bar");
\\ // ignored b/c it's about another target.
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
\\ nb;
,
"1",
},
.{ "mrs[0].type", "attributes" },
.{ "mrs[0].target == document.firstElementChild", "true" },
.{ "mrs[0].target.getAttribute('foo')", "bar" },
.{ "mrs[0].attributeName", "foo" },
.{ "mrs[0].oldValue", "null" },
}, .{});
try runner.testCases(&.{
.{
\\ var node = document.getElementById("para").firstChild;
\\ var nb2 = 0;
\\ var mrs2;
\\ new MutationObserver((mu) => {
\\ mrs2 = mu;
\\ nb2++;
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
\\ node.data = "foo";
\\ nb2;
,
"1",
},
.{ "mrs2[0].type", "characterData" },
.{ "mrs2[0].target == node", "true" },
.{ "mrs2[0].target.data", "foo" },
.{ "mrs2[0].oldValue", " And" },
}, .{});
// tests that mutation observers that have a callback which trigger the
// mutation observer don't crash.
// https://github.com/lightpanda-io/browser/issues/550
try runner.testCases(&.{
.{
\\ var node = document.getElementById("para");
\\ new MutationObserver(() => {
\\ node.innerText = 'a';
\\ }).observe(document, { subtree:true,childList:true });
\\ node.innerText = "2";
,
"2",
},
}, .{});
}

View File

@@ -1,138 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#namednodemap
pub const NamedNodeMap = struct {
pub const Self = parser.NamedNodeMap;
pub const Exception = DOMException;
pub const Iterator = NamedNodeMapIterator;
// TODO implement LegacyUnenumerableNamedProperties.
// https://webidl.spec.whatwg.org/#LegacyUnenumerableNamedProperties
pub fn get_length(self: *parser.NamedNodeMap) !u32 {
return try parser.namedNodeMapGetLength(self);
}
pub fn _item(self: *parser.NamedNodeMap, index: u32) !?*parser.Attribute {
return try parser.namedNodeMapItem(self, index);
}
pub fn _getNamedItem(self: *parser.NamedNodeMap, qname: []const u8) !?*parser.Attribute {
return try parser.namedNodeMapGetNamedItem(self, qname);
}
pub fn _getNamedItemNS(
self: *parser.NamedNodeMap,
namespace: []const u8,
localname: []const u8,
) !?*parser.Attribute {
return try parser.namedNodeMapGetNamedItemNS(self, namespace, localname);
}
pub fn _setNamedItem(self: *parser.NamedNodeMap, attr: *parser.Attribute) !?*parser.Attribute {
return try parser.namedNodeMapSetNamedItem(self, attr);
}
pub fn _setNamedItemNS(self: *parser.NamedNodeMap, attr: *parser.Attribute) !?*parser.Attribute {
return try parser.namedNodeMapSetNamedItemNS(self, attr);
}
pub fn _removeNamedItem(self: *parser.NamedNodeMap, qname: []const u8) !*parser.Attribute {
return try parser.namedNodeMapRemoveNamedItem(self, qname);
}
pub fn _removeNamedItemNS(
self: *parser.NamedNodeMap,
namespace: []const u8,
localname: []const u8,
) !*parser.Attribute {
return try parser.namedNodeMapRemoveNamedItemNS(self, namespace, localname);
}
pub fn indexed_get(self: *parser.NamedNodeMap, index: u32, has_value: *bool) !*parser.Attribute {
return (try _item(self, index)) orelse {
has_value.* = false;
return undefined;
};
}
pub fn named_get(self: *parser.NamedNodeMap, name: []const u8, has_value: *bool) !*parser.Attribute {
return (try _getNamedItem(self, name)) orelse {
has_value.* = false;
return undefined;
};
}
pub fn _symbol_iterator(self: *parser.NamedNodeMap) NamedNodeMapIterator {
return .{ .map = self };
}
};
pub const NamedNodeMapIterator = struct {
index: u32 = 0,
map: *parser.NamedNodeMap,
pub const Return = struct {
done: bool,
value: ?*parser.Attribute,
};
pub fn _next(self: *NamedNodeMapIterator) !Return {
const e = try NamedNodeMap._item(self.map, self.index);
if (e == null) {
return .{
.value = null,
.done = true,
};
}
self.index += 1;
return .{
.value = e,
.done = false,
};
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.NamedNodeMap" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let a = document.getElementById('content').attributes", "undefined" },
.{ "a.length", "1" },
.{ "a.item(0)", "[object Attr]" },
.{ "a.item(1)", "null" },
.{ "a.getNamedItem('id')", "[object Attr]" },
.{ "a.getNamedItem('foo')", "null" },
.{ "a.setNamedItem(a.getNamedItem('id'))", "[object Attr]" },
.{ "a['id'].name", "id" },
.{ "a['id'].value", "content" },
.{ "a['other']", "undefined" },
}, .{});
}

View File

@@ -1,722 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const generate = @import("../../runtime/generate.zig");
const Page = @import("../page.zig").Page;
const EventTarget = @import("event_target.zig").EventTarget;
// DOM
const Attr = @import("attribute.zig").Attr;
const CData = @import("character_data.zig");
const Element = @import("element.zig").Element;
const NodeList = @import("nodelist.zig").NodeList;
const Document = @import("document.zig").Document;
const DocumentType = @import("document_type.zig").DocumentType;
const DocumentFragment = @import("document_fragment.zig").DocumentFragment;
const HTMLCollection = @import("html_collection.zig").HTMLCollection;
const HTMLAllCollection = @import("html_collection.zig").HTMLAllCollection;
const HTMLCollectionIterator = @import("html_collection.zig").HTMLCollectionIterator;
const Walker = @import("walker.zig").WalkerDepthFirst;
// HTML
const HTML = @import("../html/html.zig");
const HTMLElem = @import("../html/elements.zig");
// Node interfaces
pub const Interfaces = .{
Attr,
CData.CharacterData,
CData.Interfaces,
Element,
Document,
DocumentType,
DocumentFragment,
HTMLCollection,
HTMLAllCollection,
HTMLCollectionIterator,
HTML.Interfaces,
};
pub const Union = generate.Union(Interfaces);
// Node implementation
pub const Node = struct {
pub const Self = parser.Node;
pub const prototype = *EventTarget;
pub const subtype = .node;
pub fn toInterface(node: *parser.Node) !Union {
return switch (try parser.nodeType(node)) {
.element => try HTMLElem.toInterface(
Union,
@as(*parser.Element, @ptrCast(node)),
),
.comment => .{ .Comment = @as(*parser.Comment, @ptrCast(node)) },
.text => .{ .Text = @as(*parser.Text, @ptrCast(node)) },
.cdata_section => .{ .CDATASection = @as(*parser.CDATASection, @ptrCast(node)) },
.processing_instruction => .{ .ProcessingInstruction = @as(*parser.ProcessingInstruction, @ptrCast(node)) },
.document => .{ .HTMLDocument = @as(*parser.DocumentHTML, @ptrCast(node)) },
.document_type => .{ .DocumentType = @as(*parser.DocumentType, @ptrCast(node)) },
.attribute => .{ .Attr = @as(*parser.Attribute, @ptrCast(node)) },
.document_fragment => .{ .DocumentFragment = @as(*parser.DocumentFragment, @ptrCast(node)) },
else => @panic("node type not handled"), // TODO
};
}
// class attributes
pub const _ELEMENT_NODE = @intFromEnum(parser.NodeType.element);
pub const _ATTRIBUTE_NODE = @intFromEnum(parser.NodeType.attribute);
pub const _TEXT_NODE = @intFromEnum(parser.NodeType.text);
pub const _CDATA_SECTION_NODE = @intFromEnum(parser.NodeType.cdata_section);
pub const _PROCESSING_INSTRUCTION_NODE = @intFromEnum(parser.NodeType.processing_instruction);
pub const _COMMENT_NODE = @intFromEnum(parser.NodeType.comment);
pub const _DOCUMENT_NODE = @intFromEnum(parser.NodeType.document);
pub const _DOCUMENT_TYPE_NODE = @intFromEnum(parser.NodeType.document_type);
pub const _DOCUMENT_FRAGMENT_NODE = @intFromEnum(parser.NodeType.document_fragment);
// These 3 are deprecated, but both Chrome and Firefox still expose them
pub const _ENTITY_REFERENCE_NODE = @intFromEnum(parser.NodeType.entity_reference);
pub const _ENTITY_NODE = @intFromEnum(parser.NodeType.entity);
pub const _NOTATION_NODE = @intFromEnum(parser.NodeType.notation);
// JS funcs
// --------
// Read-only attributes
pub fn get_firstChild(self: *parser.Node) !?Union {
const res = try parser.nodeFirstChild(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_lastChild(self: *parser.Node) !?Union {
const res = try parser.nodeLastChild(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_nextSibling(self: *parser.Node) !?Union {
const res = try parser.nodeNextSibling(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_previousSibling(self: *parser.Node) !?Union {
const res = try parser.nodePreviousSibling(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_parentNode(self: *parser.Node) !?Union {
const res = try parser.nodeParentNode(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_parentElement(self: *parser.Node) !?HTMLElem.Union {
const res = try parser.nodeParentElement(self);
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
}
pub fn get_nodeName(self: *parser.Node) ![]const u8 {
return try parser.nodeName(self);
}
pub fn get_nodeType(self: *parser.Node) !u8 {
return @intFromEnum(try parser.nodeType(self));
}
pub fn get_ownerDocument(self: *parser.Node) !?*parser.DocumentHTML {
const res = try parser.nodeOwnerDocument(self);
if (res == null) {
return null;
}
return @as(*parser.DocumentHTML, @ptrCast(res.?));
}
pub fn get_isConnected(self: *parser.Node) !bool {
// TODO: handle Shadow DOM
if (try parser.nodeType(self) == .document) {
return true;
}
return try Node.get_parentNode(self) != null;
}
// Read/Write attributes
pub fn get_nodeValue(self: *parser.Node) !?[]const u8 {
return try parser.nodeValue(self);
}
pub fn set_nodeValue(self: *parser.Node, data: []u8) !void {
try parser.nodeSetValue(self, data);
}
pub fn get_textContent(self: *parser.Node) !?[]const u8 {
return try parser.nodeTextContent(self);
}
pub fn set_textContent(self: *parser.Node, data: []u8) !void {
return try parser.nodeSetTextContent(self, data);
}
// Methods
pub fn _appendChild(self: *parser.Node, child: *parser.Node) !Union {
// TODO: DocumentFragment special case
const res = try parser.nodeAppendChild(self, child);
return try Node.toInterface(res);
}
pub fn _cloneNode(self: *parser.Node, deep: ?bool) !Union {
const clone = try parser.nodeCloneNode(self, deep orelse false);
return try Node.toInterface(clone);
}
pub fn _compareDocumentPosition(self: *parser.Node, other: *parser.Node) !u32 {
if (self == other) return 0;
const docself = try parser.nodeOwnerDocument(self);
const docother = try parser.nodeOwnerDocument(other);
// Both are in different document.
if (docself == null or docother == null or docother.? != docself.?) {
return @intFromEnum(parser.DocumentPosition.disconnected);
}
// TODO Both are in a different trees in the same document.
const w = Walker{};
var next: ?*parser.Node = null;
// Is other a descendant of self?
while (true) {
next = try w.get_next(self, next) orelse break;
if (other == next) {
return @intFromEnum(parser.DocumentPosition.following) +
@intFromEnum(parser.DocumentPosition.contained_by);
}
}
// Is self a descendant of other?
next = null;
while (true) {
next = try w.get_next(other, next) orelse break;
if (self == next) {
return @intFromEnum(parser.DocumentPosition.contains) +
@intFromEnum(parser.DocumentPosition.preceding);
}
}
next = null;
while (true) {
next = try w.get_next(parser.documentToNode(docself.?), next) orelse break;
if (other == next) {
// other precedes self.
return @intFromEnum(parser.DocumentPosition.preceding);
}
if (self == next) {
// other follows self.
return @intFromEnum(parser.DocumentPosition.following);
}
}
return 0;
}
pub fn _contains(self: *parser.Node, other: *parser.Node) !bool {
return try parser.nodeContains(self, other);
}
// Returns itself or ancestor object inheriting from Node.
// - An Element inside a standard web page will return an HTMLDocument object representing the entire page (or <iframe>).
// - An Element inside a shadow DOM will return the associated ShadowRoot.
// - An Element that is not attached to a document or a shadow tree will return the root of the DOM tree it belongs to
pub fn _getRootNode(self: *parser.Node, options: ?struct { composed: bool = false }) !Union {
if (options) |options_| if (options_.composed) {
log.warn(.web_api, "not implemented", .{ .feature = "getRootNode composed" });
};
return try Node.toInterface(try parser.nodeGetRootNode(self));
}
pub fn _hasChildNodes(self: *parser.Node) !bool {
return try parser.nodeHasChildNodes(self);
}
pub fn get_childNodes(self: *parser.Node, page: *Page) !NodeList {
const allocator = page.arena;
var list: NodeList = .{};
var n = try parser.nodeFirstChild(self) orelse return list;
while (true) {
try list.append(allocator, n);
n = try parser.nodeNextSibling(n) orelse return list;
}
}
pub fn _insertBefore(self: *parser.Node, new_node: *parser.Node, ref_node_: ?*parser.Node) !Union {
if (ref_node_) |ref_node| {
return Node.toInterface(try parser.nodeInsertBefore(self, new_node, ref_node));
}
return _appendChild(self, new_node);
}
pub fn _isDefaultNamespace(self: *parser.Node, namespace: ?[]const u8) !bool {
return try parser.nodeIsDefaultNamespace(self, namespace);
}
pub fn _isEqualNode(self: *parser.Node, other: *parser.Node) !bool {
// TODO: other is not an optional parameter, but can be null.
return try parser.nodeIsEqualNode(self, other);
}
pub fn _isSameNode(self: *parser.Node, other: *parser.Node) !bool {
// TODO: other is not an optional parameter, but can be null.
// NOTE: there is no need to use isSameNode(); instead use the === strict equality operator
return try parser.nodeIsSameNode(self, other);
}
pub fn _lookupPrefix(self: *parser.Node, namespace: ?[]const u8) !?[]const u8 {
// TODO: other is not an optional parameter, but can be null.
if (namespace == null) {
return null;
}
if (std.mem.eql(u8, namespace.?, "")) {
return null;
}
return try parser.nodeLookupPrefix(self, namespace.?);
}
pub fn _lookupNamespaceURI(self: *parser.Node, prefix: ?[]const u8) !?[]const u8 {
// TODO: other is not an optional parameter, but can be null.
return try parser.nodeLookupNamespaceURI(self, prefix);
}
pub fn _normalize(self: *parser.Node) !void {
return try parser.nodeNormalize(self);
}
pub fn _removeChild(self: *parser.Node, child: *parser.Node) !Union {
const res = try parser.nodeRemoveChild(self, child);
return try Node.toInterface(res);
}
pub fn _replaceChild(self: *parser.Node, new_child: *parser.Node, old_child: *parser.Node) !Union {
const res = try parser.nodeReplaceChild(self, new_child, old_child);
return try Node.toInterface(res);
}
// Check if the hierarchy node tree constraints are respected.
// For now, it checks only if new nodes are not self.
// TODO implements the others contraints.
// see https://dom.spec.whatwg.org/#concept-node-tree
pub fn hierarchy(self: *parser.Node, nodes: []const NodeOrText) bool {
for (nodes) |n| {
if (n.is(self)) {
return false;
}
}
return true;
}
pub fn prepend(self: *parser.Node, nodes: []const NodeOrText) !void {
if (nodes.len == 0) {
return;
}
// check hierarchy
if (!hierarchy(self, nodes)) {
return parser.DOMError.HierarchyRequest;
}
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
if (try parser.nodeFirstChild(self)) |first| {
for (nodes) |node| {
_ = try parser.nodeInsertBefore(self, try node.toNode(doc), first);
}
return;
}
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
}
}
pub fn append(self: *parser.Node, nodes: []const NodeOrText) !void {
if (nodes.len == 0) {
return;
}
// check hierarchy
if (!hierarchy(self, nodes)) {
return parser.DOMError.HierarchyRequest;
}
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
}
}
pub fn replaceChildren(self: *parser.Node, nodes: []const NodeOrText) !void {
if (nodes.len == 0) {
return;
}
// check hierarchy
if (!hierarchy(self, nodes)) {
return parser.DOMError.HierarchyRequest;
}
// remove existing children
try removeChildren(self);
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
// add new children
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
}
}
pub fn removeChildren(self: *parser.Node) !void {
if (!try parser.nodeHasChildNodes(self)) return;
const children = try parser.nodeGetChildNodes(self);
const ln = try parser.nodeListLength(children);
var i: u32 = 0;
while (i < ln) {
defer i += 1;
// we always retrieve the 0 index child on purpose: libdom nodelist
// are dynamic. So the next child to remove is always as pos 0.
const child = try parser.nodeListItem(children, 0) orelse continue;
_ = try parser.nodeRemoveChild(self, child);
}
}
pub fn before(self: *parser.Node, nodes: []const NodeOrText) !void {
const parent = try parser.nodeParentNode(self) orelse return;
const doc = (try parser.nodeOwnerDocument(parent)) orelse return;
var sibling: ?*parser.Node = self;
// have to find the first sibling that isn't in nodes
CHECK: while (sibling) |s| {
for (nodes) |n| {
if (n.is(s)) {
sibling = try parser.nodePreviousSibling(s);
continue :CHECK;
}
}
break;
}
if (sibling == null) {
sibling = try parser.nodeFirstChild(parent);
}
if (sibling) |ref_node| {
for (nodes) |node| {
_ = try parser.nodeInsertBefore(parent, try node.toNode(doc), ref_node);
}
return;
}
return Node.prepend(self, nodes);
}
pub fn after(self: *parser.Node, nodes: []const NodeOrText) !void {
const parent = try parser.nodeParentNode(self) orelse return;
const doc = (try parser.nodeOwnerDocument(parent)) orelse return;
// have to find the first sibling that isn't in nodes
var sibling = try parser.nodeNextSibling(self);
CHECK: while (sibling) |s| {
for (nodes) |n| {
if (n.is(s)) {
sibling = try parser.nodeNextSibling(s);
continue :CHECK;
}
}
break;
}
if (sibling) |ref_node| {
for (nodes) |node| {
_ = try parser.nodeInsertBefore(parent, try node.toNode(doc), ref_node);
}
return;
}
for (nodes) |node| {
_ = try parser.nodeAppendChild(parent, try node.toNode(doc));
}
}
// A lot of functions take either a node or text input.
// The text input is to be converted into a Text node.
pub const NodeOrText = union(enum) {
text: []const u8,
node: *parser.Node,
fn toNode(self: NodeOrText, doc: *parser.Document) !*parser.Node {
return switch (self) {
.node => |n| n,
.text => |txt| @alignCast(@ptrCast(try parser.documentCreateTextNode(doc, txt))),
};
}
// Whether the node represented by the NodeOrText is the same as the
// given Node. Always false for text values as these represent as-of-yet
// created Text nodes.
fn is(self: NodeOrText, other: *parser.Node) bool {
return switch (self) {
.text => false,
.node => |n| n == other,
};
}
};
};
const testing = @import("../../testing.zig");
test "Browser.DOM.node" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
{
var err_out: ?[]const u8 = null;
try runner.exec(
\\ function trimAndReplace(str) {
\\ str = str.replace(/(\r\n|\n|\r)/gm,'');
\\ str = str.replace(/\s+/g, ' ');
\\ str = str.trim();
\\ return str;
\\ }
, "trimAndReplace", &err_out);
}
try runner.testCases(&.{
.{ "document.body.compareDocumentPosition(document.firstChild); ", "10" },
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"content\"));", "10" },
.{ "document.getElementById(\"content\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "20" },
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"link\"));", "0" },
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"link\"));", "2" },
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "4" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('content').getRootNode().__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
// for next test cases
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let link = document.getElementById('link')", "undefined" },
.{ "let first_child = content.firstChild.nextSibling", "undefined" }, // nextSibling because of line return \n
.{ "let body_first_child = document.body.firstChild", "undefined" },
.{ "body_first_child.localName", "div" },
.{ "body_first_child.__proto__.constructor.name", "HTMLDivElement" },
.{ "document.getElementById('para-empty').firstChild.firstChild", "null" },
}, .{});
try runner.testCases(&.{
.{ "let last_child = content.lastChild.previousSibling", "undefined" }, // previousSibling because of line return \n
.{ "last_child.__proto__.constructor.name", "Comment" },
}, .{});
try runner.testCases(&.{
.{ "let next_sibling = link.nextSibling.nextSibling", "undefined" },
.{ "next_sibling.localName", "p" },
.{ "next_sibling.__proto__.constructor.name", "HTMLParagraphElement" },
.{ "content.nextSibling.nextSibling", "null" },
}, .{});
try runner.testCases(&.{
.{ "let prev_sibling = document.getElementById('para-empty').previousSibling.previousSibling", "undefined" },
.{ "prev_sibling.localName", "a" },
.{ "prev_sibling.__proto__.constructor.name", "HTMLAnchorElement" },
.{ "content.previousSibling", "null" },
}, .{});
try runner.testCases(&.{
.{ "let parent = document.getElementById('para').parentElement", "undefined" },
.{ "parent.localName", "div" },
.{ "parent.__proto__.constructor.name", "HTMLDivElement" },
.{ "let h = content.parentElement.parentElement", "undefined" },
.{ "h.parentElement", "null" },
.{ "h.parentNode.__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
.{ "first_child.nodeName === 'A'", "true" },
.{ "link.firstChild.nodeName === '#text'", "true" },
.{ "last_child.nodeName === '#comment'", "true" },
.{ "document.nodeName === '#document'", "true" },
}, .{});
try runner.testCases(&.{
.{ "first_child.nodeType === 1", "true" },
.{ "link.firstChild.nodeType === 3", "true" },
.{ "last_child.nodeType === 8", "true" },
.{ "document.nodeType === 9", "true" },
}, .{});
try runner.testCases(&.{
.{ "let owner = content.ownerDocument", "undefined" },
.{ "owner.__proto__.constructor.name", "HTMLDocument" },
.{ "document.ownerDocument", "null" },
.{ "let owner2 = document.createElement('div').ownerDocument", "undefined" },
.{ "owner2.__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
.{ "content.isConnected", "true" },
.{ "document.isConnected", "true" },
.{ "document.createElement('div').isConnected", "false" },
}, .{});
try runner.testCases(&.{
.{ "last_child.nodeValue === 'comment'", "true" },
.{ "link.nodeValue === null", "true" },
.{ "let text = link.firstChild", "undefined" },
.{ "text.nodeValue === 'OK'", "true" },
.{ "text.nodeValue = 'OK modified'", "OK modified" },
.{ "text.nodeValue === 'OK modified'", "true" },
.{ "link.nodeValue = 'nothing'", "nothing" },
}, .{});
try runner.testCases(&.{
.{ "text.textContent === 'OK modified'", "true" },
.{ "trimAndReplace(content.textContent) === 'OK modified And'", "true" },
.{ "text.textContent = 'OK'", "OK" },
.{ "text.textContent", "OK" },
.{ "trimAndReplace(document.getElementById('para-empty').textContent)", "" },
.{ "document.getElementById('para-empty').textContent = 'OK'", "OK" },
.{ "document.getElementById('para-empty').firstChild.nodeName === '#text'", "true" },
}, .{});
try runner.testCases(&.{
.{ "let append = document.createElement('h1')", "undefined" },
.{ "content.appendChild(append).toString()", "[object HTMLHeadingElement]" },
.{ "content.lastChild.__proto__.constructor.name", "HTMLHeadingElement" },
.{ "content.appendChild(link).toString()", "[object HTMLAnchorElement]" },
}, .{});
try runner.testCases(&.{
.{ "let clone = link.cloneNode()", "undefined" },
.{ "clone.toString()", "[object HTMLAnchorElement]" },
.{ "clone.parentNode === null", "true" },
.{ "clone.firstChild === null", "true" },
.{ "let clone_deep = link.cloneNode(true)", "undefined" },
.{ "clone_deep.firstChild.nodeName === '#text'", "true" },
}, .{});
try runner.testCases(&.{
.{ "link.contains(text)", "true" },
.{ "text.contains(link)", "false" },
}, .{});
try runner.testCases(&.{
.{ "link.hasChildNodes()", "true" },
.{ "text.hasChildNodes()", "false" },
}, .{});
try runner.testCases(&.{
.{ "link.childNodes.length", "1" },
.{ "text.childNodes.length", "0" },
}, .{});
try runner.testCases(&.{
.{ "let insertBefore = document.createElement('a')", "undefined" },
.{ "link.insertBefore(insertBefore, text) !== undefined", "true" },
.{ "link.firstChild.localName === 'a'", "true" },
.{ "let insertBefore2 = document.createElement('b')", null },
.{ "link.insertBefore(insertBefore2, null).localName", "b" },
.{ "link.childNodes[link.childNodes.length - 1].localName", "b" },
}, .{});
try runner.testCases(&.{
// TODO: does not seems to work
// .{ "link.isDefaultNamespace('')", "true" },
.{ "link.isDefaultNamespace('false')", "false" },
}, .{});
try runner.testCases(&.{
.{ "let equal1 = document.createElement('a')", "undefined" },
.{ "let equal2 = document.createElement('a')", "undefined" },
.{ "equal1.textContent = 'is equal'", "is equal" },
.{ "equal2.textContent = 'is equal'", "is equal" },
// TODO: does not seems to work
// .{ "equal1.isEqualNode(equal2)", "true" },
}, .{});
try runner.testCases(&.{
.{ "document.body.isSameNode(document.body)", "true" },
}, .{});
try runner.testCases(&.{
// TODO: no test
.{ "link.normalize()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "content.removeChild(append) !== undefined", "true" },
.{ "last_child.__proto__.constructor.name !== 'HTMLHeadingElement'", "true" },
}, .{});
try runner.testCases(&.{
.{ "let replace = document.createElement('div')", "undefined" },
.{ "link.replaceChild(replace, insertBefore) !== undefined", "true" },
}, .{});
try runner.testCases(&.{
.{ "Node.ELEMENT_NODE", "1" },
.{ "Node.ATTRIBUTE_NODE", "2" },
.{ "Node.TEXT_NODE", "3" },
.{ "Node.CDATA_SECTION_NODE", "4" },
.{ "Node.PROCESSING_INSTRUCTION_NODE", "7" },
.{ "Node.COMMENT_NODE", "8" },
.{ "Node.DOCUMENT_NODE", "9" },
.{ "Node.DOCUMENT_TYPE_NODE", "10" },
.{ "Node.DOCUMENT_FRAGMENT_NODE", "11" },
.{ "Node.ENTITY_REFERENCE_NODE", "5" },
.{ "Node.ENTITY_NODE", "6" },
.{ "Node.NOTATION_NODE", "12" },
}, .{});
}

View File

@@ -1,52 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
pub const NodeFilter = struct {
pub const _FILTER_ACCEPT: u16 = 1;
pub const _FILTER_REJECT: u16 = 2;
pub const _FILTER_SKIP: u16 = 3;
pub const _SHOW_ALL: u32 = std.math.maxInt(u32);
pub const _SHOW_ELEMENT: u32 = 0b1;
pub const _SHOW_ATTRIBUTE: u32 = 0b10;
pub const _SHOW_TEXT: u32 = 0b100;
pub const _SHOW_CDATA_SECTION: u32 = 0b1000;
pub const _SHOW_ENTITY_REFERENCE: u32 = 0b10000;
pub const _SHOW_ENTITY: u32 = 0b100000;
pub const _SHOW_PROCESSING_INSTRUCTION: u32 = 0b1000000;
pub const _SHOW_COMMENT: u32 = 0b10000000;
pub const _SHOW_DOCUMENT: u32 = 0b100000000;
pub const _SHOW_DOCUMENT_TYPE: u32 = 0b1000000000;
pub const _SHOW_DOCUMENT_FRAGMENT: u32 = 0b10000000000;
pub const _SHOW_NOTATION: u32 = 0b100000000000;
};
const testing = @import("../../testing.zig");
test "Browser.DOM.NodeFilter" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "NodeFilter.FILTER_ACCEPT", "1" },
.{ "NodeFilter.FILTER_REJECT", "2" },
.{ "NodeFilter.FILTER_SKIP", "3" },
.{ "NodeFilter.SHOW_ALL", "4294967295" },
.{ "NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT", "129" },
}, .{});
}

View File

@@ -1,198 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const JsThis = @import("../env.zig").JsThis;
const Function = @import("../env.zig").Function;
const NodeUnion = @import("node.zig").Union;
const Node = @import("node.zig").Node;
const U32Iterator = @import("../iterator/iterator.zig").U32Iterator;
const DOMException = @import("exceptions.zig").DOMException;
pub const Interfaces = .{
NodeListIterator,
NodeList,
};
pub const NodeListIterator = struct {
coll: *NodeList,
index: u32 = 0,
pub const Return = struct {
value: ?NodeUnion,
done: bool,
};
pub fn _next(self: *NodeListIterator) !Return {
const e = try self.coll._item(self.index);
if (e == null) {
return Return{
.value = null,
.done = true,
};
}
self.index += 1;
return Return{
.value = e,
.done = false,
};
}
};
pub const NodeListEntriesIterator = struct {
coll: *NodeList,
index: u32 = 0,
pub const Return = struct {
value: ?NodeUnion,
done: bool,
};
pub fn _next(self: *NodeListEntriesIterator) !Return {
const e = try self.coll._item(self.index);
if (e == null) {
return Return{
.value = null,
.done = true,
};
}
self.index += 1;
return Return{
.value = e,
.done = false,
};
}
};
// Nodelist is implemented in pure Zig b/c libdom's NodeList doesn't allow to
// append nodes.
// WEB IDL https://dom.spec.whatwg.org/#nodelist
//
// TODO: a Nodelist can be either static or live. But the current
// implementation allows only static nodelist.
// see https://dom.spec.whatwg.org/#old-style-collections
pub const NodeList = struct {
pub const Exception = DOMException;
const NodesArrayList = std.ArrayListUnmanaged(*parser.Node);
nodes: NodesArrayList = .{},
pub fn deinit(self: *NodeList, alloc: std.mem.Allocator) void {
// TODO unref all nodes
self.nodes.deinit(alloc);
}
pub fn append(self: *NodeList, alloc: std.mem.Allocator, node: *parser.Node) !void {
try self.nodes.append(alloc, node);
}
pub fn get_length(self: *NodeList) u32 {
return @intCast(self.nodes.items.len);
}
pub fn _item(self: *const NodeList, index: u32) !?NodeUnion {
if (index >= self.nodes.items.len) {
return null;
}
const n = self.nodes.items[index];
return try Node.toInterface(n);
}
// This code works, but it's _MUCH_ slower than using postAttach. The benefit
// of this version, is that it's "live"..but we're talking many orders of
// magnitude slower.
//
// You can test it by commenting out `postAttach`, uncommenting this and
// running:
// zig build wpt -- tests/wpt/dom/nodes/NodeList-static-length-getter-tampered-indexOf-1.html
//
// I think this _is_ the right way to do it, but I must be doing something
// wrong to make it so slow.
// pub fn indexed_get(self: *const NodeList, index: u32, has_value: *bool) !?NodeUnion {
// return (try self._item(index)) orelse {
// has_value.* = false;
// return null;
// };
// }
pub fn _forEach(self: *NodeList, cbk: Function) !void { // TODO handle thisArg
for (self.nodes.items, 0..) |n, i| {
const ii: u32 = @intCast(i);
var result: Function.Result = undefined;
cbk.tryCall(void, .{ n, ii, self }, &result) catch {
log.debug(.user_script, "forEach callback", .{ .err = result.exception, .stack = result.stack });
};
}
}
pub fn _keys(self: *NodeList) U32Iterator {
return .{
.length = self.get_length(),
};
}
pub fn _values(self: *NodeList) NodeListIterator {
return .{
.coll = self,
};
}
pub fn _symbol_iterator(self: *NodeList) NodeListIterator {
return self._values();
}
// TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries
pub fn postAttach(self: *NodeList, js_this: JsThis) !void {
const len = self.get_length();
for (0..len) |i| {
const node = try self._item(@intCast(i)) orelse unreachable;
try js_this.setIndex(@intCast(i), node, .{});
}
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.NodeList" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let list = document.getElementById('content').childNodes", "undefined" },
.{ "list.length", "9" },
.{ "list[0].__proto__.constructor.name", "Text" },
.{
\\ let i = 0;
\\ list.forEach(function (n, idx) {
\\ i += idx;
\\ });
\\ i;
,
"36",
},
}, .{});
}

View File

@@ -1,116 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
const Page = @import("../page.zig").Page;
// https://dom.spec.whatwg.org/#processinginstruction
pub const ProcessingInstruction = struct {
pub const Self = parser.ProcessingInstruction;
// TODO for libdom processing instruction inherit from node.
// But the spec says it must inherit from CDATA.
pub const prototype = *Node;
pub const subtype = .node;
pub fn get_target(self: *parser.ProcessingInstruction) ![]const u8 {
// libdom stores the ProcessingInstruction target in the node's name.
return try parser.nodeName(parser.processingInstructionToNode(self));
}
// There's something wrong when we try to clone a ProcessInstruction normally.
// The resulting object can't be cast back into a node (it crashes). This is
// a simple workaround.
pub fn _cloneNode(self: *parser.ProcessingInstruction, _: ?bool, page: *Page) !*parser.ProcessingInstruction {
return try parser.documentCreateProcessingInstruction(
@ptrCast(page.window.document),
try get_target(self),
(try get_data(self)) orelse "",
);
}
pub fn get_data(self: *parser.ProcessingInstruction) !?[]const u8 {
return try parser.nodeValue(parser.processingInstructionToNode(self));
}
pub fn set_data(self: *parser.ProcessingInstruction, data: []u8) !void {
try parser.nodeSetValue(parser.processingInstructionToNode(self), data);
}
// netsurf's ProcessInstruction doesn't implement the dom_node_get_attributes
// and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.ProcessingInstruction, other_node: *parser.Node) !bool {
if (try parser.nodeType(other_node) != .processing_instruction) {
return false;
}
const other: *parser.ProcessingInstruction = @ptrCast(other_node);
if (std.mem.eql(u8, try get_target(self), try get_target(other)) == false) {
return false;
}
{
const self_data = try get_data(self);
const other_data = try get_data(other);
if (self_data == null and other_data != null) {
return false;
}
if (self_data != null and other_data == null) {
return false;
}
if (std.mem.eql(u8, self_data.?, other_data.?) == false) {
return false;
}
}
return true;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.ProcessingInstruction" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
.{ "pi.target", "foo" },
.{ "pi.data", "bar" },
.{ "pi.data = 'foo'", "foo" },
.{ "pi.data", "foo" },
.{ "let pi2 = pi.cloneNode()", "undefined" },
.{ "pi2.nodeType", "7" },
}, .{});
try runner.testCases(&.{
.{ "let pi11 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
.{ "let pi12 = document.createProcessingInstruction('target2', 'data2');", "undefined" },
.{ "let pi13 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
.{ "pi11.isEqualNode(pi11)", "true" },
.{ "pi11.isEqualNode(pi13)", "true" },
.{ "pi11.isEqualNode(pi12)", "false" },
.{ "pi12.isEqualNode(pi13)", "false" },
.{ "pi11.isEqualNode(document)", "false" },
.{ "document.isEqualNode(pi11)", "false" },
}, .{});
}

View File

@@ -1,86 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const CharacterData = @import("character_data.zig").CharacterData;
const CDATASection = @import("cdata_section.zig").CDATASection;
// Text interfaces
pub const Interfaces = .{
CDATASection,
};
pub const Text = struct {
pub const Self = parser.Text;
pub const prototype = *CharacterData;
pub const subtype = .node;
pub fn constructor(data: ?[]const u8, page: *const Page) !*parser.Text {
return parser.documentCreateTextNode(
parser.documentHTMLToDocument(page.window.document),
data orelse "",
);
}
// JS funcs
// --------
// Read attributes
pub fn get_wholeText(self: *parser.Text) ![]const u8 {
return try parser.textWholdeText(self);
}
// JS methods
// ----------
pub fn _splitText(self: *parser.Text, offset: u32) !*parser.Text {
return try parser.textSplitText(self, offset);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Text" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let t = new Text('foo')", "undefined" },
.{ "t.data", "foo" },
.{ "let emptyt = new Text()", "undefined" },
.{ "emptyt.data", "" },
}, .{});
try runner.testCases(&.{
.{ "let text = document.getElementById('link').firstChild", "undefined" },
.{ "text.wholeText === 'OK'", "true" },
}, .{});
try runner.testCases(&.{
.{ "text.data = 'OK modified'", "OK modified" },
.{ "let split = text.splitText('OK'.length)", "undefined" },
.{ "split.data === ' modified'", "true" },
.{ "text.data === 'OK'", "true" },
}, .{});
}

View File

@@ -1,245 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const iterator = @import("../iterator/iterator.zig");
const Function = @import("../env.zig").Function;
const JsObject = @import("../env.zig").JsObject;
const DOMException = @import("exceptions.zig").DOMException;
pub const Interfaces = .{
DOMTokenList,
DOMTokenListIterable,
TokenListEntriesIterator,
TokenListEntriesIterator.Iterable,
};
// https://dom.spec.whatwg.org/#domtokenlist
pub const DOMTokenList = struct {
pub const Self = parser.TokenList;
pub const Exception = DOMException;
pub fn get_length(self: *parser.TokenList) !u32 {
return parser.tokenListGetLength(self);
}
pub fn _item(self: *parser.TokenList, index: u32) !?[]const u8 {
return parser.tokenListItem(self, index);
}
pub fn _contains(self: *parser.TokenList, token: []const u8) !bool {
return parser.tokenListContains(self, token);
}
pub fn _add(self: *parser.TokenList, tokens: []const []const u8) !void {
for (tokens) |token| {
try parser.tokenListAdd(self, token);
}
}
pub fn _remove(self: *parser.TokenList, tokens: []const []const u8) !void {
for (tokens) |token| {
try parser.tokenListRemove(self, token);
}
}
/// If token is the empty string, then throw a "SyntaxError" DOMException.
/// If token contains any ASCII whitespace, then throw an
/// "InvalidCharacterError" DOMException.
fn validateToken(token: []const u8) !void {
if (token.len == 0) {
return parser.DOMError.Syntax;
}
for (token) |c| {
if (std.ascii.isWhitespace(c)) return parser.DOMError.InvalidCharacter;
}
}
pub fn _toggle(self: *parser.TokenList, token: []const u8, force: ?bool) !bool {
try validateToken(token);
const exists = try parser.tokenListContains(self, token);
if (exists) {
if (force == null or force.? == false) {
try parser.tokenListRemove(self, token);
return false;
}
return true;
}
if (force == null or force.? == true) {
try parser.tokenListAdd(self, token);
return true;
}
return false;
}
pub fn _replace(self: *parser.TokenList, token: []const u8, new: []const u8) !bool {
try validateToken(token);
try validateToken(new);
const exists = try parser.tokenListContains(self, token);
if (!exists) return false;
try parser.tokenListRemove(self, token);
try parser.tokenListAdd(self, new);
return true;
}
// TODO to implement.
pub fn _supports(_: *parser.TokenList, token: []const u8) !bool {
try validateToken(token);
return error.TypeError;
}
pub fn get_value(self: *parser.TokenList) !?[]const u8 {
return (try parser.tokenListGetValue(self)) orelse "";
}
pub fn set_value(self: *parser.TokenList, value: []const u8) !void {
return parser.tokenListSetValue(self, value);
}
pub fn _toString(self: *parser.TokenList) ![]const u8 {
return (try get_value(self)) orelse "";
}
pub fn _keys(self: *parser.TokenList) !iterator.U32Iterator {
return .{ .length = try get_length(self) };
}
pub fn _values(self: *parser.TokenList) DOMTokenListIterable {
return DOMTokenListIterable.init(.{ .token_list = self });
}
pub fn _entries(self: *parser.TokenList) TokenListEntriesIterator {
return TokenListEntriesIterator.init(.{ .token_list = self });
}
pub fn _symbol_iterator(self: *parser.TokenList) DOMTokenListIterable {
return _values(self);
}
// TODO handle thisArg
pub fn _forEach(self: *parser.TokenList, cbk: Function, this_arg: JsObject) !void {
var entries = _entries(self);
while (try entries._next()) |entry| {
var result: Function.Result = undefined;
cbk.tryCallWithThis(void, this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.soure = "tokenList foreach",
});
};
}
}
};
const DOMTokenListIterable = iterator.Iterable(Iterator, "DOMTokenListIterable");
const TokenListEntriesIterator = iterator.NumericEntries(Iterator, "TokenListEntriesIterator");
pub const Iterator = struct {
index: u32 = 0,
token_list: *parser.TokenList,
// used when wrapped in an iterator.NumericEntries
pub const Error = parser.DOMError;
pub fn _next(self: *Iterator) !?[]const u8 {
const index = self.index;
self.index = index + 1;
return DOMTokenList._item(self.token_list, index);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.TokenList" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let gs = document.getElementById('para-empty')", "undefined" },
.{ "let cl = gs.classList", "undefined" },
.{ "gs.className", "ok empty" },
.{ "cl.value", "ok empty" },
.{ "cl.length", "2" },
.{ "gs.className = 'foo bar baz'", "foo bar baz" },
.{ "gs.className", "foo bar baz" },
.{ "cl.length", "3" },
.{ "gs.className = 'ok empty'", "ok empty" },
.{ "cl.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl2 = gs.classList", "undefined" },
.{ "cl2.length", "2" },
.{ "cl2.item(0)", "ok" },
.{ "cl2.item(1)", "empty" },
.{ "cl2.contains('ok')", "true" },
.{ "cl2.contains('nok')", "false" },
.{ "cl2.add('foo', 'bar', 'baz')", "undefined" },
.{ "cl2.length", "5" },
.{ "cl2.remove('foo', 'bar', 'baz')", "undefined" },
.{ "cl2.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl3 = gs.classList", "undefined" },
.{ "cl3.toggle('ok')", "false" },
.{ "cl3.toggle('ok')", "true" },
.{ "cl3.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl4 = gs.classList", "undefined" },
.{ "cl4.replace('ok', 'nok')", "true" },
.{ "cl4.value", "empty nok" },
.{ "cl4.replace('nok', 'ok')", "true" },
.{ "cl4.value", "empty ok" },
}, .{});
try runner.testCases(&.{
.{ "let cl5 = gs.classList", "undefined" },
.{ "let keys = [...cl5.keys()]", "undefined" },
.{ "keys.length", "2" },
.{ "keys[0]", "0" },
.{ "keys[1]", "1" },
.{ "let values = [...cl5.values()]", "undefined" },
.{ "values.length", "2" },
.{ "values[0]", "empty" },
.{ "values[1]", "ok" },
.{ "let entries = [...cl5.entries()]", "undefined" },
.{ "entries.length", "2" },
.{ "entries[0]", "0,empty" },
.{ "entries[1]", "1,ok" },
}, .{});
try runner.testCases(&.{
.{ "let cl6 = gs.classList", "undefined" },
.{ "cl6.value = 'a b ccc'", "a b ccc" },
.{ "cl6.value", "a b ccc" },
.{ "cl6.toString()", "a b ccc" },
}, .{});
}

View File

@@ -1,293 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const NodeFilter = @import("node_filter.zig").NodeFilter;
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
// https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
pub const TreeWalker = struct {
root: *parser.Node,
current_node: *parser.Node,
what_to_show: u32,
filter: ?Env.Function,
pub const TreeWalkerOpts = union(enum) {
function: Env.Function,
object: struct { acceptNode: Env.Function },
};
pub fn init(node: *parser.Node, what_to_show: ?u32, filter: ?TreeWalkerOpts) !TreeWalker {
var filter_func: ?Env.Function = null;
if (filter) |f| {
filter_func = switch (f) {
.function => |func| func,
.object => |o| o.acceptNode,
};
}
return .{
.root = node,
.current_node = node,
.what_to_show = what_to_show orelse NodeFilter._SHOW_ALL,
.filter = filter_func,
};
}
const VerifyResult = enum { accept, skip, reject };
fn verify(self: *const TreeWalker, node: *parser.Node) !VerifyResult {
const node_type = try parser.nodeType(node);
const what_to_show = self.what_to_show;
// Verify that we can show this node type.
if (!switch (node_type) {
.attribute => what_to_show & NodeFilter._SHOW_ATTRIBUTE != 0,
.cdata_section => what_to_show & NodeFilter._SHOW_CDATA_SECTION != 0,
.comment => what_to_show & NodeFilter._SHOW_COMMENT != 0,
.document => what_to_show & NodeFilter._SHOW_DOCUMENT != 0,
.document_fragment => what_to_show & NodeFilter._SHOW_DOCUMENT_FRAGMENT != 0,
.document_type => what_to_show & NodeFilter._SHOW_DOCUMENT_TYPE != 0,
.element => what_to_show & NodeFilter._SHOW_ELEMENT != 0,
.entity => what_to_show & NodeFilter._SHOW_ENTITY != 0,
.entity_reference => what_to_show & NodeFilter._SHOW_ENTITY_REFERENCE != 0,
.notation => what_to_show & NodeFilter._SHOW_NOTATION != 0,
.processing_instruction => what_to_show & NodeFilter._SHOW_PROCESSING_INSTRUCTION != 0,
.text => what_to_show & NodeFilter._SHOW_TEXT != 0,
}) return .reject;
// Verify that we aren't filtering it out.
if (self.filter) |f| {
const filter = try f.call(u32, .{node});
return switch (filter) {
NodeFilter._FILTER_ACCEPT => .accept,
NodeFilter._FILTER_REJECT => .reject,
NodeFilter._FILTER_SKIP => .skip,
else => .reject,
};
} else return .accept;
}
pub fn get_root(self: *TreeWalker) *parser.Node {
return self.root;
}
pub fn get_currentNode(self: *TreeWalker) *parser.Node {
return self.current_node;
}
pub fn get_whatToShow(self: *TreeWalker) u32 {
return self.what_to_show;
}
pub fn get_filter(self: *TreeWalker) ?Env.Function {
return self.filter;
}
pub fn set_currentNode(self: *TreeWalker, node: *parser.Node) !void {
self.current_node = node;
}
fn firstChild(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
const children = try parser.nodeGetChildNodes(node);
const child_count = try parser.nodeListLength(children);
for (0..child_count) |i| {
const index: u32 = @intCast(i);
const child = (try parser.nodeListItem(children, index)) orelse return null;
switch (try self.verify(child)) {
.accept => return child,
.reject => continue,
.skip => if (try self.firstChild(child)) |gchild| return gchild,
}
}
return null;
}
fn lastChild(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
const children = try parser.nodeGetChildNodes(node);
const child_count = try parser.nodeListLength(children);
var index: u32 = child_count;
while (index > 0) {
index -= 1;
const child = (try parser.nodeListItem(children, index)) orelse return null;
switch (try self.verify(child)) {
.accept => return child,
.reject => continue,
.skip => if (try self.lastChild(child)) |gchild| return gchild,
}
}
return null;
}
fn nextSibling(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
var current = node;
while (true) {
current = (try parser.nodeNextSibling(current)) orelse return null;
switch (try self.verify(current)) {
.accept => return current,
.skip, .reject => continue,
}
}
return null;
}
fn previousSibling(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
var current = node;
while (true) {
current = (try parser.nodePreviousSibling(current)) orelse return null;
switch (try self.verify(current)) {
.accept => return current,
.skip, .reject => continue,
}
}
return null;
}
fn parentNode(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
if (self.root == node) return null;
var current = node;
while (true) {
if (current == self.root) return null;
current = (try parser.nodeParentNode(current)) orelse return null;
switch (try self.verify(current)) {
.accept => return current,
.reject, .skip => continue,
}
}
}
pub fn _firstChild(self: *TreeWalker) !?*parser.Node {
if (try self.firstChild(self.current_node)) |child| {
self.current_node = child;
return child;
}
return null;
}
pub fn _lastChild(self: *TreeWalker) !?*parser.Node {
if (try self.lastChild(self.current_node)) |child| {
self.current_node = child;
return child;
}
return null;
}
pub fn _nextNode(self: *TreeWalker) !?*parser.Node {
if (try self.firstChild(self.current_node)) |child| {
self.current_node = child;
return child;
}
var current = self.current_node;
while (current != self.root) {
if (try self.nextSibling(current)) |sibling| {
self.current_node = sibling;
return sibling;
}
current = (try parser.nodeParentNode(current)) orelse break;
}
return null;
}
pub fn _nextSibling(self: *TreeWalker) !?*parser.Node {
if (try self.nextSibling(self.current_node)) |sibling| {
self.current_node = sibling;
return sibling;
}
return null;
}
pub fn _parentNode(self: *TreeWalker) !?*parser.Node {
if (try self.parentNode(self.current_node)) |parent| {
self.current_node = parent;
return parent;
}
return null;
}
pub fn _previousNode(self: *TreeWalker) !?*parser.Node {
var current = self.current_node;
while (try parser.nodePreviousSibling(current)) |previous| {
current = previous;
switch (try self.verify(current)) {
.accept => {
// Get last child if it has one.
if (try self.lastChild(current)) |child| {
self.current_node = child;
return child;
}
// Otherwise, this node is our previous one.
self.current_node = current;
return current;
},
.reject => continue,
.skip => {
// Get last child if it has one.
if (try self.lastChild(current)) |child| {
self.current_node = child;
return child;
}
},
}
}
if (current != self.root) {
if (try self.parentNode(current)) |parent| {
self.current_node = parent;
return parent;
}
}
return null;
}
pub fn _previousSibling(self: *TreeWalker) !?*parser.Node {
if (try self.previousSibling(self.current_node)) |sibling| {
self.current_node = sibling;
return sibling;
}
return null;
}
};

View File

@@ -1,102 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
pub const Walker = union(enum) {
walkerDepthFirst: WalkerDepthFirst,
walkerChildren: WalkerChildren,
walkerNone: WalkerNone,
pub fn get_next(self: Walker, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
switch (self) {
inline else => |case| return case.get_next(root, cur),
}
}
};
// WalkerDepthFirst iterates over the DOM tree to return the next following
// node or null at the end.
//
// This implementation is a zig version of Netsurf code.
// http://source.netsurf-browser.org/libdom.git/tree/src/html/html_collection.c#n177
//
// The iteration is a depth first as required by the specification.
// https://dom.spec.whatwg.org/#htmlcollection
// https://dom.spec.whatwg.org/#concept-tree-order
pub const WalkerDepthFirst = struct {
pub fn get_next(_: WalkerDepthFirst, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
var n = cur orelse root;
// TODO deinit next
if (try parser.nodeFirstChild(n)) |next| {
return next;
}
// TODO deinit next
if (try parser.nodeNextSibling(n)) |next| {
return next;
}
// TODO deinit parent
// Back to the parent of cur.
// If cur has no parent, then the iteration is over.
var parent = try parser.nodeParentNode(n) orelse return null;
// TODO deinit lastchild
var lastchild = try parser.nodeLastChild(parent);
while (n != root and n == lastchild) {
n = parent;
// TODO deinit parent
// Back to the prev's parent.
// If prev has no parent, then the loop must stop.
parent = try parser.nodeParentNode(n) orelse break;
// TODO deinit lastchild
lastchild = try parser.nodeLastChild(parent);
}
if (n == root) {
return null;
}
return try parser.nodeNextSibling(n);
}
};
// WalkerChildren iterates over the root's children only.
pub const WalkerChildren = struct {
pub fn get_next(_: WalkerChildren, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
// On walk start, we return the first root's child.
if (cur == null) return try parser.nodeFirstChild(root);
// If cur is root, then return null.
// This is a special case, if the root is included in the walk, we
// don't want to go further to find children.
if (root == cur.?) return null;
return try parser.nodeNextSibling(cur.?);
}
};
pub const WalkerNone = struct {
pub fn get_next(_: WalkerNone, _: *parser.Node, _: ?*parser.Node) !?*parser.Node {
return null;
}
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -17,218 +17,344 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Slot = @import("webapi/element/html/Slot.zig");
const IFrame = @import("webapi/element/html/IFrame.zig");
const parser = @import("netsurf.zig");
const Walker = @import("dom/walker.zig").WalkerChildren;
const IS_DEBUG = @import("builtin").mode == .Debug;
// writer must be a std.io.Writer
pub fn writeHTML(doc: *parser.Document, writer: anytype) !void {
try writer.writeAll("<!DOCTYPE html>\n");
try writeChildren(parser.documentToNode(doc), writer);
try writer.writeAll("\n");
pub const Opts = struct {
with_base: bool = false,
with_frames: bool = false,
strip: Opts.Strip = .{},
shadow: Opts.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(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
blk: {
// Ideally we just render the doctype which is part of the document
if (doc.asNode().firstChild()) |first| {
if (first._type == .document_type) {
break :blk;
}
}
// But if the doc has no child, or the first child isn't a doctype
// well force it.
try writer.writeAll("<!DOCTYPE html>");
}
// Spec: https://www.w3.org/TR/xml/#sec-prolog-dtd
pub fn writeDocType(doc_type: *parser.DocumentType, writer: anytype) !void {
try writer.writeAll("<!DOCTYPE ");
try writer.writeAll(try parser.documentTypeGetName(doc_type));
if (opts.with_base) {
const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode();
const base = try doc.createElement("base", null, page);
try base.setAttributeSafe(comptime .wrap("base"), .wrap(page.base()), page);
_ = try parent.insertBefore(base.asNode(), parent.firstChild(), page);
}
}
const public_id = try parser.documentTypeGetPublicId(doc_type);
const system_id = try parser.documentTypeGetSystemId(doc_type);
return deep(doc.asNode(), opts, writer, page);
}
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| {
if (node.is(Node.CData.Comment)) |_| {
try writer.writeAll("<!--");
try writer.writeAll(cd.getData().str());
try writer.writeAll("-->");
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
try writer.writeAll("<?");
try writer.writeAll(pi._target);
try writer.writeAll(" ");
try writer.writeAll(cd.getData().str());
try writer.writeAll("?>");
} else {
if (shouldEscapeText(node._parent)) {
try writeEscapedText(cd.getData().str(), writer);
} else {
try writer.writeAll(cd.getData().str());
}
}
},
.element => |el| {
if (shouldStripElement(el, opts)) {
return;
}
// When opts.shadow == .rendered, we normally skip any element with
// a slot attribute. Only the "active" element will get rendered into
// the <slot name="X">. 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(comptime .wrap("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("</slot>");
}
}
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("</");
try writer.writeAll(el.getTagNameDump());
try writer.writeByte('>');
}
return;
}
}
}
if (opts.with_frames and el.is(IFrame) != null) {
const frame = el.as(IFrame);
if (frame.getContentDocument()) |doc| {
// A frame's document should always ahave a page, but
// I'm not willing to crash a release build on that assertion.
if (comptime IS_DEBUG) {
std.debug.assert(doc._page != null);
}
if (doc._page) |frame_page| {
try writer.writeByte('\n');
root(doc, opts, writer, frame_page) catch return error.WriteFailed;
try writer.writeByte('\n');
}
}
} else {
try children(node, opts, writer, page);
}
if (!isVoidElement(el)) {
try writer.writeAll("</");
try writer.writeAll(el.getTagNameDump());
try writer.writeByte('>');
}
},
.document => try children(node, opts, writer, page),
.document_type => |dt| {
try writer.writeAll("<!DOCTYPE ");
try writer.writeAll(dt.getName());
const public_id = dt.getPublicId();
const system_id = dt.getSystemId();
if (public_id.len != 0 and system_id.len != 0) {
try writer.writeAll(" PUBLIC \"");
try writeEscapedAttributeValue(writer, public_id);
try writeEscapedText(public_id, writer);
try writer.writeAll("\" \"");
try writeEscapedAttributeValue(writer, system_id);
try writer.writeAll("\"");
try writeEscapedText(system_id, writer);
try writer.writeByte('"');
} else if (public_id.len != 0) {
try writer.writeAll(" PUBLIC \"");
try writeEscapedAttributeValue(writer, public_id);
try writer.writeAll("\"");
try writeEscapedText(public_id, writer);
try writer.writeByte('"');
} else if (system_id.len != 0) {
try writer.writeAll(" SYSTEM \"");
try writeEscapedAttributeValue(writer, system_id);
try writer.writeAll("\"");
try writeEscapedText(system_id, writer);
try writer.writeByte('"');
}
// Internal subset is not implemented
try writer.writeAll(">");
}
pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
switch (try parser.nodeType(node)) {
.element => {
// open the tag
const tag = try parser.nodeLocalName(node);
try writer.writeAll("<");
try writer.writeAll(tag);
// write the attributes
const _map = try parser.nodeGetAttributes(node);
if (_map) |map| {
const ln = try parser.namedNodeMapGetLength(map);
for (0..ln) |i| {
const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse break;
try writer.writeAll(">\n");
},
.document_fragment => try children(node, opts, writer, page),
.attribute => {
// Not called normally, but can be called via XMLSerializer.serializeToString
// in which case it should return an empty string
try writer.writeAll("");
try writer.writeAll(try parser.attributeGetName(attr));
try writer.writeAll("=\"");
const attribute_value = try parser.attributeGetValue(attr) orelse "";
try writeEscapedAttributeValue(writer, attribute_value);
try writer.writeAll("\"");
}
}
try writer.writeAll(">");
// void elements can't have any content.
if (try isVoid(parser.nodeToElement(node))) return;
// write the children
// TODO avoid recursion
try writeChildren(node, writer);
// close the tag
try writer.writeAll("</");
try writer.writeAll(tag);
try writer.writeAll(">");
},
.text => {
const v = try parser.nodeValue(node) orelse return;
try writeEscapedTextNode(writer, v);
},
.cdata_section => {
const v = try parser.nodeValue(node) orelse return;
try writer.writeAll("<![CDATA[");
try writer.writeAll(v);
try writer.writeAll("]]>");
},
.comment => {
const v = try parser.nodeValue(node) orelse return;
try writer.writeAll("<!--");
try writer.writeAll(v);
try writer.writeAll("-->");
},
// TODO handle processing instruction dump
.processing_instruction => return,
// document fragment is outside of the main document DOM, so we
// don't output it.
.document_fragment => return,
// document will never be called, but required for completeness.
.document => return,
// done globally instead, but required for completeness. Only the outer DOCTYPE should be written
.document_type => return,
// deprecated
.attribute => return,
.entity_reference => return,
.entity => return,
.notation => return,
}
}
// writer must be a std.io.Writer
pub fn writeChildren(root: *parser.Node, writer: anytype) !void {
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = try walker.get_next(root, next) orelse break;
try writeNode(next.?, writer);
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, page);
}
}
// area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr
// https://html.spec.whatwg.org/#void-elements
fn isVoid(elem: *parser.Element) !bool {
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(elem)));
return switch (tag) {
.area, .base, .br, .col, .embed, .hr, .img, .input, .link => true,
.meta, .source, .track, .wbr => true,
pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void {
try writer.beginObject();
try writer.objectField("type");
switch (node.type) {
.cdata => {
try writer.write("cdata");
},
.document => {
try writer.write("document");
},
.document_type => {
try writer.write("document_type");
},
.element => |*el| {
try writer.write("element");
try writer.objectField("tag");
try writer.write(el.tagName());
try writer.objectField("attributes");
try writer.beginObject();
var it = el.attributeIterator();
while (it.next()) |attr| {
try writer.objectField(attr.name);
try writer.write(attr.value);
}
try writer.endObject();
},
}
try writer.objectField("children");
try writer.beginArray();
var it = node.childrenIterator();
while (it.next()) |child| {
try toJSON(child, writer);
}
try writer.endArray();
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, true, 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) {
.br, .hr, .img, .input, .link, .meta => true,
else => false,
},
.svg => false,
};
}
fn writeEscapedTextNode(writer: anytype, value: []const u8) !void {
var v = value;
while (v.len > 0) {
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>' }) orelse {
return writer.writeAll(v);
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(comptime .wrap("as"))) |as| {
if (std.mem.eql(u8, as, "script")) return true;
}
if (el.getAttributeSafe(comptime .wrap("rel"))) |rel| {
if (std.mem.eql(u8, rel, "modulepreload") or std.mem.eql(u8, rel, "preload")) {
if (el.getAttributeSafe(comptime .wrap("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(comptime .wrap("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;
}
fn shouldEscapeText(node_: ?*Node) bool {
const node = node_ orelse return true;
if (node.is(Node.Element.Html.Script) != null) {
return false;
}
// When scripting is enabled, <noscript> is a raw text element per the HTML spec
// (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments).
// Its text content must not be HTML-escaped during serialization.
if (node.is(Node.Element.Html.Generic)) |generic| {
if (generic._tag == .noscript) return false;
}
return true;
}
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {
// Fast path: if no special characters, write directly
const first_special = std.mem.indexOfAnyPos(u8, text, 0, &.{ '&', '<', '>', 194 }) orelse {
return writer.writeAll(text);
};
try writer.writeAll(v[0..index]);
switch (v[index]) {
try writer.writeAll(text[0..first_special]);
var remaining = try writeEscapedByte(text, first_special, writer);
while (std.mem.indexOfAnyPos(u8, remaining, 0, &.{ '&', '<', '>', 194 })) |offset| {
try writer.writeAll(remaining[0..offset]);
remaining = try writeEscapedByte(remaining, offset, writer);
}
if (remaining.len > 0) {
try writer.writeAll(remaining);
}
}
fn writeEscapedByte(input: []const u8, index: usize, writer: *std.Io.Writer) ![]const u8 {
switch (input[index]) {
'&' => try writer.writeAll("&amp;"),
'<' => try writer.writeAll("&lt;"),
'>' => try writer.writeAll("&gt;"),
194 => {
// non breaking space
if (input.len > index + 1 and input[index + 1] == 160) {
try writer.writeAll("&nbsp;");
return input[index + 2 ..];
}
try writer.writeByte(194);
},
else => unreachable,
}
v = v[index + 1 ..];
}
}
fn writeEscapedAttributeValue(writer: anytype, value: []const u8) !void {
var v = value;
while (v.len > 0) {
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>', '"' }) orelse {
return writer.writeAll(v);
};
try writer.writeAll(v[0..index]);
switch (v[index]) {
'&' => try writer.writeAll("&amp;"),
'<' => try writer.writeAll("&lt;"),
'>' => try writer.writeAll("&gt;"),
'"' => try writer.writeAll("&quot;"),
else => unreachable,
}
v = v[index + 1 ..];
}
}
const testing = std.testing;
test "dump.writeHTML" {
try parser.init();
defer parser.deinit();
try testWriteHTML(
"<div id=\"content\">Over 9000!</div>",
"<div id=\"content\">Over 9000!</div>",
);
try testWriteHTML(
"<root><!-- a comment --></root>",
"<root><!-- a comment --></root>",
);
try testWriteHTML(
"<p>&lt; &gt; &amp;</p>",
"<p>&lt; &gt; &amp;</p>",
);
try testWriteHTML(
"<p id=\"&quot;&gt;&lt;&amp;&quot;''\">wat?</p>",
"<p id='\">&lt;&amp;&quot;&#39;&apos;'>wat?</p>",
);
try testWriteFullHTML(
\\<!DOCTYPE html>
\\<html><head><title>It's over what?</title><meta name="a" value="b">
\\</head><body>9000</body></html>
\\
, "<html><title>It's over what?</title><meta name=a value=\"b\">\n<body>9000");
}
fn testWriteHTML(comptime expected_body: []const u8, src: []const u8) !void {
const expected =
"<!DOCTYPE html>\n<html><head></head><body>" ++
expected_body ++
"</body></html>\n";
return testWriteFullHTML(expected, src);
}
fn testWriteFullHTML(comptime expected: []const u8, src: []const u8) !void {
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(testing.allocator);
const doc_html = try parser.documentHTMLParseFromStr(src);
defer parser.documentHTMLClose(doc_html) catch {};
const doc = parser.documentHTMLToDocument(doc_html);
try writeHTML(doc, buf.writer(testing.allocator));
try testing.expectEqualStrings(expected, buf.items);
return input[index + 1 ..];
}

View File

@@ -1,64 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Env = @import("../env.zig").Env;
pub const Interfaces = .{
TextEncoder,
};
// https://encoding.spec.whatwg.org/#interface-textencoder
pub const TextEncoder = struct {
pub fn constructor() !TextEncoder {
return .{};
}
pub fn get_encoding(_: *const TextEncoder) []const u8 {
return "utf-8";
}
pub fn _encode(_: *const TextEncoder, v: []const u8) !Env.TypedArray(u8) {
// Ensure the input is a valid utf-8
// It seems chrome accepts invalid utf-8 sequence.
//
if (!std.unicode.utf8ValidateSlice(v)) {
return error.InvalidUtf8;
}
return .{ .values = v };
}
};
const testing = @import("../../testing.zig");
test "Browser.Encoding.TextEncoder" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "var encoder = new TextEncoder();", "undefined" },
.{ "encoder.encoding;", "utf-8" },
.{ "encoder.encode('€');", "226,130,172" },
// Invalid utf-8 sequence.
// Result with chrome:
// .{ "encoder.encode(new Uint8Array([0xE2,0x28,0xA1]))", "50,50,54,44,52,48,44,49,54,49" },
.{ "try {encoder.encode(new Uint8Array([0xE2,0x28,0xA1])) } catch (e) { e };", "Error: InvalidUtf8" },
}, .{});
}

View File

@@ -1,44 +0,0 @@
const std = @import("std");
const Page = @import("page.zig").Page;
const js = @import("../runtime/js.zig");
const generate = @import("../runtime/generate.zig");
const WebApis = struct {
// Wrapped like this for debug ergonomics.
// When we create our Env, a few lines down, we define it as:
// pub const Env = js.Env(*Page, WebApis);
//
// If there's a compile time error witht he Env, it's type will be readable,
// i.e.: runtime.js.Env(*browser.env.Page, browser.env.WebApis)
//
// But if we didn't wrap it in the struct, like we once didn't, and defined
// env as:
// pub const Env = js.Env(*Page, Interfaces);
//
// Because Interfaces is an anynoumous type, it doesn't have a friendly name
// and errors would be something like:
// runtime.js.Env(*browser.Page, .{...A HUNDRED TYPES...})
pub const Interfaces = generate.Tuple(.{
@import("crypto/crypto.zig").Crypto,
@import("console/console.zig").Console,
@import("cssom/css_style_declaration.zig").Interfaces,
@import("dom/dom.zig").Interfaces,
@import("encoding/text_encoder.zig").Interfaces,
@import("events/event.zig").Interfaces,
@import("html/html.zig").Interfaces,
@import("iterator/iterator.zig").Interfaces,
@import("storage/storage.zig").Interfaces,
@import("url/url.zig").Interfaces,
@import("xhr/xhr.zig").Interfaces,
@import("xhr/form_data.zig").Interfaces,
@import("xmlserializer/xmlserializer.zig").Interfaces,
@import("webcomponents/webcomponents.zig").Interfaces,
});
};
pub const JsThis = Env.JsThis;
pub const JsObject = Env.JsObject;
pub const Function = Env.Function;
pub const Env = js.Env(*Page, WebApis);
pub const Global = @import("html/window.zig").Window;

View File

@@ -1,80 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const Event = @import("event.zig").Event;
const JsObject = @import("../env.zig").JsObject;
// https://dom.spec.whatwg.org/#interface-customevent
pub const CustomEvent = struct {
pub const prototype = *Event;
pub const union_make_copy = true;
proto: parser.Event,
detail: ?JsObject,
const CustomEventInit = struct {
bubbles: bool = false,
cancelable: bool = false,
composed: bool = false,
detail: ?JsObject = null,
};
pub fn constructor(event_type: []const u8, opts_: ?CustomEventInit) !CustomEvent {
const opts = opts_ orelse CustomEventInit{};
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{
.bubbles = opts.bubbles,
.cancelable = opts.cancelable,
.composed = opts.composed,
});
return .{
.proto = event.*,
.detail = if (opts.detail) |d| try d.persist() else null,
};
}
pub fn get_detail(self: *CustomEvent) ?JsObject {
return self.detail;
}
};
const testing = @import("../../testing.zig");
test "Browser.CustomEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let capture = null", "undefined" },
.{ "const el = document.createElement('div');", "undefined" },
.{ "el.addEventListener('c1', (e) => { capture = 'c1-' + new String(e.detail)})", "undefined" },
.{ "el.addEventListener('c2', (e) => { capture = 'c2-' + new String(e.detail.over)})", "undefined" },
.{ "el.dispatchEvent(new CustomEvent('c1'));", "true" },
.{ "capture", "c1-null" },
.{ "el.dispatchEvent(new CustomEvent('c1', {detail: '123'}));", "true" },
.{ "capture", "c1-123" },
.{ "el.dispatchEvent(new CustomEvent('c2', {detail: {over: 9000}}));", "true" },
.{ "capture", "c2-9000" },
}, .{});
}

View File

@@ -1,374 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const generate = @import("../../runtime/generate.zig");
const Page = @import("../page.zig").Page;
const DOMException = @import("../dom/exceptions.zig").DOMException;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventTargetUnion = @import("../dom/event_target.zig").Union;
const CustomEvent = @import("custom_event.zig").CustomEvent;
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
const MouseEvent = @import("mouse_event.zig").MouseEvent;
// Event interfaces
pub const Interfaces = .{
Event,
CustomEvent,
ProgressEvent,
MouseEvent,
};
pub const Union = generate.Union(Interfaces);
// https://dom.spec.whatwg.org/#event
pub const Event = struct {
pub const Self = parser.Event;
pub const Exception = DOMException;
pub const EventInit = parser.EventInit;
// JS
// --
pub const _CAPTURING_PHASE = 1;
pub const _AT_TARGET = 2;
pub const _BUBBLING_PHASE = 3;
pub fn toInterface(evt: *parser.Event) !Union {
return switch (try parser.eventGetInternalType(evt)) {
.event => .{ .Event = evt },
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) },
};
}
pub fn constructor(event_type: []const u8, opts: ?EventInit) !*parser.Event {
const event = try parser.eventCreate();
try parser.eventInit(event, event_type, opts orelse EventInit{});
return event;
}
// Getters
pub fn get_type(self: *parser.Event) ![]const u8 {
return try parser.eventType(self);
}
pub fn get_target(self: *parser.Event, page: *Page) !?EventTargetUnion {
const et = try parser.eventTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?, page);
}
pub fn get_currentTarget(self: *parser.Event, page: *Page) !?EventTargetUnion {
const et = try parser.eventCurrentTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?, page);
}
pub fn get_eventPhase(self: *parser.Event) !u8 {
return try parser.eventPhase(self);
}
pub fn get_bubbles(self: *parser.Event) !bool {
return try parser.eventBubbles(self);
}
pub fn get_cancelable(self: *parser.Event) !bool {
return try parser.eventCancelable(self);
}
pub fn get_defaultPrevented(self: *parser.Event) !bool {
return try parser.eventDefaultPrevented(self);
}
pub fn get_isTrusted(self: *parser.Event) !bool {
return try parser.eventIsTrusted(self);
}
pub fn get_timestamp(self: *parser.Event) !u32 {
return try parser.eventTimestamp(self);
}
// Methods
pub fn _initEvent(
self: *parser.Event,
eventType: []const u8,
bubbles: ?bool,
cancelable: ?bool,
) !void {
const opts = EventInit{
.bubbles = bubbles orelse false,
.cancelable = cancelable orelse false,
};
return try parser.eventInit(self, eventType, opts);
}
pub fn _stopPropagation(self: *parser.Event) !void {
return try parser.eventStopPropagation(self);
}
pub fn _stopImmediatePropagation(self: *parser.Event) !void {
return try parser.eventStopImmediatePropagation(self);
}
pub fn _preventDefault(self: *parser.Event) !void {
return try parser.eventPreventDefault(self);
}
};
pub const EventHandler = struct {
once: bool,
capture: bool,
callback: Function,
node: parser.EventNode,
listener: *parser.EventListener,
const Env = @import("../env.zig").Env;
const Function = Env.Function;
pub const Listener = union(enum) {
function: Function,
object: Env.JsObject,
pub fn callback(self: Listener, target: *parser.EventTarget) !?Function {
return switch (self) {
.function => |func| try func.withThis(target),
.object => |obj| blk: {
const func = (try obj.getFunction("handleEvent")) orelse return null;
break :blk try func.withThis(try obj.persist());
},
};
}
};
pub const Opts = union(enum) {
flags: Flags,
capture: bool,
const Flags = struct {
once: ?bool,
capture: ?bool,
// We ignore this property. It seems to be largely used to help the
// browser make certain performance tweaks (i.e. the browser knows
// that the listener won't call preventDefault() and thus can safely
// run the default as needed).
passive: ?bool,
signal: ?bool, // currently does nothing
};
};
pub fn register(
allocator: Allocator,
target: *parser.EventTarget,
typ: []const u8,
listener: Listener,
opts_: ?Opts,
) !?*EventHandler {
var once = false;
var capture = false;
if (opts_) |opts| {
switch (opts) {
.capture => |c| capture = c,
.flags => |f| {
// Done this way so that, for common cases that _only_ set
// capture, i.e. {captrue: true}, it works.
// But for any case that sets any of the other flags, we
// error. If we don't error, this function call would succeed
// but the behavior might be wrong. At this point, it's
// better to be explicit and error.
if (f.signal orelse false) return error.NotImplemented;
once = f.once orelse false;
capture = f.capture orelse false;
},
}
}
const callback = (try listener.callback(target)) orelse return null;
// check if event target has already this listener
if (try parser.eventTargetHasListener(target, typ, capture, callback.id) != null) {
return null;
}
const eh = try allocator.create(EventHandler);
eh.* = .{
.once = once,
.capture = capture,
.callback = callback,
.node = .{
.id = callback.id,
.func = handle,
},
.listener = undefined,
};
eh.listener = try parser.eventTargetAddEventListener(
target,
typ,
&eh.node,
capture,
);
return eh;
}
fn handle(node: *parser.EventNode, event: *parser.Event) void {
const ievent = Event.toInterface(event) catch |err| {
log.err(.app, "toInterface error", .{ .err = err });
return;
};
const self: *EventHandler = @fieldParentPtr("node", node);
var result: Function.Result = undefined;
self.callback.tryCall(void, .{ievent}, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "event handler",
});
};
if (self.once) {
const target = (parser.eventTarget(event) catch return).?;
const typ = parser.eventType(event) catch return;
parser.eventTargetRemoveEventListener(
target,
typ,
self.listener,
self.capture,
) catch {};
}
}
};
const testing = @import("../../testing.zig");
test "Browser.Event" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let para = document.getElementById('para')", "undefined" },
.{ "var nb = 0; var evt", "undefined" },
}, .{});
try runner.testCases(&.{
.{
\\ content.addEventListener('target', function(e) {
\\ evt = e; nb = nb + 1;
\\ e.preventDefault();
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", "false" },
.{ "nb", "1" },
.{ "evt.target === content", "true" },
.{ "evt.bubbles", "true" },
.{ "evt.cancelable", "true" },
.{ "evt.defaultPrevented", "true" },
.{ "evt.isTrusted", "true" },
.{ "evt.timestamp > 1704063600", "true" }, // 2024/01/01 00:00
// event.type, event.currentTarget, event.phase checked in EventTarget
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('stop',function(e) {
\\ e.stopPropagation();
\\ nb = nb + 1;
\\ }, true)
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ para.addEventListener('stop',function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "para.dispatchEvent(new Event('stop'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('immediate', function(e) {
\\ e.stopImmediatePropagation();
\\ nb = nb + 1;
\\ })
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ content.addEventListener('immediate', function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('immediate'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at first content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('legacy', function(e) {
\\ evt = e; nb = nb + 1;
\\ })
,
"undefined",
},
.{ "let evtLegacy = document.createEvent('Event')", "undefined" },
.{ "evtLegacy.initEvent('legacy')", "undefined" },
.{ "content.dispatchEvent(evtLegacy)", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", "undefined" },
.{ "document.addEventListener('count', cbk)", "undefined" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "0" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; function cbk(event) { nb ++; }", null },
.{ "document.addEventListener('count', cbk, {once: true})", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "1" },
}, .{});
}

View File

@@ -1,140 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = std.log.scoped(.mouse_event);
const parser = @import("../netsurf.zig");
const Event = @import("event.zig").Event;
const JsObject = @import("../env.zig").JsObject;
// TODO: We currently don't have a UIEvent interface so we skip it in the prototype chain.
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent
const UIEvent = Event;
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
pub const MouseEvent = struct {
pub const Self = parser.MouseEvent;
pub const prototype = *UIEvent;
const MouseButton = enum(u16) {
main_button = 0,
auxillary_button = 1,
secondary_button = 2,
fourth_button = 3,
fifth_button = 4,
};
const MouseEventInit = struct {
screenX: i32 = 0,
screenY: i32 = 0,
clientX: i32 = 0,
clientY: i32 = 0,
ctrlKey: bool = false,
shiftKey: bool = false,
altKey: bool = false,
metaKey: bool = false,
button: MouseButton = .main_button,
};
pub fn constructor(event_type: []const u8, opts_: ?MouseEventInit) !*parser.MouseEvent {
const opts = opts_ orelse MouseEventInit{};
var mouse_event = try parser.mouseEventCreate();
try parser.eventSetInternalType(@ptrCast(&mouse_event), .mouse_event);
try parser.mouseEventInit(mouse_event, event_type, .{
.x = opts.clientX,
.y = opts.clientY,
.ctrl = opts.ctrlKey,
.shift = opts.shiftKey,
.alt = opts.altKey,
.meta = opts.metaKey,
.button = @intFromEnum(opts.button),
});
if (!std.mem.eql(u8, event_type, "click")) {
log.warn("MouseEvent currently only supports listeners for 'click' events!", .{});
}
return mouse_event;
}
pub fn get_button(self: *parser.MouseEvent) u16 {
return self.button;
}
// These is just an alias for clientX.
pub fn get_x(self: *parser.MouseEvent) i32 {
return self.cx;
}
// These is just an alias for clientY.
pub fn get_y(self: *parser.MouseEvent) i32 {
return self.cy;
}
pub fn get_clientX(self: *parser.MouseEvent) i32 {
return self.cx;
}
pub fn get_clientY(self: *parser.MouseEvent) i32 {
return self.cy;
}
pub fn get_screenX(self: *parser.MouseEvent) i32 {
return self.sx;
}
pub fn get_screenY(self: *parser.MouseEvent) i32 {
return self.sy;
}
};
const testing = @import("../../testing.zig");
test "Browser.MouseEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
// Default MouseEvent
.{ "let event = new MouseEvent('click')", "undefined" },
.{ "event.type", "click" },
.{ "event instanceof MouseEvent", "true" },
.{ "event instanceof Event", "true" },
.{ "event.clientX", "0" },
.{ "event.clientY", "0" },
.{ "event.screenX", "0" },
.{ "event.screenY", "0" },
// MouseEvent with parameters
.{ "let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20 })", "undefined" },
.{ "new_event.button", "0" },
.{ "new_event.x", "10" },
.{ "new_event.y", "20" },
.{ "new_event.screenX", "10" },
.{ "new_event.screenY", "20" },
// MouseEvent Listener
.{ "let me = new MouseEvent('click')", "undefined" },
.{ "me instanceof Event", "true" },
.{ "var eevt = null; function ccbk(event) { eevt = event; }", "undefined" },
.{ "document.addEventListener('click', ccbk)", "undefined" },
.{ "document.dispatchEvent(me)", "true" },
.{ "eevt.type", "click" },
.{ "eevt instanceof MouseEvent", "true" },
}, .{});
}

View File

@@ -1,396 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Window = @import("window.zig").Window;
const Element = @import("../dom/element.zig").Element;
const ElementUnion = @import("../dom/element.zig").Union;
const Document = @import("../dom/document.zig").Document;
const NodeList = @import("../dom/nodelist.zig").NodeList;
const Location = @import("location.zig").Location;
const collection = @import("../dom/html_collection.zig");
const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
const Cookie = @import("../storage/cookie.zig").Cookie;
// WEB IDL https://html.spec.whatwg.org/#the-document-object
pub const HTMLDocument = struct {
pub const Self = parser.DocumentHTML;
pub const prototype = *Document;
pub const subtype = .node;
// JS funcs
// --------
pub fn get_domain(self: *parser.DocumentHTML) ![]const u8 {
return try parser.documentHTMLGetDomain(self);
}
pub fn set_domain(_: *parser.DocumentHTML, _: []const u8) ![]const u8 {
return error.NotImplemented;
}
pub fn get_referrer(self: *parser.DocumentHTML) ![]const u8 {
return try parser.documentHTMLGetReferrer(self);
}
pub fn set_referrer(_: *parser.DocumentHTML, _: []const u8) ![]const u8 {
return error.NotImplemented;
}
pub fn get_body(self: *parser.DocumentHTML) !?*parser.Body {
return try parser.documentHTMLBody(self);
}
pub fn set_body(self: *parser.DocumentHTML, elt: ?*parser.ElementHTML) !?*parser.Body {
try parser.documentHTMLSetBody(self, elt);
return try get_body(self);
}
pub fn get_head(self: *parser.DocumentHTML) !?*parser.Head {
const root = parser.documentHTMLToNode(self);
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = try walker.get_next(root, next) orelse return null;
if (std.ascii.eqlIgnoreCase("head", try parser.nodeName(next.?))) {
return @as(*parser.Head, @ptrCast(next.?));
}
}
}
pub fn get_cookie(_: *parser.DocumentHTML, page: *Page) ![]const u8 {
var buf: std.ArrayListUnmanaged(u8) = .{};
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true });
return buf.items;
}
pub fn set_cookie(_: *parser.DocumentHTML, cookie_str: []const u8, page: *Page) ![]const u8 {
// we use the cookie jar's allocator to parse the cookie because it
// outlives the page's arena.
const c = try Cookie.parse(page.cookie_jar.allocator, &page.url.uri, cookie_str);
errdefer c.deinit();
try page.cookie_jar.add(c, std.time.timestamp());
return cookie_str;
}
pub fn get_title(self: *parser.DocumentHTML) ![]const u8 {
return try parser.documentHTMLGetTitle(self);
}
pub fn set_title(self: *parser.DocumentHTML, v: []const u8) ![]const u8 {
try parser.documentHTMLSetTitle(self, v);
return v;
}
pub fn _getElementsByName(self: *parser.DocumentHTML, name: []const u8, page: *Page) !NodeList {
const arena = page.arena;
var list: NodeList = .{};
if (name.len == 0) return list;
const root = parser.documentHTMLToNode(self);
var c = try collection.HTMLCollectionByName(arena, root, name, false);
const ln = try c.get_length();
var i: u32 = 0;
while (i < ln) {
const n = try c.item(i) orelse break;
try list.append(arena, n);
i += 1;
}
return list;
}
pub fn get_images(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "img", false);
}
pub fn get_embeds(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "embed", false);
}
pub fn get_plugins(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
return get_embeds(self, page);
}
pub fn get_forms(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "form", false);
}
pub fn get_scripts(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "script", false);
}
pub fn get_applets(_: *parser.DocumentHTML) !collection.HTMLCollection {
return try collection.HTMLCollectionEmpty();
}
pub fn get_links(self: *parser.DocumentHTML) !collection.HTMLCollection {
return try collection.HTMLCollectionByLinks(parser.documentHTMLToNode(self), false);
}
pub fn get_anchors(self: *parser.DocumentHTML) !collection.HTMLCollection {
return try collection.HTMLCollectionByAnchors(parser.documentHTMLToNode(self), false);
}
pub fn get_all(self: *parser.DocumentHTML) collection.HTMLAllCollection {
return collection.HTMLAllCollection.init(parser.documentHTMLToNode(self));
}
pub fn get_currentScript(self: *parser.DocumentHTML) !?*parser.Script {
return try parser.documentHTMLGetCurrentScript(self);
}
pub fn get_location(self: *parser.DocumentHTML) !?*Location {
return try parser.documentHTMLGetLocation(Location, self);
}
pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {
return "off";
}
pub fn set_designMode(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "off";
}
pub fn get_defaultView(_: *parser.DocumentHTML, page: *Page) *Window {
return &page.window;
}
pub fn get_readyState(self: *parser.DocumentHTML, page: *Page) ![]const u8 {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
return @tagName(state.ready_state);
}
// noop legacy functions
// https://html.spec.whatwg.org/#Document-partial
pub fn _clear(_: *parser.DocumentHTML) void {}
pub fn _captureEvents(_: *parser.DocumentHTML) void {}
pub fn _releaseEvents(_: *parser.DocumentHTML) void {}
pub fn get_fgColor(_: *parser.DocumentHTML) []const u8 {
return "";
}
pub fn set_fgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
pub fn get_linkColor(_: *parser.DocumentHTML) []const u8 {
return "";
}
pub fn set_linkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
pub fn get_vlinkColor(_: *parser.DocumentHTML) []const u8 {
return "";
}
pub fn set_vlinkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
pub fn get_alinkColor(_: *parser.DocumentHTML) []const u8 {
return "";
}
pub fn set_alinkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
pub fn get_bgColor(_: *parser.DocumentHTML) []const u8 {
return "";
}
pub fn set_bgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
// Returns the topmost Element at the specified coordinates (relative to the viewport).
// Since LightPanda requires the client to know what they are clicking on we do not return the underlying element at this moment
// This can currenty only happen if the first pixel is clicked without having rendered any element. This will change when css properties are supported.
// This returns an ElementUnion instead of a *Parser.Element in case the element somehow hasn't passed through the js runtime yet.
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) !?ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = page.renderer.getElementAtPosition(ix, iy) orelse return null;
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
return try Element.toInterface(element);
}
// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) ![]ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = page.renderer.getElementAtPosition(ix, iy) orelse return &.{};
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
try list.ensureTotalCapacity(page.call_arena, 3);
list.appendAssumeCapacity(try Element.toInterface(element));
// Since we are using a flat renderer there is no hierarchy of elements. What we do know is that the element is part of the main document.
// Thus we can add the HtmlHtmlElement and it's child HTMLBodyElement to the returned list.
// TBD Should we instead return every parent that is an element? Note that a child does not physically need to be overlapping the parent.
// Should we do a render pass on demand?
const doc_elem = try parser.documentGetDocumentElement(parser.documentHTMLToDocument(page.window.document)) orelse {
return list.items;
};
if (try parser.documentHTMLBody(page.window.document)) |body| {
list.appendAssumeCapacity(try Element.toInterface(parser.bodyToElement(body)));
}
list.appendAssumeCapacity(try Element.toInterface(doc_elem));
return list.items;
}
pub fn documentIsLoaded(self: *parser.DocumentHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.ready_state = .interactive;
const evt = try parser.eventCreate();
defer parser.eventDestroy(evt);
log.debug(.script_event, "dispatch event", .{
.type = "DOMContentLoaded",
.source = "document",
});
try parser.eventInit(evt, "DOMContentLoaded", .{ .bubbles = true, .cancelable = true });
_ = try parser.eventTargetDispatchEvent(parser.toEventTarget(parser.DocumentHTML, self), evt);
}
pub fn documentIsComplete(self: *parser.DocumentHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.ready_state = .complete;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.HTML.Document" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "document.__proto__.constructor.name", "HTMLDocument" },
.{ "document.__proto__.__proto__.constructor.name", "Document" },
.{ "document.body.localName == 'body'", "true" },
}, .{});
try runner.testCases(&.{
.{ "document.domain", "" },
.{ "document.referrer", "" },
.{ "document.title", "" },
.{ "document.body.localName", "body" },
.{ "document.head.localName", "head" },
.{ "document.images.length", "0" },
.{ "document.embeds.length", "0" },
.{ "document.plugins.length", "0" },
.{ "document.scripts.length", "0" },
.{ "document.forms.length", "0" },
.{ "document.links.length", "1" },
.{ "document.applets.length", "0" },
.{ "document.anchors.length", "0" },
.{ "document.all.length", "8" },
.{ "document.currentScript", "null" },
}, .{});
try runner.testCases(&.{
.{ "document.title = 'foo'", "foo" },
.{ "document.title", "foo" },
.{ "document.title = ''", "" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('link').setAttribute('name', 'foo')", "undefined" },
.{ "let list = document.getElementsByName('foo')", "undefined" },
.{ "list.length", "1" },
}, .{});
try runner.testCases(&.{
.{ "document.cookie", "" },
.{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" },
.{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
}, .{});
try runner.testCases(&.{
.{ "document.elementFromPoint(0.5, 0.5)", "null" }, // Return null since we only return element s when they have previously been localized
.{ "document.elementsFromPoint(0.5, 0.5)", "" },
.{
\\ let div1 = document.createElement('div');
\\ document.body.appendChild(div1);
\\ div1.getClientRects();
,
null,
},
.{ "document.elementFromPoint(0.5, 0.5)", "[object HTMLDivElement]" },
.{ "let elems = document.elementsFromPoint(0.5, 0.5)", null },
.{ "elems.length", "3" },
.{ "elems[0]", "[object HTMLDivElement]" },
.{ "elems[1]", "[object HTMLBodyElement]" },
.{ "elems[2]", "[object HTMLHtmlElement]" },
}, .{});
try runner.testCases(&.{
.{
\\ let a = document.createElement('a');
\\ a.href = "https://lightpanda.io";
\\ document.body.appendChild(a);
\\ a.getClientRects();
, // Note this will be placed after the div of previous test
null,
},
.{ "let a_again = document.elementFromPoint(1.5, 0.5)", null },
.{ "a_again", "[object HTMLAnchorElement]" },
.{ "a_again.href", "https://lightpanda.io" },
.{ "let a_agains = document.elementsFromPoint(1.5, 0.5)", null },
.{ "a_agains[0].href", "https://lightpanda.io" },
}, .{});
try runner.testCases(&.{
.{ "!document.all", "true" },
.{ "!!document.all", "false" },
.{ "document.all(5)", "[object HTMLParagraphElement]" },
.{ "document.all('content')", "[object HTMLDivElement]" },
}, .{});
try runner.testCases(&.{
.{ "document.defaultView.document == document", "true" },
}, .{});
try runner.testCases(&.{
.{ "document.readyState", "loading" },
}, .{});
try HTMLDocument.documentIsLoaded(runner.page.window.document, runner.page);
try runner.testCases(&.{
.{ "document.readyState", "interactive" },
}, .{});
try HTMLDocument.documentIsComplete(runner.page.window.document, runner.page);
try runner.testCases(&.{
.{ "document.readyState", "complete" },
}, .{});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface
pub const History = struct {
const ScrollRestorationMode = enum {
auto,
manual,
};
scrollRestoration: ScrollRestorationMode = .auto,
state: std.json.Value = .null,
// count tracks the history length until we implement correctly pushstate.
count: u32 = 0,
pub fn get_length(self: *History) u32 {
// TODO return the real history length value.
return self.count;
}
pub fn get_scrollRestoration(self: *History) []const u8 {
return switch (self.scrollRestoration) {
.auto => "auto",
.manual => "manual",
};
}
pub fn set_scrollRestoration(self: *History, mode: []const u8) void {
if (std.mem.eql(u8, "manual", mode)) self.scrollRestoration = .manual;
if (std.mem.eql(u8, "auto", mode)) self.scrollRestoration = .auto;
}
pub fn get_state(self: *History) std.json.Value {
return self.state;
}
// TODO implement the function
// data must handle any argument. We could expect a std.json.Value but
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
pub fn _pushState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
self.count += 1;
_ = url;
_ = data;
}
// TODO implement the function
// data must handle any argument. We could expect a std.json.Value but
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
pub fn _replaceState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
_ = self;
_ = url;
_ = data;
}
// TODO implement the function
pub fn _go(self: *History, delta: ?i32) void {
_ = self;
_ = delta;
}
// TODO implement the function
pub fn _back(self: *History) void {
_ = self;
}
// TODO implement the function
pub fn _forward(self: *History) void {
_ = self;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.HTML.History" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "history.scrollRestoration", "auto" },
.{ "history.scrollRestoration = 'manual'", "manual" },
.{ "history.scrollRestoration = 'foo'", "foo" },
.{ "history.scrollRestoration", "manual" },
.{ "history.scrollRestoration = 'auto'", "auto" },
.{ "history.scrollRestoration", "auto" },
.{ "history.state", "null" },
.{ "history.pushState({}, null, '')", "undefined" },
.{ "history.replaceState({}, null, '')", "undefined" },
.{ "history.go()", "undefined" },
.{ "history.go(1)", "undefined" },
.{ "history.go(-1)", "undefined" },
.{ "history.forward()", "undefined" },
.{ "history.back()", "undefined" },
}, .{});
}

View File

@@ -1,41 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const HTMLDocument = @import("document.zig").HTMLDocument;
const HTMLElem = @import("elements.zig");
const SVGElem = @import("svg_elements.zig");
const Window = @import("window.zig").Window;
const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History;
const Location = @import("location.zig").Location;
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
const Performance = @import("performance.zig").Performance;
pub const Interfaces = .{
HTMLDocument,
HTMLElem.HTMLElement,
HTMLElem.HTMLMediaElement,
HTMLElem.Interfaces,
SVGElem.SVGElement,
Window,
Navigator,
History,
Location,
MediaQueryList,
Performance,
};

View File

@@ -1,106 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const Page = @import("../page.zig").Page;
const URL = @import("../url/url.zig").URL;
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-location-interface
pub const Location = struct {
url: ?URL = null,
pub fn get_href(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_href(page);
return "";
}
pub fn get_protocol(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_protocol(page);
return "";
}
pub fn get_host(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_host(page);
return "";
}
pub fn get_hostname(self: *Location) []const u8 {
if (self.url) |*u| return u.get_hostname();
return "";
}
pub fn get_port(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_port(page);
return "";
}
pub fn get_pathname(self: *Location) []const u8 {
if (self.url) |*u| return u.get_pathname();
return "";
}
pub fn get_search(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_search(page);
return "";
}
pub fn get_hash(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_hash(page);
return "";
}
pub fn get_origin(self: *Location, page: *Page) ![]const u8 {
if (self.url) |*u| return u.get_origin(page);
return "";
}
pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn _reload(_: *const Location, page: *Page) !void {
return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
}
pub fn _toString(self: *Location, page: *Page) ![]const u8 {
return try self.get_href(page);
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.Location" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "location.href", "https://lightpanda.io/opensource-browser/" },
.{ "document.location.href", "https://lightpanda.io/opensource-browser/" },
.{ "location.host", "lightpanda.io" },
.{ "location.hostname", "lightpanda.io" },
.{ "location.origin", "https://lightpanda.io" },
.{ "location.pathname", "/opensource-browser/" },
.{ "location.hash", "" },
.{ "location.port", "" },
.{ "location.search", "" },
}, .{});
}

View File

@@ -1,45 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const parser = @import("../netsurf.zig");
const Function = @import("../env.zig").Function;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
// https://drafts.csswg.org/cssom-view/#the-mediaquerylist-interface
pub const MediaQueryList = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
// This is not safe as it relies on a structure layout that isn't guaranteed
base: parser.EventTargetTBase = parser.EventTargetTBase{},
matches: bool,
media: []const u8,
pub fn get_matches(self: *const MediaQueryList) bool {
return self.matches;
}
pub fn get_media(self: *const MediaQueryList) []const u8 {
return self.media;
}
pub fn _addListener(_: *const MediaQueryList, _: Function) void {}
pub fn _removeListener(_: *const MediaQueryList, _: Function) void {}
};

View File

@@ -1,96 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
// https://html.spec.whatwg.org/multipage/system-state.html#navigator
pub const Navigator = struct {
agent: []const u8 = "Lightpanda/1.0",
version: []const u8 = "1.0",
vendor: []const u8 = "",
platform: []const u8 = std.fmt.comptimePrint("{any} {any}", .{ builtin.os.tag, builtin.cpu.arch }),
language: []const u8 = "en-US",
pub fn get_userAgent(self: *Navigator) []const u8 {
return self.agent;
}
pub fn get_appCodeName(_: *Navigator) []const u8 {
return "Mozilla";
}
pub fn get_appName(_: *Navigator) []const u8 {
return "Netscape";
}
pub fn get_appVersion(self: *Navigator) []const u8 {
return self.version;
}
pub fn get_platform(self: *Navigator) []const u8 {
return self.platform;
}
pub fn get_product(_: *Navigator) []const u8 {
return "Gecko";
}
pub fn get_productSub(_: *Navigator) []const u8 {
return "20030107";
}
pub fn get_vendor(self: *Navigator) []const u8 {
return self.vendor;
}
pub fn get_vendorSub(_: *Navigator) []const u8 {
return "";
}
pub fn get_language(self: *Navigator) []const u8 {
return self.language;
}
// TODO wait for arrays.
//pub fn get_languages(self: *Navigator) [][]const u8 {
// return .{self.language};
//}
pub fn get_online(_: *Navigator) bool {
return true;
}
pub fn _registerProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
_ = scheme;
_ = url;
}
pub fn _unregisterProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
_ = scheme;
_ = url;
}
pub fn get_cookieEnabled(_: *Navigator) bool {
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.HTML.Navigator" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "navigator.userAgent", "Lightpanda/1.0" },
.{ "navigator.appVersion", "1.0" },
.{ "navigator.language", "en-US" },
}, .{});
}

View File

@@ -1,87 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const EventTarget = @import("../dom/event_target.zig").EventTarget;
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
pub const Performance = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
time_origin: std.time.Timer,
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
// else -> Resolution in non-isolated contexts: 100 microseconds
const ms_resolution = 100;
fn limitedResolutionMs(nanoseconds: u64) f64 {
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
return elapsed / @as(f64, std.time.us_per_ms);
}
pub fn get_timeOrigin(self: *const Performance) f64 {
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
.windows, .uefi, .wasi => false,
else => true,
};
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
const started = self.time_origin.started.since(zero);
return limitedResolutionMs(started);
}
pub fn _now(self: *Performance) f64 {
return limitedResolutionMs(self.time_origin.read());
}
};
const testing = @import("./../../testing.zig");
test "Performance: get_timeOrigin" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
const time_origin = perf.get_timeOrigin();
try testing.expect(time_origin >= 0);
// Check resolution
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
}
test "Performance: now" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
// Monotonically increasing
var now = perf._now();
while (now <= 0) { // Loop for now to not be 0
try testing.expectEqual(now, 0);
now = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
var after = perf._now();
while (after <= now) { // Loop untill after > now
try testing.expectEqual(after, now);
after = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
}

View File

@@ -1,144 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
const HTMLElement = @import("elements.zig").HTMLElement;
const Page = @import("../page.zig").Page;
pub const HTMLSelectElement = struct {
pub const Self = parser.Select;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn get_length(select: *parser.Select) !u32 {
return parser.selectGetLength(select);
}
pub fn get_form(select: *parser.Select) !?*parser.Form {
return parser.selectGetForm(select);
}
pub fn get_name(select: *parser.Select) ![]const u8 {
return parser.selectGetName(select);
}
pub fn set_name(select: *parser.Select, name: []const u8) !void {
return parser.selectSetName(select, name);
}
pub fn get_disabled(select: *parser.Select) !bool {
return parser.selectGetDisabled(select);
}
pub fn set_disabled(select: *parser.Select, disabled: bool) !void {
return parser.selectSetDisabled(select, disabled);
}
pub fn get_multiple(select: *parser.Select) !bool {
return parser.selectGetMultiple(select);
}
pub fn set_multiple(select: *parser.Select, multiple: bool) !void {
return parser.selectSetMultiple(select, multiple);
}
pub fn get_selectedIndex(select: *parser.Select, page: *Page) !i32 {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
const selected_index = try parser.selectGetSelectedIndex(select);
// See the explicit_index_set field documentation
if (!state.explicit_index_set) {
if (selected_index == -1) {
if (try parser.selectGetMultiple(select) == false) {
if (try get_length(select) > 0) {
return 0;
}
}
}
}
return selected_index;
}
// Libdom's dom_html_select_select_set_selected_index will crash if index
// is out of range, and it doesn't properly unset options
pub fn set_selectedIndex(select: *parser.Select, index: i32, page: *Page) !void {
var state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
state.explicit_index_set = true;
const options = try parser.selectGetOptions(select);
const len = try parser.optionCollectionGetLength(options);
for (0..len) |i| {
const option = try parser.optionCollectionItem(options, @intCast(i));
try parser.optionSetSelected(option, false);
}
if (index >= 0 and index < try get_length(select)) {
const option = try parser.optionCollectionItem(options, @intCast(index));
try parser.optionSetSelected(option, true);
}
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.Select" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
\\ <form id=f1>
\\ <select id=s1 name=s1><option>o1<option>o2</select>
\\ </form>
\\ <select id=s2></select>
});
defer runner.deinit();
try runner.testCases(&.{
.{ "const s = document.getElementById('s1');", null },
.{ "s.form", "[object HTMLFormElement]" },
.{ "document.getElementById('s2').form", "null" },
.{ "s.disabled", "false" },
.{ "s.disabled = true", null },
.{ "s.disabled", "true" },
.{ "s.disabled = false", null },
.{ "s.disabled", "false" },
.{ "s.multiple", "false" },
.{ "s.multiple = true", null },
.{ "s.multiple", "true" },
.{ "s.multiple = false", null },
.{ "s.multiple", "false" },
.{ "s.name;", "s1" },
.{ "s.name = 'sel1';", null },
.{ "s.name", "sel1" },
.{ "s.length;", "2" },
.{ "s.selectedIndex", "0" },
.{ "s.selectedIndex = 2", null }, // out of range
.{ "s.selectedIndex", "-1" },
.{ "s.selectedIndex = -1", null },
.{ "s.selectedIndex", "-1" },
.{ "s.selectedIndex = 0", null },
.{ "s.selectedIndex", "0" },
.{ "s.selectedIndex = 1", null },
.{ "s.selectedIndex", "1" },
.{ "s.selectedIndex = -323", null },
.{ "s.selectedIndex", "-1" },
}, .{});
}

View File

@@ -1,41 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const Element = @import("../dom/element.zig").Element;
// Support for SVGElements is very limited, this is a dummy implementation.
// This is here no to be able to support `element instanceof SVGElement;` in JavaScript.
// https://developer.mozilla.org/en-US/docs/Web/API/SVGElement
pub const SVGElement = struct {
// Currently the prototype chain is not implemented (will not be returned by toInterface())
// For that we need parser.SvgElement and the derived types with tags in the v-table.
pub const prototype = *Element;
// While this is a Node, could consider not exposing the subtype untill we have
// a Self type to cast to.
pub const subtype = .node;
};
const testing = @import("../../testing.zig");
test "Browser.HTML.SVGElement" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "'AString' instanceof SVGElement", "false" },
}, .{});
}

View File

@@ -1,390 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Function = @import("../env.zig").Function;
const Page = @import("../page.zig").Page;
const Loop = @import("../../runtime/loop.zig").Loop;
const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History;
const Location = @import("location.zig").Location;
const Crypto = @import("../crypto/crypto.zig").Crypto;
const Console = @import("../console/console.zig").Console;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
const Performance = @import("performance.zig").Performance;
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
const CustomElementRegistry = @import("../webcomponents/custom_element_registry.zig").CustomElementRegistry;
const storage = @import("../storage/storage.zig");
// https://dom.spec.whatwg.org/#interface-window-extensions
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
pub const Window = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
document: *parser.DocumentHTML,
target: []const u8 = "",
history: History = .{},
location: Location = .{},
storage_shelf: ?*storage.Shelf = null,
// counter for having unique timer ids
timer_id: u30 = 0,
timers: std.AutoHashMapUnmanaged(u32, *TimerCallback) = .{},
crypto: Crypto = .{},
console: Console = .{},
navigator: Navigator = .{},
performance: Performance,
custom_elements: CustomElementRegistry = .{},
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
var fbs = std.io.fixedBufferStream("");
const html_doc = try parser.documentHTMLParse(fbs.reader(), "utf-8");
const doc = parser.documentHTMLToDocument(html_doc);
try parser.documentSetDocumentURI(doc, "about:blank");
return .{
.document = html_doc,
.target = target orelse "",
.navigator = navigator orelse .{},
.performance = .{ .time_origin = try std.time.Timer.start() },
};
}
pub fn replaceLocation(self: *Window, loc: Location) !void {
self.location = loc;
try parser.documentHTMLSetLocation(Location, self.document, &self.location);
}
pub fn replaceDocument(self: *Window, doc: *parser.DocumentHTML) !void {
self.performance.time_origin.reset(); // When to reset see: https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin
self.document = doc;
try parser.documentHTMLSetLocation(Location, doc, &self.location);
}
pub fn setStorageShelf(self: *Window, shelf: *storage.Shelf) void {
self.storage_shelf = shelf;
}
pub fn get_window(self: *Window) *Window {
return self;
}
pub fn get_navigator(self: *Window) *Navigator {
return &self.navigator;
}
pub fn get_location(self: *Window) *Location {
return &self.location;
}
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
}
pub fn get_console(self: *Window) *Console {
return &self.console;
}
pub fn get_crypto(self: *Window) *Crypto {
return &self.crypto;
}
pub fn get_self(self: *Window) *Window {
return self;
}
pub fn get_parent(self: *Window) *Window {
return self;
}
// TODO: frames
pub fn get_top(self: *Window) *Window {
return self;
}
pub fn get_document(self: *Window) ?*parser.DocumentHTML {
return self.document;
}
pub fn get_history(self: *Window) *History {
return &self.history;
}
// The interior height of the window in pixels, including the height of the horizontal scroll bar, if present.
pub fn get_innerHeight(_: *Window, page: *Page) u32 {
// We do not have scrollbars or padding so this is the same as Element.clientHeight
return page.renderer.height();
}
// The interior width of the window in pixels. That includes the width of the vertical scroll bar, if one is present.
pub fn get_innerWidth(_: *Window, page: *Page) u32 {
// We do not have scrollbars or padding so this is the same as Element.clientWidth
return page.renderer.width();
}
pub fn get_name(self: *Window) []const u8 {
return self.target;
}
pub fn get_localStorage(self: *Window) !*storage.Bottle {
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
return &self.storage_shelf.?.bucket.local;
}
pub fn get_sessionStorage(self: *Window) !*storage.Bottle {
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
return &self.storage_shelf.?.bucket.session;
}
pub fn get_performance(self: *Window) *Performance {
return &self.performance;
}
pub fn get_customElements(self: *Window) *CustomElementRegistry {
return &self.custom_elements;
}
pub fn _requestAnimationFrame(self: *Window, cbk: Function, page: *Page) !u32 {
return self.createTimeout(cbk, 5, page, .{ .animation_frame = true });
}
pub fn _cancelAnimationFrame(self: *Window, id: u32, page: *Page) !void {
const kv = self.timers.fetchRemove(id) orelse return;
return page.loop.cancel(kv.value.loop_id);
}
// TODO handle callback arguments.
pub fn _setTimeout(self: *Window, cbk: Function, delay: ?u32, page: *Page) !u32 {
return self.createTimeout(cbk, delay, page, .{});
}
// TODO handle callback arguments.
pub fn _setInterval(self: *Window, cbk: Function, delay: ?u32, page: *Page) !u32 {
return self.createTimeout(cbk, delay, page, .{ .repeat = true });
}
pub fn _clearTimeout(self: *Window, id: u32, page: *Page) !void {
const kv = self.timers.fetchRemove(id) orelse return;
return page.loop.cancel(kv.value.loop_id);
}
pub fn _clearInterval(self: *Window, id: u32, page: *Page) !void {
const kv = self.timers.fetchRemove(id) orelse return;
return page.loop.cancel(kv.value.loop_id);
}
pub fn _matchMedia(_: *const Window, media: []const u8, page: *Page) !MediaQueryList {
return .{
.matches = false, // TODO?
.media = try page.arena.dupe(u8, media),
};
}
const CreateTimeoutOpts = struct {
repeat: bool = false,
animation_frame: bool = false,
};
fn createTimeout(self: *Window, cbk: Function, delay_: ?u32, page: *Page, comptime opts: CreateTimeoutOpts) !u32 {
const delay = delay_ orelse 0;
if (delay > 5000) {
log.warn(.user_script, "long timeout ignored", .{ .delay = delay, .interval = opts.repeat });
// self.timer_id is u30, so the largest value we can generate is
// 1_073_741_824. Returning 2_000_000_000 makes sure that clients
// can call cancelTimer/cancelInterval without breaking anything.
return 2_000_000_000;
}
if (self.timers.count() > 512) {
return error.TooManyTimeout;
}
const timer_id = self.timer_id +% 1;
self.timer_id = timer_id;
const arena = page.arena;
const gop = try self.timers.getOrPut(arena, timer_id);
if (gop.found_existing) {
// this can only happen if we've created 2^31 timeouts.
return error.TooManyTimeout;
}
errdefer _ = self.timers.remove(timer_id);
const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms;
const callback = try arena.create(TimerCallback);
callback.* = .{
.cbk = cbk,
.loop_id = 0, // we're going to set this to a real value shortly
.window = self,
.timer_id = timer_id,
.node = .{ .func = TimerCallback.run },
.repeat = if (opts.repeat) delay_ms else null,
.animation_frame = opts.animation_frame,
};
callback.loop_id = try page.loop.timeout(delay_ms, &callback.node);
gop.value_ptr.* = callback;
return timer_id;
}
// TODO: getComputedStyle should return a read-only CSSStyleDeclaration.
// We currently don't have a read-only one, so we return a new instance on
// each call.
pub fn _getComputedStyle(_: *const Window, element: *parser.Element, pseudo_element: ?[]const u8) !CSSStyleDeclaration {
_ = element;
_ = pseudo_element;
return .empty;
}
const ScrollToOpts = union(enum) {
x: i32,
opts: Opts,
const Opts = struct {
top: i32,
left: i32,
behavior: []const u8,
};
};
pub fn _scrollTo(_: *const Window, opts: ScrollToOpts, y: ?u32) void {
_ = opts;
_ = y;
}
};
const TimerCallback = struct {
// the internal loop id, need it when cancelling
loop_id: usize,
// the id of our timer (windows.timers key)
timer_id: u31,
// The JavaScript callback to execute
cbk: Function,
// This is the internal data that the event loop tracks. We'll get this
// back in run and, from it, can get our TimerCallback instance
node: Loop.CallbackNode = undefined,
// if the event should be repeated
repeat: ?u63 = null,
animation_frame: bool = false,
window: *Window,
fn run(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
const self: *TimerCallback = @fieldParentPtr("node", node);
var result: Function.Result = undefined;
var call: anyerror!void = undefined;
if (self.animation_frame) {
call = self.cbk.tryCall(void, .{self.window.performance._now()}, &result);
} else {
call = self.cbk.tryCall(void, .{}, &result);
}
call catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "window timeout",
});
};
if (self.repeat) |r| {
// setInterval
repeat_delay.* = r;
return;
}
// setTimeout
_ = self.window.timers.remove(self.timer_id);
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.Window" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "window.parent === window", "true" },
.{ "window.top === window", "true" },
}, .{});
// requestAnimationFrame should be able to wait by recursively calling itself
// Note however that we in this test do not wait as the request is just send to the browser
try runner.testCases(&.{
.{
\\ let start = 0;
\\ function step(timestamp) {
\\ start = timestamp;
\\ }
,
null,
},
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
.{ " start > 0", "true" },
}, .{});
// cancelAnimationFrame should be able to cancel a request with the given id
try runner.testCases(&.{
.{ "let request_id = requestAnimationFrame(timestamp => {});", null },
.{ "cancelAnimationFrame(request_id);", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "innerHeight", "1" },
.{ "innerWidth", "1" }, // Width is 1 even if there are no elements
.{
\\ let div1 = document.createElement('div');
\\ document.body.appendChild(div1);
\\ div1.getClientRects();
,
null,
},
.{
\\ let div2 = document.createElement('div');
\\ document.body.appendChild(div2);
\\ div2.getClientRects();
,
null,
},
.{ "innerHeight", "1" },
.{ "innerWidth", "2" },
}, .{});
// cancelAnimationFrame should be able to cancel a request with the given id
try runner.testCases(&.{
.{ "let longCall = false;", null },
.{ "window.setTimeout(() => {longCall = true}, 5001);", null },
.{ "longCall;", "false" },
}, .{});
}

View File

@@ -1,226 +0,0 @@
pub const Interfaces = .{
U32Iterator,
};
pub const U32Iterator = struct {
length: u32,
index: u32 = 0,
pub const Return = struct {
value: u32,
done: bool,
};
pub fn _next(self: *U32Iterator) Return {
const i = self.index;
if (i >= self.length) {
return .{
.value = 0,
.done = true,
};
}
self.index = i + 1;
return .{
.value = i,
.done = false,
};
}
// Iterators should be iterable. There's a [JS] example on MDN that
// suggests this is the correct approach:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol
pub fn _symbol_iterator(self: *U32Iterator) *U32Iterator {
return self;
}
};
// A wrapper around an iterator that emits an Iterable result
// An iterable has a next() which emits a {done: bool, value: T} result
pub fn Iterable(comptime T: type, comptime JsName: []const u8) type {
// The inner iterator's return type.
// Maybe an error union.
// Definitely an optional
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
const CanError = @typeInfo(RawValue) == .error_union;
const Value = blk: {
// Unwrap the RawValue
var V = RawValue;
if (CanError) {
V = @typeInfo(V).error_union.payload;
}
break :blk @typeInfo(V).optional.child;
};
const Result = struct {
done: bool,
// todo, technically, we should return undefined when done = true
// or even omit the value;
value: ?Value,
};
const ReturnType = if (CanError) T.Error!Result else Result;
return struct {
// the inner value iterator
inner: T,
// Generics don't generate clean names. Can't just take the resulting
// type name and use that as a the JS class name. So we always ask for
// an explicit JS class name
pub const js_name = JsName;
const Self = @This();
pub fn init(inner: T) Self {
return .{ .inner = inner };
}
pub fn _next(self: *Self) ReturnType {
const value = if (comptime CanError) try self.inner._next() else self.inner._next();
return .{ .done = value == null, .value = value };
}
pub fn _symbol_iterator(self: *Self) *Self {
return self;
}
};
}
// A wrapper around an iterator that emits integer/index keyed entries.
pub fn NumericEntries(comptime T: type, comptime JsName: []const u8) type {
// The inner iterator's return type.
// Maybe an error union.
// Definitely an optional
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
const CanError = @typeInfo(RawValue) == .error_union;
const Value = blk: {
// Unwrap the RawValue
var V = RawValue;
if (CanError) {
V = @typeInfo(V).error_union.payload;
}
break :blk @typeInfo(V).optional.child;
};
const ReturnType = if (CanError) T.Error!?struct { u32, Value } else ?struct { u32, Value };
// Avoid ambiguity. We want to expose a NumericEntries(T).Iterable, so we
// need a declartion inside here for an "Iterable", but that will conflict
// with the above Iterable generic function we have.
const BaseIterable = Iterable;
return struct {
// the inner value iterator
inner: T,
index: u32,
const Self = @This();
// Generics don't generate clean names. Can't just take the resulting
// type name and use that as a the JS class name. So we always ask for
// an explicit JS class name
pub const js_name = JsName;
// re-exposed for when/if we compose this type into an Iterable
pub const Error = T.Error;
// This iterator as an iterable
pub const Iterable = BaseIterable(Self, JsName ++ "Iterable");
pub fn init(inner: T) Self {
return .{ .inner = inner, .index = 0 };
}
pub fn _next(self: *Self) ReturnType {
const value_ = if (comptime CanError) try self.inner._next() else self.inner._next();
const value = value_ orelse return null;
const index = self.index;
self.index = index + 1;
return .{ index, value };
}
// make the iterator, iterable
pub fn _symbol_iterator(self: *Self) Self.Iterable {
return Self.Iterable.init(self.*);
}
};
}
const testing = @import("../../testing.zig");
test "U32Iterator" {
{
var it = U32Iterator{ .length = 0 };
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
}
{
var it = U32Iterator{ .length = 3 };
try testing.expectEqual(.{ .value = 0, .done = false }, it._next());
try testing.expectEqual(.{ .value = 1, .done = false }, it._next());
try testing.expectEqual(.{ .value = 2, .done = false }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
}
}
test "NumericEntries" {
const it = DummyIterator{};
var entries = NumericEntries(DummyIterator, "DummyIterator").init(it);
const v1 = entries._next().?;
try testing.expectEqual(0, v1.@"0");
try testing.expectEqual("it's", v1.@"1");
const v2 = entries._next().?;
try testing.expectEqual(1, v2.@"0");
try testing.expectEqual("over", v2.@"1");
const v3 = entries._next().?;
try testing.expectEqual(2, v3.@"0");
try testing.expectEqual("9000!!", v3.@"1");
try testing.expectEqual(null, entries._next());
try testing.expectEqual(null, entries._next());
try testing.expectEqual(null, entries._next());
}
test "Iterable" {
const it = DummyIterator{};
var entries = Iterable(DummyIterator, "DummyIterator").init(it);
const v1 = entries._next();
try testing.expectEqual(false, v1.done);
try testing.expectEqual("it's", v1.value.?);
const v2 = entries._next();
try testing.expectEqual(false, v2.done);
try testing.expectEqual("over", v2.value.?);
const v3 = entries._next();
try testing.expectEqual(false, v3.done);
try testing.expectEqual("9000!!", v3.value.?);
try testing.expectEqual(true, entries._next().done);
try testing.expectEqual(true, entries._next().done);
try testing.expectEqual(true, entries._next().done);
}
const DummyIterator = struct {
index: u32 = 0,
pub fn _next(self: *DummyIterator) ?[]const u8 {
const index = self.index;
self.index = index + 1;
return switch (index) {
0 => "it's",
1 => "over",
2 => "9000!!",
else => null,
};
}
};

66
src/browser/js/Array.zig Normal file
View File

@@ -0,0 +1,66 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Array = @This();
local: *const js.Local,
handle: *const v8.Array,
pub fn len(self: Array) usize {
return v8.v8__Array__Length(self.handle);
}
pub fn get(self: Array, index: u32) !js.Value {
const ctx = self.local.ctx;
const idx = js.Integer.init(ctx.isolate.handle, index);
const handle = v8.v8__Object__Get(@ptrCast(self.handle), self.local.handle, idx.handle) orelse {
return error.JsException;
};
return .{
.local = self.local,
.handle = handle,
};
}
pub fn set(self: Array, index: u32, value: anytype, comptime opts: js.Caller.CallOpts) !bool {
const js_value = try self.local.zigValueToJs(value, opts);
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetAtIndex(@ptrCast(self.handle), self.local.handle, index, js_value.handle, &out);
return out.has_value;
}
pub fn toObject(self: Array) js.Object {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toValue(self: Array) js.Value {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}

41
src/browser/js/BigInt.zig Normal file
View File

@@ -0,0 +1,41 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const js = @import("js.zig");
const v8 = js.v8;
const BigInt = @This();
handle: *const v8.Integer,
pub fn init(isolate: *v8.Isolate, val: anytype) BigInt {
const handle = switch (@TypeOf(val)) {
i8, i16, i32, i64, isize => v8.v8__BigInt__New(isolate, val).?,
u8, u16, u32, u64, usize => v8.v8__BigInt__NewFromUnsigned(isolate, val).?,
else => |T| @compileError("cannot create v8::BigInt from: " ++ @typeName(T)),
};
return .{ .handle = handle };
}
pub fn getInt64(self: BigInt) i64 {
return v8.v8__BigInt__Int64Value(self.handle, null);
}
pub fn getUint64(self: BigInt) u64 {
return v8.v8__BigInt__Uint64Value(self.handle, null);
}

764
src/browser/js/Caller.zig Normal file
View File

@@ -0,0 +1,764 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
const Page = @import("../Page.zig");
const js = @import("js.zig");
const Local = @import("Local.zig");
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const v8 = js.v8;
const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @This();
local: Local,
prev_local: ?*const js.Local,
prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
initWithContext(self, Context.fromC(v8_context), v8_context);
}
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
ctx.call_depth += 1;
self.* = Caller{
.local = .{
.ctx = ctx,
.handle = v8_context,
.call_arena = ctx.call_arena,
.isolate = ctx.isolate,
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
};
ctx.page.js = ctx;
ctx.local = &self.local;
}
pub fn deinit(self: *Caller) void {
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
// Because of callbacks, calls can be nested. Because of this, we
// can't clear the call_arena after _every_ call. Imagine we have
// arr.forEach((i) => { console.log(i); }
//
// First we call forEach. Inside of our forEach call,
// we call console.log. If we reset the call_arena after this call,
// it'll reset it for the `forEach` call after, which might still
// need the data.
//
// Therefore, we keep a call_depth, and only reset the call_arena
// when a top-level (call_depth == 0) function ends.
if (call_depth == 0) {
const arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.call_arena.ptr));
_ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
}
ctx.call_depth = call_depth;
ctx.local = self.prev_local;
ctx.page.js = self.prev_context;
}
pub const CallOpts = struct {
dom_exception: bool = false,
null_as_undefined: bool = false,
as_typed_array: bool = false,
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) {
handleError(T, @TypeOf(func), local, error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
};
}
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func);
const local = &self.local;
const args = try getArgs(F, 0, local, info);
const res = @call(.auto, func, args);
const ReturnType = @typeInfo(F).@"fn".return_type orelse {
@compileError(@typeName(F) ++ " has a constructor without a return type");
};
const new_this_handle = info.getThis();
var this = js.Object{ .local = local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err;
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else {
this = try local.mapZigInstanceToJs(new_this_handle, res);
}
// If we got back a different object (existing wrapper), copy the prototype
// from new object. (this happens when we're upgrading an CustomElement)
if (this.handle != new_this_handle) {
const prototype_handle = v8.v8__Object__GetPrototype(new_this_handle).?;
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(this.handle, self.local.handle, prototype_handle, &out);
if (comptime IS_DEBUG) {
std.debug.assert(out.has_value and out.value);
}
}
info.getReturnValue().set(this.handle);
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
return 0;
};
}
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getEnumerator(T, local, func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
if (@typeInfo(F).@"fn".params.len == 2) {
@field(args, "1") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: 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))) {
.error_union => |eu| blk: {
break :blk ret catch |err| {
// We can't compare err == error.NotHandled if error.NotHandled
// isn't part of the possible error set. So we first need to check
// if error.NotHandled is part of the error set.
if (isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
// not intercepted
return 0;
}
}
handleError(T, F, local, err, info, opts);
// not intercepted
return 0;
};
},
else => ret,
};
if (comptime with_value) {
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
}
// intercepted
return 1;
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
inline for (@typeInfo(T).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
}
return false;
}
fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T {
const handle = @as(*const v8.String, @ptrCast(name));
if (T == string.String) {
return js.String.toSSO(.{ .local = local, .handle = handle }, false);
}
if (T == string.Global) {
return js.String.toSSO(.{ .local = local, .handle = handle }, true);
}
return try js.String.toSlice(.{ .local = local, .handle = handle });
}
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = local.isolate;
if (comptime IS_DEBUG and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .debug)) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err) == null) {
// This isn't a DOMException, let's log it
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
}
}
}
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {
if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| {
const value = local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle;
}
}
break :blk isolate.createError(@errorName(err));
},
};
const js_exception = isolate.throwException(js_err);
info.getReturnValue().setValueHandle(js_exception);
}
// This is extracted to speed up compilation. When left inlined in handleError,
// this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(local: *const Local, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = serializeFunctionArgs(local, info) catch "failed to serialize args";
log.debug(.js, "function call error", .{
.type = type_name,
.func = func,
.err = err,
.args = args_dump,
.stack = local.stackTrace() catch |err1| @errorName(err1),
});
}
fn serializeFunctionArgs(local: *const Local, info: FunctionCallbackInfo) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator();
for (0..info.length()) |i| {
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
const js_value = info.getArg(@intCast(i), local);
try local.debugValue(js_value, &buf.writer);
}
return buf.written();
}
// Takes a function, and returns a tuple for its argument. Used when we
// @call a function
fn ParameterTypes(comptime F: type) type {
const params = @typeInfo(F).@"fn".params;
var fields: [params.len]std.builtin.Type.StructField = undefined;
inline for (params, 0..) |param, i| {
fields[i] = .{
.name = tupleFieldName(i),
.type = param.type.?,
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(param.type.?),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.decls = &.{},
.fields = &fields,
.is_tuple = true,
} });
}
fn tupleFieldName(comptime i: usize) [:0]const u8 {
return switch (i) {
0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
else => std.fmt.comptimePrint("{d}", .{i}),
};
}
fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page;
}
// These wrap the raw v8 C API to provide a cleaner interface.
pub const FunctionCallbackInfo = struct {
handle: *const v8.FunctionCallbackInfo,
pub fn length(self: FunctionCallbackInfo) u32 {
return @intCast(v8.v8__FunctionCallbackInfo__Length(self.handle));
}
pub fn getArg(self: FunctionCallbackInfo, index: u32, local: *const js.Local) js.Value {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: FunctionCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
fn isConstructCall(self: FunctionCallbackInfo) bool {
return v8.v8__FunctionCallbackInfo__IsConstructCall(self.handle);
}
};
pub const PropertyCallbackInfo = struct {
handle: *const v8.PropertyCallbackInfo,
pub fn getThis(self: PropertyCallbackInfo) *const v8.Object {
return v8.v8__PropertyCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: PropertyCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__PropertyCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
};
const ReturnValue = struct {
handle: v8.ReturnValue,
pub fn set(self: ReturnValue, value: anytype) void {
const T = @TypeOf(value);
if (T == *const v8.Object) {
self.setValueHandle(@ptrCast(value));
} else if (T == *const v8.Value) {
self.setValueHandle(value);
} else if (T == js.Value) {
self.setValueHandle(value.handle);
} else {
@compileError("Unsupported type for ReturnValue.set: " ++ @typeName(T));
}
}
pub fn setValueHandle(self: ReturnValue, handle: *const v8.Value) void {
v8.v8__ReturnValue__Set(self.handle, handle);
}
};
pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
cache: ?Caching = null,
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
// and a Struct.
// 1 - Using the object's internal fields. Think of this as
// adding a field to the struct. It's fast, but the space is reserved
// upfront for _every_ instance, whether we use it or not.
//
// 2 - Using the object's private state with a v8::Private key. Think of
// this as a HashMap. It takes no memory if the cache isn't used
// but has overhead when used.
//
// Consider `window.document`, (1) we have relatively few Window objects,
// (2) They all have a document and (3) The document is accessed _a lot_.
// An internal field makes sense.
//
// Consider `node.childNodes`, (1) we can have 20K+ node objects, (2)
// 95% of nodes will never have their .childNodes access by JavaScript.
// Private map lookup makes sense.
pub const Caching = union(enum) {
internal: u8,
private: []const u8,
};
};
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
const ctx = Context.fromC(v8_context);
const info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined;
hs.initWithIsolateHandle(v8_isolate);
defer hs.deinit();
var cache_state: CacheState = undefined;
if (comptime opts.cache) |cache| {
// This API is a bit weird. On
if (respondFromCache(cache, ctx, v8_context, info, &cache_state)) {
// Value was fetched from the cache and returned already
return;
} else {
// Cache miss: cache_state will have been populated
}
}
var caller: Caller = undefined;
caller.initWithContext(ctx, v8_context);
defer caller.deinit();
const js_value = _call(T, &caller.local, info, func, opts) catch |err| {
handleError(T, @TypeOf(func), &caller.local, err, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
return;
};
if (comptime opts.cache) |cache| {
cache_state.save(cache, js_value);
}
}
fn _call(comptime T: type, local: *const Local, info: FunctionCallbackInfo, func: anytype, comptime opts: Opts) !js.Value {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
if (comptime opts.static) {
args = try getArgs(F, 0, local, info);
} else {
args = try getArgs(F, 1, local, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
}
const res = @call(.auto, func, args);
const js_value = try local.zigValueToJs(res, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
info.getReturnValue().set(js_value);
return js_value;
}
// We can cache a value directly into the v8::Object so that our callback to fetch a property
// can be fast. Generally, think of it like this:
// fn callback(handle: *const v8.FunctionCallbackInfo) callconv(.c) void {
// const js_obj = info.getThis();
// const cached_value = js_obj.getFromCache("Nodes.childNodes");
// info.returnValue().set(cached_value);
// }
//
// That above pseudocode snippet is largely what this respondFromCache is doing.
// But on miss, it's also setting the `cache_state` with all of the data it
// got checking the cache, so that, once we get the value from our Zig code,
// it's quick to store in the v8::Object for subsequent calls.
fn respondFromCache(comptime cache: Opts.Caching, ctx: *Context, v8_context: *const v8.Context, info: FunctionCallbackInfo, cache_state: *CacheState) bool {
const js_this = info.getThis();
const return_value = info.getReturnValue();
switch (cache) {
.internal => |idx| {
if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| {
// means we can't cache undefined, since we can't tell the
// difference between "it isn't in the cache" and "it's
// in the cache with a valud of undefined"
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .internal = idx },
};
},
.private => |private_symbol| {
const global_handle = &@field(ctx.env.private_symbols, private_symbol).handle;
const private_key: *const v8.Private = v8.v8__Global__Get(global_handle, ctx.isolate.handle).?;
if (v8.v8__Object__GetPrivate(js_this, v8_context, private_key)) |cached| {
// This means we can't cache "undefined", since we can't tell
// the difference between a (a) undefined == not in the cache
// and (b) undefined == the cache value. If this becomes
// important, we can check HasPrivate first. But that requires
// calling HasPrivate then GetPrivate.
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .private = private_key },
};
},
}
// cache miss
return false;
}
const CacheState = struct {
js_this: *const v8.Object,
v8_context: *const v8.Context,
mode: union(enum) {
internal: u8,
private: *const v8.Private,
},
pub fn save(self: *const CacheState, comptime cache: Opts.Caching, js_value: js.Value) void {
if (comptime cache == .internal) {
v8.v8__Object__SetInternalField(self.js_this, self.mode.internal, js_value.handle);
} else {
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrivate(self.js_this, self.v8_context, self.mode.private, js_value.handle, &out);
}
}
};
};
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: FunctionCallbackInfo) !ParameterTypes(F) {
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}

1121
src/browser/js/Context.zig Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More