mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-04-03 08:00:34 +00:00
Compare commits
975 Commits
v0.2.4
...
update-zig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fbf462f22 | ||
|
|
cf641ed458 | ||
|
|
0fc959dcc5 | ||
|
|
077376ea04 | ||
|
|
6ed8d1d201 | ||
|
|
5207bd4202 | ||
|
|
11ed95290b | ||
|
|
a876275828 | ||
|
|
e83b8aa36d | ||
|
|
179f9c1169 | ||
|
|
ca41bb5fa2 | ||
|
|
9c37961042 | ||
|
|
0dd0495ab8 | ||
|
|
c9fa76da0c | ||
|
|
7718184e22 | ||
|
|
b81b41cbf0 | ||
|
|
3a0cead03a | ||
|
|
92ce6a916a | ||
|
|
130bf7ba11 | ||
|
|
2e40354a3a | ||
|
|
3074bde2f3 | ||
|
|
ed9f5aae2e | ||
|
|
8e315e551a | ||
|
|
bad690da65 | ||
|
|
ae080f32eb | ||
|
|
c5c1d1f2f8 | ||
|
|
eb18dc89f6 | ||
|
|
afb0c29243 | ||
|
|
267eee9693 | ||
|
|
39352a6bda | ||
|
|
0838b510f8 | ||
|
|
b19f30d865 | ||
|
|
35be9f897f | ||
|
|
d517488158 | ||
|
|
fee8fe7830 | ||
|
|
428190aecc | ||
|
|
61dabdedec | ||
|
|
dfd9f216bd | ||
|
|
567cd97312 | ||
|
|
0bfe00bbb7 | ||
|
|
260768463b | ||
|
|
fd96cd6eb9 | ||
|
|
25a7b5b778 | ||
|
|
d4bcfa974f | ||
|
|
c91eac17d0 | ||
|
|
5c79961bb7 | ||
|
|
a0c200bc49 | ||
|
|
9ea39e1c34 | ||
|
|
f7125d2bf3 | ||
|
|
b163d9709b | ||
|
|
5453630955 | ||
|
|
8ada67637f | ||
|
|
5972630e95 | ||
|
|
58c18114a5 | ||
|
|
a94b0bec93 | ||
|
|
ff0fbb6b41 | ||
|
|
797cae2ef8 | ||
|
|
433c03c709 | ||
|
|
4d3e9feaf4 | ||
|
|
5700e214bf | ||
|
|
88d40a7dcd | ||
|
|
ff209f5adf | ||
|
|
8ad092a960 | ||
|
|
0fcdc1d194 | ||
|
|
60c2359fdd | ||
|
|
08c8ba72f5 | ||
|
|
cfa4201532 | ||
|
|
cb02eb000e | ||
|
|
23334edc05 | ||
|
|
8dbe22a01a | ||
|
|
80235e2ddd | ||
|
|
2abed9fe75 | ||
|
|
35551ac84e | ||
|
|
c3a2318eca | ||
|
|
a6e801be59 | ||
|
|
0bbe25ab5e | ||
|
|
c37286f845 | ||
|
|
34079913a3 | ||
|
|
4f1b499d0f | ||
|
|
c9bc370d6a | ||
|
|
4b29823a5b | ||
|
|
a69a22ccd7 | ||
|
|
a6d2ec7610 | ||
|
|
ad83c6e70b | ||
|
|
c2a0d4c0b2 | ||
|
|
9e7f0b4776 | ||
|
|
e3085cb0f1 | ||
|
|
4e2e895cd9 | ||
|
|
c1fc2b1301 | ||
|
|
324e5eb152 | ||
|
|
df4df64066 | ||
|
|
c557a0fd87 | ||
|
|
a869f92e9a | ||
|
|
4d28265839 | ||
|
|
78c6def2b1 | ||
|
|
87a0690776 | ||
|
|
fbc71d6ff7 | ||
|
|
e10ccd846d | ||
|
|
384b2f7614 | ||
|
|
fdc79af55c | ||
|
|
e9bed18cd8 | ||
|
|
30f387d361 | ||
|
|
e7d272eaf6 | ||
|
|
00d06dbe8c | ||
|
|
7b104789aa | ||
|
|
2107ade3a5 | ||
|
|
e60424a402 | ||
|
|
107da49f81 | ||
|
|
3e309da69f | ||
|
|
370ae2b85c | ||
|
|
6008187c78 | ||
|
|
598fa254cf | ||
|
|
8526770e9f | ||
|
|
21325ca9be | ||
|
|
b5b012bd5d | ||
|
|
b4b7a7d58a | ||
|
|
a5378feb1d | ||
|
|
b5d3d37f16 | ||
|
|
9b02e4963b | ||
|
|
a865b86fa5 | ||
|
|
de28d14aff | ||
|
|
2d91acbd14 | ||
|
|
88681b1fdb | ||
|
|
1feb121ba7 | ||
|
|
35cdc3c348 | ||
|
|
1353f76bf1 | ||
|
|
3e2be5b317 | ||
|
|
448eca0c32 | ||
|
|
5404ca723c | ||
|
|
e56ffe4b60 | ||
|
|
02d05ae464 | ||
|
|
a74e97854d | ||
|
|
6925fc3f70 | ||
|
|
84557cb4e6 | ||
|
|
4cdc24326a | ||
|
|
cf46f0097a | ||
|
|
f1293b7346 | ||
|
|
d94fd2a43b | ||
|
|
8c5e737669 | ||
|
|
fb29a1c5bf | ||
|
|
94190f93af | ||
|
|
93e239f682 | ||
|
|
a4cb5031d1 | ||
|
|
a2e59af44c | ||
|
|
00c962bdd8 | ||
|
|
1fa87442b8 | ||
|
|
ac5400696a | ||
|
|
5062273b7a | ||
|
|
9c2393351d | ||
|
|
f0cfe3ffc8 | ||
|
|
f70865e174 | ||
|
|
615fcffb99 | ||
|
|
13b746f9e4 | ||
|
|
e90fce4c55 | ||
|
|
59175437b5 | ||
|
|
e950384b9b | ||
|
|
78440350dc | ||
|
|
f435297949 | ||
|
|
54d1563cf3 | ||
|
|
38e9f86088 | ||
|
|
d9c5f56500 | ||
|
|
6c5733bba3 | ||
|
|
b8f1622b52 | ||
|
|
f36499b806 | ||
|
|
fa1dd5237d | ||
|
|
2b9d5fd4d9 | ||
|
|
2dbd32d120 | ||
|
|
1695ea81d2 | ||
|
|
b7bf86fd85 | ||
|
|
94d8f90a96 | ||
|
|
964fa0a8aa | ||
|
|
db01158d2d | ||
|
|
e997f8317e | ||
|
|
b9bef22bbf | ||
|
|
b2a996e5c7 | ||
|
|
a88c21cdb5 | ||
|
|
e2be8525c4 | ||
|
|
c15afa23ca | ||
|
|
7a7c4b9f49 | ||
|
|
f594b033bf | ||
|
|
10e379e4fb | ||
|
|
c1bb27c450 | ||
|
|
dda5e2c542 | ||
|
|
edd0c5c83f | ||
|
|
c6861829c3 | ||
|
|
e14c8b3025 | ||
|
|
5bc00c595c | ||
|
|
db5fb40de0 | ||
|
|
4e6a357e6e | ||
|
|
6cf515151d | ||
|
|
bf6e4cf3a6 | ||
|
|
60936baa96 | ||
|
|
c29f72a7e8 | ||
|
|
d4427e4370 | ||
|
|
b85ec04175 | ||
|
|
da05ba0eb7 | ||
|
|
414a68abeb | ||
|
|
52455b732b | ||
|
|
ba71268eb3 | ||
|
|
694aac5ce8 | ||
|
|
cbab0b712a | ||
|
|
1aee3db521 | ||
|
|
e29778d72b | ||
|
|
f634c9843d | ||
|
|
e1e45d1c5d | ||
|
|
09327c3897 | ||
|
|
ff288c8aa2 | ||
|
|
e1b14a6833 | ||
|
|
015edc3848 | ||
|
|
bd2406f803 | ||
|
|
3c29e7dbd4 | ||
|
|
586413357e | ||
|
|
9a055a61a6 | ||
|
|
5fb561dc9c | ||
|
|
b14ae02548 | ||
|
|
51fb08e6aa | ||
|
|
a6d699ad5d | ||
|
|
8372b45cc5 | ||
|
|
1739ae6b9a | ||
|
|
ba62150f7a | ||
|
|
8143a61955 | ||
|
|
e16c479781 | ||
|
|
c0c4e26d63 | ||
|
|
b252aa71d0 | ||
|
|
9ef8d9c189 | ||
|
|
9f27416603 | ||
|
|
0729f4a03a | ||
|
|
21f7b95db9 | ||
|
|
4125a5aa1e | ||
|
|
6d0dc6cb1e | ||
|
|
0675c23217 | ||
|
|
d0e6a1f5bb | ||
|
|
91afe08235 | ||
|
|
041d9d41fb | ||
|
|
7009fb5899 | ||
|
|
d2003c7c9a | ||
|
|
ce002b999c | ||
|
|
5b1056862a | ||
|
|
cc4ac99b4a | ||
|
|
46df341506 | ||
|
|
b698e2d078 | ||
|
|
5cc5e513dd | ||
|
|
e048b0372f | ||
|
|
d7aaa1c870 | ||
|
|
463aac9b59 | ||
|
|
d9cdd78138 | ||
|
|
44a83c0e1c | ||
|
|
96f24a2662 | ||
|
|
5d2801c652 | ||
|
|
deb08b7880 | ||
|
|
96e5054ffc | ||
|
|
c9753a690d | ||
|
|
27aaf46630 | ||
|
|
84190e1e06 | ||
|
|
b0b1f755ea | ||
|
|
fcf1d30c77 | ||
|
|
3c532e5aef | ||
|
|
3efcb2705d | ||
|
|
c25f389e91 | ||
|
|
533f4075a3 | ||
|
|
f508d37426 | ||
|
|
548c6eeb7a | ||
|
|
c8265f4807 | ||
|
|
a74e46debf | ||
|
|
1ceaabe69f | ||
|
|
91a2441ed8 | ||
|
|
2ecbc833a9 | ||
|
|
dac456d98c | ||
|
|
422320d9ac | ||
|
|
18b635936c | ||
|
|
7b2895ef08 | ||
|
|
b09e9f7398 | ||
|
|
ac651328c3 | ||
|
|
0380df1cb4 | ||
|
|
21421d5b53 | ||
|
|
80c309aa69 | ||
|
|
f5bc7310b1 | ||
|
|
21e9967a8a | ||
|
|
32f450f803 | ||
|
|
1972142703 | ||
|
|
b10d866e4b | ||
|
|
b373fb4a42 | ||
|
|
43a70272c5 | ||
|
|
ddd34dc57b | ||
|
|
265c5aba2e | ||
|
|
21fc6d1cf6 | ||
|
|
1a7fe6129c | ||
|
|
37462a16c5 | ||
|
|
323ec0046c | ||
|
|
dc7c6984fb | ||
|
|
92f7248a16 | ||
|
|
1ec3e156fb | ||
|
|
1121bed49b | ||
|
|
0eb43fb530 | ||
|
|
1f50dc38c3 | ||
|
|
a9d044ec10 | ||
|
|
1bdf464ef2 | ||
|
|
a70da0d176 | ||
|
|
8c52b8357c | ||
|
|
0243c6b450 | ||
|
|
f7071447cb | ||
|
|
c038bfafa1 | ||
|
|
4d60f56e66 | ||
|
|
56d3cf51e8 | ||
|
|
3013e3a9e6 | ||
|
|
fe9b2e672b | ||
|
|
3e9fa4ca47 | ||
|
|
a2e66f85a1 | ||
|
|
a9b9cf14c3 | ||
|
|
d4b941cf30 | ||
|
|
4b6bf29b83 | ||
|
|
a8b147dfc0 | ||
|
|
65627c1296 | ||
|
|
3dcdaa0a9b | ||
|
|
5bc00045c7 | ||
|
|
93ea95af24 | ||
|
|
f754773bf6 | ||
|
|
f0c9c262ca | ||
|
|
42bb2f3c58 | ||
|
|
3fde349b9f | ||
|
|
55a9976d46 | ||
|
|
66a86541d1 | ||
|
|
bc19079dad | ||
|
|
351e44343d | ||
|
|
e362a9cbc3 | ||
|
|
e2563e57f2 | ||
|
|
df5e978247 | ||
|
|
68337a6989 | ||
|
|
bf6dbedbe4 | ||
|
|
a204f40968 | ||
|
|
fe3faa0a5a | ||
|
|
39d5a25258 | ||
|
|
f4044230fd | ||
|
|
4d6d8d9a83 | ||
|
|
c4176a282f | ||
|
|
1352839472 | ||
|
|
535128da71 | ||
|
|
099550dddc | ||
|
|
7fe26bc966 | ||
|
|
cc6587d6e5 | ||
|
|
8b310ce993 | ||
|
|
be8ba53263 | ||
|
|
043d48d1c7 | ||
|
|
e8fe80189b | ||
|
|
0e48f317cb | ||
|
|
867745c71d | ||
|
|
a1a7919f74 | ||
|
|
c3de47de90 | ||
|
|
dd35bdfeb4 | ||
|
|
07c3aec34f | ||
|
|
bce3e8f7c6 | ||
|
|
ba9777e754 | ||
|
|
7040801dfa | ||
|
|
f37862a25d | ||
|
|
84d76cf90d | ||
|
|
e12f28fb70 | ||
|
|
4f8a6b62b8 | ||
|
|
d3dad772cf | ||
|
|
944b672fea | ||
|
|
b1c54aa92d | ||
|
|
4ca6f43aeb | ||
|
|
f09e66e1cc | ||
|
|
8b7a4ceaaa | ||
|
|
51e90f5971 | ||
|
|
8db64772b7 | ||
|
|
bf0be60b89 | ||
|
|
172481dd72 | ||
|
|
c6c0492c33 | ||
|
|
fca29a8be2 | ||
|
|
d365240f91 | ||
|
|
1ed61d4783 | ||
|
|
a1fb11ae33 | ||
|
|
9971816711 | ||
|
|
c38d9a3098 | ||
|
|
02198de455 | ||
|
|
6cd8202310 | ||
|
|
4d7b7d1d42 | ||
|
|
dfe04960c0 | ||
|
|
de2b1cc6fe | ||
|
|
2aef4ab677 | ||
|
|
798f68d0ce | ||
|
|
e0343a3f6d | ||
|
|
d918ec694b | ||
|
|
b2b609a309 | ||
|
|
48dd80867b | ||
|
|
f58f6e8d65 | ||
|
|
e4e21f52b5 | ||
|
|
84e1cd08b6 | ||
|
|
7796753e7a | ||
|
|
880205e874 | ||
|
|
1b96087b08 | ||
|
|
aa246c9e9f | ||
|
|
ee034943b6 | ||
|
|
f1d311d232 | ||
|
|
e4f7fca10d | ||
|
|
3d6d669a50 | ||
|
|
c4097e2b7e | ||
|
|
619d27c773 | ||
|
|
1522c90294 | ||
|
|
794e15ce21 | ||
|
|
34771b835e | ||
|
|
8df51b232a | ||
|
|
13b8ce18b2 | ||
|
|
448386e52b | ||
|
|
bf07659dd5 | ||
|
|
16dfad0895 | ||
|
|
f61449c31c | ||
|
|
60699229ca | ||
|
|
e1dd26b307 | ||
|
|
7d835ef99d | ||
|
|
0971df4dfc | ||
|
|
9fb57fbac0 | ||
|
|
48ead90850 | ||
|
|
cc88bb7feb | ||
|
|
a725e2aa6a | ||
|
|
ee637c3662 | ||
|
|
65d7a39554 | ||
|
|
37735b1caa | ||
|
|
c8f8d79f45 | ||
|
|
1866e7141e | ||
|
|
feccc9f5ce | ||
|
|
af803da5c8 | ||
|
|
25c89c9940 | ||
|
|
697a2834c2 | ||
|
|
056b8bb536 | ||
|
|
625d424199 | ||
|
|
5329d05005 | ||
|
|
d2c55da6c9 | ||
|
|
2e6dd3edfe | ||
|
|
a95b4ea7b9 | ||
|
|
c891eff664 | ||
|
|
68564ca714 | ||
|
|
ca931a11be | ||
|
|
6c7272061c | ||
|
|
4f262e5bed | ||
|
|
ff26b0d5a4 | ||
|
|
a6ccc72d15 | ||
|
|
487ee18358 | ||
|
|
dc3d2e9790 | ||
|
|
f6d0e484b0 | ||
|
|
4cea9aba3c | ||
|
|
7348a68c84 | ||
|
|
7d90c3f582 | ||
|
|
2a103fc94a | ||
|
|
753391b7e2 | ||
|
|
94ce5edd20 | ||
|
|
3626f70d3e | ||
|
|
24cc24ed50 | ||
|
|
dd29ba4664 | ||
|
|
7927ad8fcf | ||
|
|
d23453ce45 | ||
|
|
a22040efa9 | ||
|
|
ba3da32ce6 | ||
|
|
9d2ba52160 | ||
|
|
e610506df4 | ||
|
|
dd91d28bfa | ||
|
|
1ebf7460fe | ||
|
|
8c930e5c33 | ||
|
|
4fb2f7474c | ||
|
|
5301f79989 | ||
|
|
6a7f7fdf15 | ||
|
|
11fb5f990e | ||
|
|
d1ee0442ea | ||
|
|
62f31ea24a | ||
|
|
f4ca5313e6 | ||
|
|
064e7b404b | ||
|
|
dfd90bd564 | ||
|
|
55508eb418 | ||
|
|
2a4fa4ed6f | ||
|
|
cf7c9f6372 | ||
|
|
ec68c3207d | ||
|
|
ecf140f3d6 | ||
|
|
13f73b7b87 | ||
|
|
12c5bcd24f | ||
|
|
56f47ee574 | ||
|
|
74f0436ac7 | ||
|
|
22d31b1527 | ||
|
|
9f3bca771a | ||
|
|
4e16d90a81 | ||
|
|
d669d5c153 | ||
|
|
343d985e96 | ||
|
|
dc3958356d | ||
|
|
c4e85c3277 | ||
|
|
89e46376dc | ||
|
|
8eeb34dba8 | ||
|
|
7171305972 | ||
|
|
2b0c223425 | ||
|
|
8f960ab0f7 | ||
|
|
60350efa10 | ||
|
|
687f577562 | ||
|
|
8e59ce9e9f | ||
|
|
33d75354a2 | ||
|
|
a318c6263d | ||
|
|
0e4a65efb7 | ||
|
|
b88134cf04 | ||
|
|
2aaa212dbc | ||
|
|
1e37990938 | ||
|
|
a417c73bf7 | ||
|
|
37c34351ee | ||
|
|
8672232ee2 | ||
|
|
83ba974f94 | ||
|
|
85ebbe8759 | ||
|
|
61cba3f6eb | ||
|
|
3ad10ff8d0 | ||
|
|
183643547b | ||
|
|
5568340b9a | ||
|
|
1399bd3065 | ||
|
|
9172e16e80 | ||
|
|
3e5f602396 | ||
|
|
3c97332fd8 | ||
|
|
379a3f27b8 | ||
|
|
ecec932a47 | ||
|
|
e239f69f69 | ||
|
|
c77cb317c4 | ||
|
|
034b089433 | ||
|
|
c0db96482c | ||
|
|
ffa8fa0a6f | ||
|
|
7e1d459a2d | ||
|
|
71c4fce87f | ||
|
|
e91da78ebb | ||
|
|
8adad6fa61 | ||
|
|
b47004bb7c | ||
|
|
08a7fb4de0 | ||
|
|
c17a9b11cc | ||
|
|
245a92a644 | ||
|
|
6b313946fe | ||
|
|
4586fb1d13 | ||
|
|
aa051434cb | ||
|
|
c3a53752e7 | ||
|
|
f3e1204fa1 | ||
|
|
0a5eb93565 | ||
|
|
b8a3135835 | ||
|
|
330dfccb89 | ||
|
|
d80e926015 | ||
|
|
2a2b067633 | ||
|
|
be73c14395 | ||
|
|
9cd5afe5b6 | ||
|
|
1cb5d26344 | ||
|
|
ec9a2d8155 | ||
|
|
4ba40f2295 | ||
|
|
b674c2e448 | ||
|
|
0227afffc8 | ||
|
|
b8139a6e83 | ||
|
|
bde5fc9264 | ||
|
|
6a421a1d96 | ||
|
|
4f55a0f1d0 | ||
|
|
3de55899fa | ||
|
|
ae4ad713ec | ||
|
|
21313adf9c | ||
|
|
9c1293ca45 | ||
|
|
1cb1e6b680 | ||
|
|
ed6ddeaa4c | ||
|
|
de08a89e6b | ||
|
|
dd42ef1920 | ||
|
|
dd192be689 | ||
|
|
52250ed10e | ||
|
|
4a26cd8d68 | ||
|
|
2ca972c3c8 | ||
|
|
74c0d55a6c | ||
|
|
3271e1464e | ||
|
|
cabd62b48f | ||
|
|
58c2355c8b | ||
|
|
bfe2065b9f | ||
|
|
9332b1355e | ||
|
|
45705a3e29 | ||
|
|
e0f0b9f210 | ||
|
|
f2832447d4 | ||
|
|
471ba5baf6 | ||
|
|
248851701f | ||
|
|
0f46277b1f | ||
|
|
679e703754 | ||
|
|
7322f90af4 | ||
|
|
e869df98c9 | ||
|
|
e499d36126 | ||
|
|
cac66d7fad | ||
|
|
320aaf0e33 | ||
|
|
178a175e99 | ||
|
|
5fdf1cb2d1 | ||
|
|
c64500dd85 | ||
|
|
812ad3f49e | ||
|
|
8e8a1a7541 | ||
|
|
4863b3df6e | ||
|
|
768c3a533b | ||
|
|
3dea554e9e | ||
|
|
16d4f6e4e1 | ||
|
|
9c7ecf221e | ||
|
|
26db481d46 | ||
|
|
3256a57230 | ||
|
|
cbc30587ff | ||
|
|
a27de38c03 | ||
|
|
e2f1609116 | ||
|
|
ea66a91a95 | ||
|
|
0d87c352b2 | ||
|
|
918f6ce0e6 | ||
|
|
6c5efe6ce0 | ||
|
|
f0be6675e7 | ||
|
|
6a8174a15c | ||
|
|
40c3f1b618 | ||
|
|
6dd2dac049 | ||
|
|
b39bbb557f | ||
|
|
f7682cba67 | ||
|
|
f94c07160a | ||
|
|
bbe6692580 | ||
|
|
9266a1c4d9 | ||
|
|
220d80f05f | ||
|
|
9144c909dd | ||
|
|
7981fcec84 | ||
|
|
71264c56fc | ||
|
|
ca0f77bdee | ||
|
|
fc8b1b8549 | ||
|
|
bc8c44f62f | ||
|
|
01fab5c92a | ||
|
|
1c07d786a0 | ||
|
|
6f0cd87d1c | ||
|
|
e44308cba2 | ||
|
|
50245c5157 | ||
|
|
9ca5188e12 | ||
|
|
e25c33eaa6 | ||
|
|
56cc881ac0 | ||
|
|
7bddc0a89c | ||
|
|
50896bdc9d | ||
|
|
8dd4567828 | ||
|
|
06ef6d3e6a | ||
|
|
14b58e8062 | ||
|
|
eee232c12c | ||
|
|
febe321aef | ||
|
|
28777ac717 | ||
|
|
13b008b56c | ||
|
|
403f42bf38 | ||
|
|
523efbd85a | ||
|
|
fcacc8bfc6 | ||
|
|
b2e301418f | ||
|
|
334a2e44a1 | ||
|
|
252b3c3bf6 | ||
|
|
c9121a03d2 | ||
|
|
cc93180d57 | ||
|
|
24221748e1 | ||
|
|
4062a425cb | ||
|
|
cce533ebb6 | ||
|
|
48df38cbfe | ||
|
|
f982f073df | ||
|
|
34999f12ca | ||
|
|
c8d5665653 | ||
|
|
ddebaf87d0 | ||
|
|
6b80cd6109 | ||
|
|
7635d8d2a5 | ||
|
|
141ae053db | ||
|
|
10ec4ff814 | ||
|
|
d2da0b7c0e | ||
|
|
7d0548406e | ||
|
|
634e3e35a0 | ||
|
|
da3dc58199 | ||
|
|
4f99df694b | ||
|
|
c121dbbd67 | ||
|
|
c1c0a7d494 | ||
|
|
0749f60702 | ||
|
|
982b8e2d72 | ||
|
|
6e7c8d7ae2 | ||
|
|
3c858f522b | ||
|
|
f2a30f8cdd | ||
|
|
43785bfab4 | ||
|
|
78edf6d324 | ||
|
|
73565c4493 | ||
|
|
ca0ef18bdf | ||
|
|
6ed011e2f8 | ||
|
|
23d322452a | ||
|
|
5d3b965d28 | ||
|
|
d9794d72c7 | ||
|
|
524b5be937 | ||
|
|
ac2e276a6a | ||
|
|
4f4dbc0c22 | ||
|
|
8c37cac957 | ||
|
|
eceab76b6f | ||
|
|
1f81b6ddc4 | ||
|
|
52c3aadd24 | ||
|
|
ad87573d09 | ||
|
|
20fbfc8544 | ||
|
|
7695c8403f | ||
|
|
421983d06e | ||
|
|
328c681a8f | ||
|
|
48d94d0f68 | ||
|
|
10ad5d763e | ||
|
|
2a78c946e4 | ||
|
|
a7872aa054 | ||
|
|
5c228ae0a1 | ||
|
|
ce73f7ac5a | ||
|
|
64107f5957 | ||
|
|
8a1795d56f | ||
|
|
b104c3bfe8 | ||
|
|
82e3f126ff | ||
|
|
175488563e | ||
|
|
da51cdd11d | ||
|
|
a8a47b138f | ||
|
|
b63d4cf675 | ||
|
|
03b999c592 | ||
|
|
a91afab038 | ||
|
|
d4747b5386 | ||
|
|
41b81c8b05 | ||
|
|
552831364d | ||
|
|
42b5e32473 | ||
|
|
e9c36fd6f8 | ||
|
|
952dfbef36 | ||
|
|
254984b600 | ||
|
|
8cbc58d257 | ||
|
|
e6cc3e8c34 | ||
|
|
516335e0ed | ||
|
|
01798ed7f8 | ||
|
|
fcad67a854 | ||
|
|
e359ffead0 | ||
|
|
eb09041859 | ||
|
|
b3d52c966d | ||
|
|
3fb8a14348 | ||
|
|
84a949e7c7 | ||
|
|
eaf1cb26b2 | ||
|
|
f37962d3de | ||
|
|
511e957d4b | ||
|
|
71df03b729 | ||
|
|
839052f4b8 | ||
|
|
7c18d857f0 | ||
|
|
947e672d18 | ||
|
|
96942960a9 | ||
|
|
8b0118e2c8 | ||
|
|
5f9a7a5381 | ||
|
|
6897d72c3e | ||
|
|
aae9a505e0 | ||
|
|
45196e022b | ||
|
|
b9e4c44d63 | ||
|
|
0a9e5b66ee | ||
|
|
8b99e82743 | ||
|
|
059fb85e22 | ||
|
|
8997df861a | ||
|
|
e65667963f | ||
|
|
3d51667fc8 | ||
|
|
7fc6e97cd8 | ||
|
|
1473e58a41 | ||
|
|
2394b2f44f | ||
|
|
516bd98198 | ||
|
|
7d8688a130 | ||
|
|
631ec70058 | ||
|
|
6fd51cfdc0 | ||
|
|
6857b74623 | ||
|
|
5ec4305a9f | ||
|
|
88baff96d0 | ||
|
|
e871f0002b | ||
|
|
7358d48e35 | ||
|
|
178fbf0fca | ||
|
|
a50597ff27 | ||
|
|
e4cb78abee | ||
|
|
732884a3b2 | ||
|
|
80f2c42c69 | ||
|
|
49a5a39659 | ||
|
|
a4a7040b98 | ||
|
|
de5a7d5b99 | ||
|
|
3f92e388be | ||
|
|
25c941b847 | ||
|
|
24b6934d3b | ||
|
|
d286ab406c | ||
|
|
ef6a7a6904 | ||
|
|
c61eda0d24 | ||
|
|
ad226b6fb1 | ||
|
|
24491f0dfe | ||
|
|
870fd1654d | ||
|
|
38bc912e4e | ||
|
|
315c9a2d92 | ||
|
|
a14ad6f700 | ||
|
|
d56e63a91b | ||
|
|
76dcdfb98c | ||
|
|
99c09ba8a1 | ||
|
|
0f18b76813 | ||
|
|
8504e4cd22 | ||
|
|
ebe793e0e7 | ||
|
|
965c6cf4d9 | ||
|
|
2b1ab3184e | ||
|
|
e7d21c2dbe | ||
|
|
11906d9d71 | ||
|
|
ac5a64d77a | ||
|
|
c86c851c60 | ||
|
|
721cf98486 | ||
|
|
84bbb6efd4 | ||
|
|
f897cda6cd | ||
|
|
2da8b25b09 | ||
|
|
3f94fd90dd | ||
|
|
bc6be22cb4 | ||
|
|
e23604e08d | ||
|
|
be858ac9ce | ||
|
|
137ab4a557 | ||
|
|
bad0fc386d | ||
|
|
641c7b2c89 | ||
|
|
21be3db51f | ||
|
|
e978857820 | ||
|
|
3bf596c54c | ||
|
|
aedb823b4d | ||
|
|
7a417435cc | ||
|
|
497d6e80f7 | ||
|
|
ae6ab34e72 | ||
|
|
4c26161728 | ||
|
|
1731dca5dd | ||
|
|
ee2caff46e | ||
|
|
db8fb8b05d | ||
|
|
bec7e141dc | ||
|
|
ab85b4b129 | ||
|
|
b030049b40 | ||
|
|
1338a3d89d | ||
|
|
181178296f | ||
|
|
df7888d6fb | ||
|
|
dd15f5e052 | ||
|
|
f348d85b11 | ||
|
|
8c8a05b8c1 | ||
|
|
34d2fc1503 | ||
|
|
9b3fa809bf | ||
|
|
59535c112e | ||
|
|
04e5a6425a | ||
|
|
424dddf67b | ||
|
|
f0d6ae2a00 | ||
|
|
25298a32fa | ||
|
|
ba28bf01b7 | ||
|
|
d15c29b1a3 | ||
|
|
b083910a51 | ||
|
|
235aad32a6 | ||
|
|
a818560344 | ||
|
|
8f179becf7 | ||
|
|
e1695a0874 | ||
|
|
af7498d283 | ||
|
|
3e2a4d8053 | ||
|
|
29982e2caf | ||
|
|
5fea1df42b | ||
|
|
a041162b32 | ||
|
|
32cd3981d8 | ||
|
|
ca5af87196 | ||
|
|
a8164f612f | ||
|
|
d3bb0b6ff0 | ||
|
|
0ef10c1e13 | ||
|
|
4017911373 | ||
|
|
048034d4b1 | ||
|
|
fcb3f08bcb | ||
|
|
d2a05bb622 | ||
|
|
f7254ee169 | ||
|
|
a0e5c9d570 | ||
|
|
8291e4ba73 | ||
|
|
b324be3b0b | ||
|
|
6ba0ba7126 | ||
|
|
1d8e0629af | ||
|
|
42df54869f | ||
|
|
7b758b85ec | ||
|
|
82987ec401 | ||
|
|
71707b5aa7 | ||
|
|
ca2df83928 | ||
|
|
085771c2f0 | ||
|
|
607a638858 | ||
|
|
5f6d06d05d | ||
|
|
19ecb87b07 | ||
|
|
2a332c0883 | ||
|
|
bb773c6c13 | ||
|
|
238de489c1 | ||
|
|
6b4db330d8 | ||
|
|
ea5d7c0dee | ||
|
|
0f189f1af3 | ||
|
|
0f1b8dd51a | ||
|
|
d7e6946a78 | ||
|
|
255b7b1a54 | ||
|
|
79e1c751a1 | ||
|
|
fc745b9614 | ||
|
|
95b1baebd2 | ||
|
|
56fe1ceb97 | ||
|
|
863a51e556 | ||
|
|
69b3064b45 | ||
|
|
fb3eab1aa8 | ||
|
|
32c7399f26 | ||
|
|
955351b5bd | ||
|
|
75f6c67b6e | ||
|
|
700a3e6ed9 | ||
|
|
00702448c7 | ||
|
|
5074827d51 | ||
|
|
ceb0711e42 | ||
|
|
ddb5824b58 | ||
|
|
39f9209374 | ||
|
|
5fea4cf760 | ||
|
|
0e5ec86ca9 | ||
|
|
8b95211055 | ||
|
|
a27339b954 | ||
|
|
028b728760 | ||
|
|
18e63df01e | ||
|
|
5f459c0901 | ||
|
|
a90bcde38c | ||
|
|
603e7d922e | ||
|
|
861126f810 | ||
|
|
eb9b706ebc | ||
|
|
de9cbae0b2 | ||
|
|
25e890986f | ||
|
|
f66627dd04 | ||
|
|
924eb33b3f | ||
|
|
1b288c541a | ||
|
|
2612b8c86f | ||
|
|
3e2796d456 | ||
|
|
7092913863 | ||
|
|
67625fc347 | ||
|
|
eb55030b06 | ||
|
|
6e1b2d50f2 | ||
|
|
c6f72c44b8 | ||
|
|
d38ded0f26 | ||
|
|
ec20b7bd3a | ||
|
|
0766cf464a | ||
|
|
867f00e091 | ||
|
|
c823b8d7ae | ||
|
|
393d4d336c | ||
|
|
2cb3f2d03d | ||
|
|
279f2dd633 | ||
|
|
dec051a6e0 | ||
|
|
790fdd320c | ||
|
|
feb4a364a7 | ||
|
|
1140149e1e | ||
|
|
2ee9599b6e | ||
|
|
188d45e002 | ||
|
|
7c4c2f7860 | ||
|
|
90b7f2ff3b | ||
|
|
d3f0041e93 | ||
|
|
9d60142828 | ||
|
|
68d5edca60 | ||
|
|
1b369489df | ||
|
|
600ddfbf2d | ||
|
|
415d4dde2a | ||
|
|
1867245ed3 | ||
|
|
71d34592d9 | ||
|
|
db2927eea7 | ||
|
|
bb01a5cb31 | ||
|
|
815319140f | ||
|
|
6e6082119f | ||
|
|
da48ffe05c | ||
|
|
081979be3b | ||
|
|
3673956c1c | ||
|
|
bdd3c274ed | ||
|
|
423034d5c4 | ||
|
|
19fd2b12c0 | ||
|
|
21cd17873f | ||
|
|
9870fa9e34 | ||
|
|
938cd5e136 | ||
|
|
e8025ad4b3 | ||
|
|
07fa141aaa | ||
|
|
18bdf1e8b3 | ||
|
|
5be977005e | ||
|
|
282b64278e | ||
|
|
7263d484de | ||
|
|
bdb059b6c9 | ||
|
|
de3f5011bc | ||
|
|
de9faffa33 | ||
|
|
f67ca69e05 | ||
|
|
dd19e880c5 | ||
|
|
b5e8fa007c | ||
|
|
c3555bfcab | ||
|
|
0383db8788 | ||
|
|
d7af122c18 | ||
|
|
e15b8145b1 | ||
|
|
d75f5f9231 | ||
|
|
9939797792 | ||
|
|
5248b9fc6f | ||
|
|
96cfdebced | ||
|
|
944f34b833 | ||
|
|
1023b2ca9c | ||
|
|
16318bb9f6 | ||
|
|
350586335d | ||
|
|
9d809499a5 | ||
|
|
1461d029db | ||
|
|
e37d4a6756 | ||
|
|
e2a1ce623c | ||
|
|
0ff243266c | ||
|
|
645da2e307 | ||
|
|
84ffffb3f3 | ||
|
|
90138ed574 | ||
|
|
338580087e | ||
|
|
37ecd5cdef | ||
|
|
c4391ff058 | ||
|
|
3822e3f8d9 | ||
|
|
f8f99f3878 | ||
|
|
e77e4acea9 | ||
|
|
c6de444d0b |
4
.github/actions/install/action.yml
vendored
4
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
|||||||
zig-v8:
|
zig-v8:
|
||||||
description: 'zig v8 version to install'
|
description: 'zig v8 version to install'
|
||||||
required: false
|
required: false
|
||||||
default: 'v0.2.9'
|
default: 'v0.3.4'
|
||||||
v8:
|
v8:
|
||||||
description: 'v8 version to install'
|
description: 'v8 version to install'
|
||||||
required: false
|
required: false
|
||||||
@@ -46,7 +46,7 @@ runs:
|
|||||||
|
|
||||||
- name: Cache v8
|
- name: Cache v8
|
||||||
id: cache-v8
|
id: cache-v8
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
env:
|
env:
|
||||||
cache-name: cache-v8
|
cache-name: cache-v8
|
||||||
with:
|
with:
|
||||||
|
|||||||
14
.github/workflows/e2e-integration-test.yml
vendored
14
.github/workflows/e2e-integration-test.yml
vendored
@@ -20,19 +20,17 @@ jobs:
|
|||||||
if: github.event.pull_request.draft == false
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
|
|
||||||
- name: zig build release
|
- 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 }})
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
path: |
|
path: |
|
||||||
@@ -47,7 +45,7 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: 'lightpanda-io/demo'
|
repository: 'lightpanda-io/demo'
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -55,7 +53,7 @@ jobs:
|
|||||||
- run: npm install
|
- run: npm install
|
||||||
|
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
|
|
||||||
@@ -63,6 +61,6 @@ jobs:
|
|||||||
|
|
||||||
- name: run end to end integration tests
|
- name: run end to end integration tests
|
||||||
run: |
|
run: |
|
||||||
./lightpanda serve & echo $! > LPD.pid
|
./lightpanda serve --log-level error & echo $! > LPD.pid
|
||||||
go run integration/main.go
|
go run integration/main.go
|
||||||
kill `cat LPD.pid`
|
kill `cat LPD.pid`
|
||||||
|
|||||||
147
.github/workflows/e2e-test.yml
vendored
147
.github/workflows/e2e-test.yml
vendored
@@ -9,15 +9,13 @@ env:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- "build.zig"
|
|
||||||
- "src/**/*.zig"
|
|
||||||
- "src/*.zig"
|
|
||||||
- "vendor/zig-js-runtime"
|
|
||||||
- ".github/**"
|
- ".github/**"
|
||||||
- "vendor/**"
|
- "src/**"
|
||||||
|
- "build.zig"
|
||||||
|
- "build.zig.zon"
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# By default GH trigger on types opened, synchronize and reopened.
|
# By default GH trigger on types opened, synchronize and reopened.
|
||||||
@@ -29,12 +27,10 @@ on:
|
|||||||
|
|
||||||
paths:
|
paths:
|
||||||
- ".github/**"
|
- ".github/**"
|
||||||
|
- "src/**"
|
||||||
- "build.zig"
|
- "build.zig"
|
||||||
- "src/**/*.zig"
|
- "build.zig.zon"
|
||||||
- "src/*.zig"
|
|
||||||
- "vendor/**"
|
|
||||||
- ".github/**"
|
|
||||||
- "vendor/**"
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -52,16 +48,14 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
|
|
||||||
- name: zig build release
|
- 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 }})
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
path: |
|
path: |
|
||||||
@@ -76,7 +70,7 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: 'lightpanda-io/demo'
|
repository: 'lightpanda-io/demo'
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -84,7 +78,7 @@ jobs:
|
|||||||
- run: npm install
|
- run: npm install
|
||||||
|
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
|
|
||||||
@@ -104,7 +98,7 @@ jobs:
|
|||||||
- name: run end to end tests through proxy
|
- name: run end to end tests through proxy
|
||||||
run: |
|
run: |
|
||||||
./proxy/proxy & echo $! > PROXY.id
|
./proxy/proxy & echo $! > PROXY.id
|
||||||
./lightpanda serve --http_proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
|
./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
|
||||||
go run runner/main.go
|
go run runner/main.go
|
||||||
kill `cat LPD.pid` `cat PROXY.id`
|
kill `cat LPD.pid` `cat PROXY.id`
|
||||||
|
|
||||||
@@ -117,6 +111,110 @@ jobs:
|
|||||||
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
||||||
kill `cat LPD.pid` `cat PROXY.id`
|
kill `cat LPD.pid` `cat PROXY.id`
|
||||||
|
|
||||||
|
# e2e tests w/ web-bot-auth configuration on.
|
||||||
|
wba-demo-scripts:
|
||||||
|
name: wba-demo-scripts
|
||||||
|
needs: zig-build-release
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
repository: 'lightpanda-io/demo'
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- run: npm install
|
||||||
|
|
||||||
|
- name: download artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: lightpanda-build-release
|
||||||
|
|
||||||
|
- run: chmod a+x ./lightpanda
|
||||||
|
|
||||||
|
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
|
||||||
|
|
||||||
|
- name: run end to end tests
|
||||||
|
run: |
|
||||||
|
./lightpanda serve \
|
||||||
|
--web-bot-auth-key-file private_key.pem \
|
||||||
|
--web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
|
||||||
|
--web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
|
||||||
|
& echo $! > LPD.pid
|
||||||
|
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 \
|
||||||
|
--web-bot-auth-key-file private_key.pem \
|
||||||
|
--web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
|
||||||
|
--web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
|
||||||
|
--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`
|
||||||
|
|
||||||
|
wba-test:
|
||||||
|
name: wba-test
|
||||||
|
needs: zig-build-release
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
# Don't execute on PR
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
repository: 'lightpanda-io/demo'
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: download artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: lightpanda-build-release
|
||||||
|
|
||||||
|
- run: chmod a+x ./lightpanda
|
||||||
|
|
||||||
|
# force a wakup of the auth server before requesting it w/ the test itself
|
||||||
|
- run: curl https://${{ vars.WBA_DOMAIN }}
|
||||||
|
|
||||||
|
- name: run wba test
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node webbotauth/validator.js &
|
||||||
|
VALIDATOR_PID=$!
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
|
||||||
|
|
||||||
|
./lightpanda fetch --dump http://127.0.0.1:8989/ \
|
||||||
|
--web-bot-auth-key-file /proc/self/fd/3 \
|
||||||
|
--web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
|
||||||
|
--web-bot-auth-domain ${{ vars.WBA_DOMAIN }}
|
||||||
|
|
||||||
|
wait $VALIDATOR_PID
|
||||||
|
exec 3>&-
|
||||||
|
|
||||||
cdp-and-hyperfine-bench:
|
cdp-and-hyperfine-bench:
|
||||||
name: cdp-and-hyperfine-bench
|
name: cdp-and-hyperfine-bench
|
||||||
needs: zig-build-release
|
needs: zig-build-release
|
||||||
@@ -125,7 +223,6 @@ jobs:
|
|||||||
MAX_VmHWM: 28000 # 28MB (KB)
|
MAX_VmHWM: 28000 # 28MB (KB)
|
||||||
MAX_CG_PEAK: 8000 # 8MB (KB)
|
MAX_CG_PEAK: 8000 # 8MB (KB)
|
||||||
MAX_AVG_DURATION: 17
|
MAX_AVG_DURATION: 17
|
||||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
|
||||||
|
|
||||||
# How to give cgroups access to the user actions-runner on the host:
|
# How to give cgroups access to the user actions-runner on the host:
|
||||||
# $ sudo apt install cgroup-tools
|
# $ sudo apt install cgroup-tools
|
||||||
@@ -140,7 +237,7 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: 'lightpanda-io/demo'
|
repository: 'lightpanda-io/demo'
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -148,7 +245,7 @@ jobs:
|
|||||||
- run: npm install
|
- run: npm install
|
||||||
|
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
|
|
||||||
@@ -234,7 +331,7 @@ jobs:
|
|||||||
echo "${{github.sha}}" > commit.txt
|
echo "${{github.sha}}" > commit.txt
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: bench-results
|
name: bench-results
|
||||||
path: |
|
path: |
|
||||||
@@ -262,7 +359,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: bench-results
|
name: bench-results
|
||||||
|
|
||||||
@@ -280,7 +377,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: lightpanda-build-release
|
name: lightpanda-build-release
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ env:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
|
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
|
||||||
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
||||||
|
|
||||||
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
||||||
|
VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '1.0.0-nightly' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -33,8 +35,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
with:
|
with:
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||||
|
|
||||||
- name: zig build
|
- name: zig build
|
||||||
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 }})
|
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dversion_string=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||||
@@ -72,11 +72,9 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
with:
|
with:
|
||||||
@@ -87,7 +85,7 @@ jobs:
|
|||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||||
|
|
||||||
- name: zig build
|
- name: zig build
|
||||||
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 }})
|
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dversion_string=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||||
@@ -116,11 +114,9 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
with:
|
with:
|
||||||
@@ -131,7 +127,7 @@ jobs:
|
|||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||||
|
|
||||||
- name: zig build
|
- name: zig build
|
||||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||||
@@ -158,11 +154,9 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
with:
|
with:
|
||||||
@@ -173,7 +167,7 @@ jobs:
|
|||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||||
|
|
||||||
- name: zig build
|
- name: zig build
|
||||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||||
109
.github/workflows/wpt.yml
vendored
109
.github/workflows/wpt.yml
vendored
@@ -5,43 +5,126 @@ env:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
||||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||||
|
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
|
||||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "23 2 * * *"
|
- cron: "21 2 * * *"
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
wpt:
|
wpt-build-release:
|
||||||
name: web platform tests json output
|
name: zig build release
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
env:
|
||||||
timeout-minutes: 90
|
ARCH: aarch64
|
||||||
|
OS: linux
|
||||||
|
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
|
with:
|
||||||
|
os: ${{env.OS}}
|
||||||
|
arch: ${{env.ARCH}}
|
||||||
|
|
||||||
- name: build wpt
|
- name: v8 snapshot
|
||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||||
|
|
||||||
|
- name: zig build release
|
||||||
|
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
|
||||||
|
|
||||||
|
- name: upload artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: lightpanda-build-release
|
||||||
|
path: |
|
||||||
|
zig-out/bin/lightpanda
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
wpt-build-runner:
|
||||||
|
name: build wpt runner
|
||||||
|
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
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@v7
|
||||||
|
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-wpt-aws
|
||||||
|
timeout-minutes: 600
|
||||||
|
|
||||||
|
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@v8
|
||||||
|
with:
|
||||||
|
name: lightpanda-build-release
|
||||||
|
|
||||||
|
- run: chmod a+x ./lightpanda
|
||||||
|
|
||||||
|
- name: download wptrunner
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: wptrunner
|
||||||
|
|
||||||
|
- run: chmod a+x ./wptrunner
|
||||||
|
|
||||||
- name: run test with json output
|
- name: run test with json output
|
||||||
run: zig-out/bin/lightpanda-wpt --json > wpt.json
|
run: |
|
||||||
|
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
||||||
|
sleep 20s
|
||||||
|
./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
|
||||||
|
kill `cat WPT.pid`
|
||||||
|
|
||||||
- name: write commit
|
- name: write commit
|
||||||
run: |
|
run: |
|
||||||
echo "${{github.sha}}" > commit.txt
|
echo "${{github.sha}}" > commit.txt
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: wpt-results
|
name: wpt-results
|
||||||
path: |
|
path: |
|
||||||
@@ -51,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
perf-fmt:
|
perf-fmt:
|
||||||
name: perf-fmt
|
name: perf-fmt
|
||||||
needs: wpt
|
needs: run-wpt
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
@@ -64,7 +147,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: wpt-results
|
name: wpt-results
|
||||||
|
|
||||||
|
|||||||
60
.github/workflows/zig-fmt.yml
vendored
60
.github/workflows/zig-fmt.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: zig-fmt
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
# By default GH trigger on types opened, synchronize and reopened.
|
|
||||||
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
|
||||||
# Since we skip the job when the PR is in draft state, we want to force CI
|
|
||||||
# running when the PR is marked ready_for_review w/o other change.
|
|
||||||
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
paths:
|
|
||||||
- ".github/**"
|
|
||||||
- "build.zig"
|
|
||||||
- "src/**/*.zig"
|
|
||||||
- "src/*.zig"
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
zig-fmt:
|
|
||||||
name: zig fmt
|
|
||||||
|
|
||||||
# Don't run the CI with draft PR.
|
|
||||||
if: github.event.pull_request.draft == false
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- 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: |
|
|
||||||
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
|
||||||
delimiter="$(openssl rand -hex 8)"
|
|
||||||
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
if [ -s zig-fmt.err ]; then
|
|
||||||
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
|
||||||
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -s zig-fmt.err2 ]; then
|
|
||||||
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
|
||||||
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Fail the job
|
|
||||||
if: steps.fmt.outputs.zig_fmt_errs != ''
|
|
||||||
run: exit 1
|
|
||||||
94
.github/workflows/zig-test.yml
vendored
94
.github/workflows/zig-test.yml
vendored
@@ -5,19 +5,18 @@ env:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
||||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||||
|
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- "build.zig"
|
|
||||||
- "src/**"
|
|
||||||
- "vendor/zig-js-runtime"
|
|
||||||
- ".github/**"
|
- ".github/**"
|
||||||
- "vendor/**"
|
- "src/**"
|
||||||
pull_request:
|
- "build.zig"
|
||||||
|
- "build.zig.zon"
|
||||||
|
|
||||||
|
pull_request:
|
||||||
# By default GH trigger on types opened, synchronize and reopened.
|
# By default GH trigger on types opened, synchronize and reopened.
|
||||||
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
||||||
# Since we skip the job when the PR is in draft state, we want to force CI
|
# Since we skip the job when the PR is in draft state, we want to force CI
|
||||||
@@ -27,28 +26,63 @@ on:
|
|||||||
|
|
||||||
paths:
|
paths:
|
||||||
- ".github/**"
|
- ".github/**"
|
||||||
|
- "src/**"
|
||||||
- "build.zig"
|
- "build.zig"
|
||||||
- "src/**/*.zig"
|
- "build.zig.zon"
|
||||||
- "src/*.zig"
|
|
||||||
- "vendor/**"
|
|
||||||
- ".github/**"
|
|
||||||
- "vendor/**"
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zig-test-debug:
|
zig-fmt:
|
||||||
name: zig test using v8 in debug mode
|
name: zig fmt
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# 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: |
|
||||||
|
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
||||||
|
delimiter="$(openssl rand -hex 8)"
|
||||||
|
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
if [ -s zig-fmt.err ]; then
|
||||||
|
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
||||||
|
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -s zig-fmt.err2 ]; then
|
||||||
|
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
||||||
|
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Fail the job
|
||||||
|
if: steps.fmt.outputs.zig_fmt_errs != ''
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
zig-test-debug:
|
||||||
|
name: zig test using v8 in debug mode
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
with:
|
with:
|
||||||
@@ -57,21 +91,18 @@ jobs:
|
|||||||
- name: zig build test
|
- name: zig build test
|
||||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
|
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
|
||||||
|
|
||||||
zig-test:
|
zig-test-release:
|
||||||
name: zig test
|
name: zig test
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
# Don't run the CI with draft PR.
|
|
||||||
if: github.event.pull_request.draft == false
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- uses: ./.github/actions/install
|
- uses: ./.github/actions/install
|
||||||
|
|
||||||
@@ -83,7 +114,7 @@ jobs:
|
|||||||
echo "${{github.sha}}" > commit.txt
|
echo "${{github.sha}}" > commit.txt
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: bench-results
|
name: bench-results
|
||||||
path: |
|
path: |
|
||||||
@@ -93,14 +124,13 @@ jobs:
|
|||||||
|
|
||||||
bench-fmt:
|
bench-fmt:
|
||||||
name: perf-fmt
|
name: perf-fmt
|
||||||
needs: zig-test
|
needs: zig-test-release
|
||||||
|
|
||||||
# Don't execute on PR
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||||
credentials:
|
credentials:
|
||||||
@@ -109,7 +139,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: download artifact
|
- name: download artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: bench-results
|
name: bench-results
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,11 +1,6 @@
|
|||||||
zig-cache
|
|
||||||
/.zig-cache/
|
/.zig-cache/
|
||||||
/.lp-cache/
|
/.lp-cache/
|
||||||
zig-out
|
zig-out
|
||||||
/vendor/netsurf/out
|
|
||||||
/vendor/libiconv/
|
|
||||||
lightpanda.id
|
lightpanda.id
|
||||||
/v8/
|
|
||||||
/build/
|
|
||||||
/src/html5ever/target/
|
/src/html5ever/target/
|
||||||
src/snapshot.bin
|
src/snapshot.bin
|
||||||
|
|||||||
15
.gitmodules
vendored
15
.gitmodules
vendored
@@ -1,15 +0,0 @@
|
|||||||
[submodule "tests/wpt"]
|
|
||||||
path = tests/wpt
|
|
||||||
url = https://github.com/lightpanda-io/wpt
|
|
||||||
[submodule "vendor/nghttp2"]
|
|
||||||
path = vendor/nghttp2
|
|
||||||
url = https://github.com/nghttp2/nghttp2.git
|
|
||||||
[submodule "vendor/zlib"]
|
|
||||||
path = vendor/zlib
|
|
||||||
url = https://github.com/madler/zlib.git
|
|
||||||
[submodule "vendor/curl"]
|
|
||||||
path = vendor/curl
|
|
||||||
url = https://github.com/curl/curl.git
|
|
||||||
[submodule "vendor/brotli"]
|
|
||||||
path = vendor/brotli
|
|
||||||
url = https://github.com/google/brotli
|
|
||||||
11
Dockerfile
11
Dockerfile
@@ -3,7 +3,7 @@ FROM debian:stable-slim
|
|||||||
ARG MINISIG=0.12
|
ARG MINISIG=0.12
|
||||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||||
ARG V8=14.0.365.4
|
ARG V8=14.0.365.4
|
||||||
ARG ZIG_V8=v0.2.9
|
ARG ZIG_V8=v0.3.4
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
RUN apt-get update -yq && \
|
RUN apt-get update -yq && \
|
||||||
@@ -36,10 +36,6 @@ RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
|
|||||||
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
|
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
|
||||||
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
|
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
|
||||||
|
|
||||||
# install deps
|
|
||||||
RUN git submodule init && \
|
|
||||||
git submodule update --recursive
|
|
||||||
|
|
||||||
# download and install v8
|
# download and install v8
|
||||||
RUN case $TARGETPLATFORM in \
|
RUN case $TARGETPLATFORM in \
|
||||||
"linux/arm64") ARCH="aarch64" ;; \
|
"linux/arm64") ARCH="aarch64" ;; \
|
||||||
@@ -57,8 +53,7 @@ RUN zig build -Doptimize=ReleaseFast \
|
|||||||
# build release
|
# build release
|
||||||
RUN zig build -Doptimize=ReleaseFast \
|
RUN zig build -Doptimize=ReleaseFast \
|
||||||
-Dsnapshot_path=../../snapshot.bin \
|
-Dsnapshot_path=../../snapshot.bin \
|
||||||
-Dprebuilt_v8_path=v8/libc_v8.a \
|
-Dprebuilt_v8_path=v8/libc_v8.a
|
||||||
-Dgit_commit=$(git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
FROM debian:stable-slim
|
FROM debian:stable-slim
|
||||||
|
|
||||||
@@ -79,4 +74,4 @@ EXPOSE 9222/tcp
|
|||||||
# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang.
|
# 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).
|
# (See https://github.com/krallin/tini#why-tini).
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log_level", "info"]
|
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log-level", "info"]
|
||||||
|
|||||||
@@ -4,11 +4,3 @@ License names used in this document are as per [SPDX License
|
|||||||
List](https://spdx.org/licenses/).
|
List](https://spdx.org/licenses/).
|
||||||
|
|
||||||
The default license for this project is [AGPL-3.0-only](LICENSE).
|
The default license for this project is [AGPL-3.0-only](LICENSE).
|
||||||
|
|
||||||
The following directories and their subdirectories are licensed under their
|
|
||||||
original upstream licenses:
|
|
||||||
|
|
||||||
```
|
|
||||||
vendor/
|
|
||||||
tests/wpt/
|
|
||||||
```
|
|
||||||
|
|||||||
28
Makefile
28
Makefile
@@ -47,7 +47,7 @@ help:
|
|||||||
|
|
||||||
# $(ZIG) commands
|
# $(ZIG) commands
|
||||||
# ------------
|
# ------------
|
||||||
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end
|
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
|
||||||
|
|
||||||
## Build v8 snapshot
|
## Build v8 snapshot
|
||||||
build-v8-snapshot:
|
build-v8-snapshot:
|
||||||
@@ -58,13 +58,13 @@ build-v8-snapshot:
|
|||||||
## Build in release-fast mode
|
## Build in release-fast mode
|
||||||
build: build-v8-snapshot
|
build: build-v8-snapshot
|
||||||
@printf "\033[36mBuilding (release fast)...\033[0m\n"
|
@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;)
|
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||||
@printf "\033[33mBuild OK\033[0m\n"
|
@printf "\033[33mBuild OK\033[0m\n"
|
||||||
|
|
||||||
## Build in debug mode
|
## Build in debug mode
|
||||||
build-dev:
|
build-dev:
|
||||||
@printf "\033[36mBuilding (debug)...\033[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;)
|
@$(ZIG) build || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||||
@printf "\033[33mBuild OK\033[0m\n"
|
@printf "\033[33mBuild OK\033[0m\n"
|
||||||
|
|
||||||
## Run the server in release mode
|
## Run the server in release mode
|
||||||
@@ -77,20 +77,6 @@ run-debug: build-dev
|
|||||||
@printf "\033[36mRunning...\033[0m\n"
|
@printf "\033[36mRunning...\033[0m\n"
|
||||||
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
|
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
|
||||||
|
|
||||||
## Run a JS shell in debug mode
|
|
||||||
shell:
|
|
||||||
@printf "\033[36mBuilding shell...\033[0m\n"
|
|
||||||
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
|
||||||
|
|
||||||
## Run WPT tests
|
|
||||||
wpt:
|
|
||||||
@printf "\033[36mBuilding wpt...\033[0m\n"
|
|
||||||
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
|
||||||
|
|
||||||
wpt-summary:
|
|
||||||
@printf "\033[36mBuilding wpt...\033[0m\n"
|
|
||||||
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
|
||||||
|
|
||||||
## Test - `grep` is used to filter out the huge compile command on build
|
## Test - `grep` is used to filter out the huge compile command on build
|
||||||
ifeq ($(OS), macos)
|
ifeq ($(OS), macos)
|
||||||
test:
|
test:
|
||||||
@@ -111,13 +97,7 @@ end2end:
|
|||||||
# ------------
|
# ------------
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
|
|
||||||
## Install and build dependencies for release
|
install: build
|
||||||
install: install-submodule
|
|
||||||
|
|
||||||
data:
|
data:
|
||||||
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
|
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
|
||||||
|
|
||||||
## Init and update git submodule
|
|
||||||
install-submodule:
|
|
||||||
@git submodule init && \
|
|
||||||
git submodule update
|
|
||||||
|
|||||||
118
README.md
118
README.md
@@ -1,18 +1,32 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
|
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Lightpanda Browser</h1>
|
<h1 align="center">Lightpanda Browser</h1>
|
||||||
|
<p align="center">
|
||||||
|
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
|
||||||
|
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
|
[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
|
||||||
[](https://twitter.com/lightpanda_io)
|
[](https://twitter.com/lightpanda_io)
|
||||||
[](https://github.com/lightpanda-io/browser)
|
[](https://github.com/lightpanda-io/browser)
|
||||||
|
[](https://discord.gg/K63XeymfB5)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time-v2.svg">
|
||||||
|
](https://github.com/lightpanda-io/demo)
|
||||||
|
 
|
||||||
|
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame-v2.svg">
|
||||||
|
](https://github.com/lightpanda-io/demo)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
_chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
|
||||||
|
See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._
|
||||||
|
|
||||||
Lightpanda is the open-source browser made for headless usage:
|
Lightpanda is the open-source browser made for headless usage:
|
||||||
|
|
||||||
@@ -26,16 +40,6 @@ Fast web automation for AI agents, LLM training, scraping and testing:
|
|||||||
- Exceptionally fast execution (11x faster than Chrome)
|
- Exceptionally fast execution (11x faster than Chrome)
|
||||||
- Instant startup
|
- Instant startup
|
||||||
|
|
||||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
|
|
||||||
](https://github.com/lightpanda-io/demo)
|
|
||||||
 
|
|
||||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
|
|
||||||
](https://github.com/lightpanda-io/demo)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
|
|
||||||
See [benchmark details](https://github.com/lightpanda-io/demo)._
|
|
||||||
|
|
||||||
[^1]: **Playwright support disclaimer:**
|
[^1]: **Playwright support disclaimer:**
|
||||||
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
|
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
|
|||||||
### Dump a URL
|
### Dump a URL
|
||||||
|
|
||||||
```console
|
```console
|
||||||
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
|
./lightpanda fetch --obey-robots --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/
|
||||||
```
|
```
|
||||||
```console
|
```console
|
||||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||||
@@ -113,7 +117,7 @@ INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
|
|||||||
### Start a CDP server
|
### Start a CDP server
|
||||||
|
|
||||||
```console
|
```console
|
||||||
./lightpanda serve --obey_robots --log_format pretty --log_level info --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
|
```console
|
||||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||||
@@ -182,12 +186,10 @@ Here are the key features we have implemented:
|
|||||||
- [x] Custom HTTP headers
|
- [x] Custom HTTP headers
|
||||||
- [x] Proxy support
|
- [x] Proxy support
|
||||||
- [x] Network interception
|
- [x] Network interception
|
||||||
- [x] Respect `robots.txt` with option `--obey_robots`
|
- [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.
|
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||||
|
|
||||||
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
|
|
||||||
|
|
||||||
## Build from sources
|
## Build from sources
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -196,10 +198,10 @@ 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.
|
install it with the right version in order to build the project.
|
||||||
|
|
||||||
Lightpanda also depends on
|
Lightpanda also depends on
|
||||||
[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
|
[v8](https://chromium.googlesource.com/v8/v8.git),
|
||||||
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
|
[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:
|
To be able to build the v8 engine, you have to install some libs:
|
||||||
|
|
||||||
For **Debian/Ubuntu based Linux**:
|
For **Debian/Ubuntu based Linux**:
|
||||||
|
|
||||||
@@ -220,18 +222,6 @@ For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
|
|||||||
brew install cmake
|
brew install cmake
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install Git submodules
|
|
||||||
|
|
||||||
The project uses git submodules for dependencies.
|
|
||||||
|
|
||||||
To init or update the submodules in the `vendor/` directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
make install-submodule
|
|
||||||
```
|
|
||||||
|
|
||||||
This is an alias for `git submodule init && git submodule update`.
|
|
||||||
|
|
||||||
### Build and run
|
### Build and run
|
||||||
|
|
||||||
You an build the entire browser with `make build` or `make build-dev` for debug
|
You an build the entire browser with `make build` or `make build-dev` for debug
|
||||||
@@ -281,35 +271,75 @@ make end2end
|
|||||||
Lightpanda is tested against the standardized [Web Platform
|
Lightpanda is tested against the standardized [Web Platform
|
||||||
Tests](https://web-platform-tests.org/).
|
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.
|
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).
|
||||||
All the tests cases executed are located in the `tests/wpt` sub-directory.
|
|
||||||
|
|
||||||
For reference, you can easily execute a WPT test case with your browser via
|
For reference, you can easily execute a WPT test case with your browser via
|
||||||
[wpt.live](https://wpt.live).
|
[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
|
#### 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:
|
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.
|
zig build -Doptimize=ReleaseFast run
|
||||||
|
```
|
||||||
:warning: Please keep the original directory tree structure of `tests/wpt`.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
.{
|
.{
|
||||||
.name = .browser,
|
.name = .browser,
|
||||||
.paths = .{""},
|
.version = "1.0.0-dev",
|
||||||
.version = "0.0.0",
|
|
||||||
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
|
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
|
||||||
.minimum_zig_version = "0.15.2",
|
.minimum_zig_version = "0.15.2",
|
||||||
.dependencies = .{
|
.dependencies = .{
|
||||||
.v8 = .{
|
.v8 = .{
|
||||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.2.9.tar.gz",
|
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.5.tar.gz",
|
||||||
.hash = "v8-0.0.0-xddH689vBACgpqFVEhT2wxRin-qQQSOcKJoM37MVo0rU",
|
.hash = "v8-0.0.0-xddH66Z5BABx8CmC6u6qNOjrT4_42uliDSnA7Yg0pcBe",
|
||||||
},
|
},
|
||||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||||
|
.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" = .{
|
.@"boringssl-zig" = .{
|
||||||
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
|
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
|
||||||
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
|
.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 = .{""},
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/App.zig
40
src/App.zig
@@ -25,35 +25,38 @@ const Config = @import("Config.zig");
|
|||||||
const Snapshot = @import("browser/js/Snapshot.zig");
|
const Snapshot = @import("browser/js/Snapshot.zig");
|
||||||
const Platform = @import("browser/js/Platform.zig");
|
const Platform = @import("browser/js/Platform.zig");
|
||||||
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
||||||
const RobotStore = @import("browser/Robots.zig").RobotStore;
|
|
||||||
|
|
||||||
pub const Http = @import("http/Http.zig");
|
const Network = @import("network/Runtime.zig");
|
||||||
pub const ArenaPool = @import("ArenaPool.zig");
|
pub const ArenaPool = @import("ArenaPool.zig");
|
||||||
|
|
||||||
const App = @This();
|
const App = @This();
|
||||||
|
|
||||||
http: Http,
|
network: Network,
|
||||||
config: *const Config,
|
config: *const Config,
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
telemetry: Telemetry,
|
telemetry: Telemetry,
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
arena_pool: ArenaPool,
|
arena_pool: ArenaPool,
|
||||||
robots: RobotStore,
|
|
||||||
app_dir_path: ?[]const u8,
|
app_dir_path: ?[]const u8,
|
||||||
shutdown: bool = false,
|
|
||||||
|
|
||||||
pub fn init(allocator: Allocator, config: *const Config) !*App {
|
pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||||
const app = try allocator.create(App);
|
const app = try allocator.create(App);
|
||||||
errdefer allocator.destroy(app);
|
errdefer allocator.destroy(app);
|
||||||
|
|
||||||
app.config = config;
|
app.* = .{
|
||||||
app.allocator = allocator;
|
.config = config,
|
||||||
|
.allocator = allocator,
|
||||||
|
.network = undefined,
|
||||||
|
.platform = undefined,
|
||||||
|
.snapshot = undefined,
|
||||||
|
.app_dir_path = undefined,
|
||||||
|
.telemetry = undefined,
|
||||||
|
.arena_pool = undefined,
|
||||||
|
};
|
||||||
|
|
||||||
app.robots = RobotStore.init(allocator);
|
app.network = try Network.init(allocator, config);
|
||||||
|
errdefer app.network.deinit();
|
||||||
app.http = try Http.init(allocator, &app.robots, config);
|
|
||||||
errdefer app.http.deinit();
|
|
||||||
|
|
||||||
app.platform = try Platform.init();
|
app.platform = try Platform.init();
|
||||||
errdefer app.platform.deinit();
|
errdefer app.platform.deinit();
|
||||||
@@ -64,27 +67,26 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
|
|||||||
app.app_dir_path = getAndMakeAppDir(allocator);
|
app.app_dir_path = getAndMakeAppDir(allocator);
|
||||||
|
|
||||||
app.telemetry = try Telemetry.init(app, config.mode);
|
app.telemetry = try Telemetry.init(app, config.mode);
|
||||||
errdefer app.telemetry.deinit();
|
errdefer app.telemetry.deinit(allocator);
|
||||||
|
|
||||||
app.arena_pool = ArenaPool.init(allocator);
|
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
|
||||||
errdefer app.arena_pool.deinit();
|
errdefer app.arena_pool.deinit();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *App) void {
|
pub fn shutdown(self: *const App) bool {
|
||||||
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
|
return self.network.shutdown.load(.acquire);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *App) void {
|
||||||
const allocator = self.allocator;
|
const allocator = self.allocator;
|
||||||
if (self.app_dir_path) |app_dir_path| {
|
if (self.app_dir_path) |app_dir_path| {
|
||||||
allocator.free(app_dir_path);
|
allocator.free(app_dir_path);
|
||||||
self.app_dir_path = null;
|
self.app_dir_path = null;
|
||||||
}
|
}
|
||||||
self.telemetry.deinit();
|
self.telemetry.deinit(allocator);
|
||||||
self.robots.deinit();
|
self.network.deinit();
|
||||||
self.http.deinit();
|
|
||||||
self.snapshot.deinit();
|
self.snapshot.deinit();
|
||||||
self.platform.deinit();
|
self.platform.deinit();
|
||||||
self.arena_pool.deinit();
|
self.arena_pool.deinit();
|
||||||
|
|||||||
@@ -17,12 +17,15 @@
|
|||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||||
|
|
||||||
const ArenaPool = @This();
|
const ArenaPool = @This();
|
||||||
|
|
||||||
|
const IS_DEBUG = builtin.mode == .Debug;
|
||||||
|
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
retain_bytes: usize,
|
retain_bytes: usize,
|
||||||
free_list_len: u16 = 0,
|
free_list_len: u16 = 0,
|
||||||
@@ -30,22 +33,45 @@ free_list: ?*Entry = null,
|
|||||||
free_list_max: u16,
|
free_list_max: u16,
|
||||||
entry_pool: std.heap.MemoryPool(Entry),
|
entry_pool: std.heap.MemoryPool(Entry),
|
||||||
mutex: std.Thread.Mutex = .{},
|
mutex: std.Thread.Mutex = .{},
|
||||||
|
// Debug mode: track acquire/release counts per debug name to detect leaks and double-frees
|
||||||
|
_leak_track: if (IS_DEBUG) std.StringHashMapUnmanaged(isize) else void = if (IS_DEBUG) .empty else {},
|
||||||
|
|
||||||
const Entry = struct {
|
const Entry = struct {
|
||||||
next: ?*Entry,
|
next: ?*Entry,
|
||||||
arena: ArenaAllocator,
|
arena: ArenaAllocator,
|
||||||
|
debug: if (IS_DEBUG) []const u8 else void = if (IS_DEBUG) "" else {},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn init(allocator: Allocator) ArenaPool {
|
pub const DebugInfo = struct {
|
||||||
|
debug: []const u8 = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool {
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.free_list_max = 512, // TODO make configurable
|
.free_list_max = free_list_max,
|
||||||
.retain_bytes = 1024 * 16, // TODO make configurable
|
.retain_bytes = retain_bytes,
|
||||||
.entry_pool = std.heap.MemoryPool(Entry).init(allocator),
|
.entry_pool = .init(allocator),
|
||||||
|
._leak_track = if (IS_DEBUG) .empty else {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *ArenaPool) void {
|
pub fn deinit(self: *ArenaPool) void {
|
||||||
|
if (IS_DEBUG) {
|
||||||
|
var has_leaks = false;
|
||||||
|
var it = self._leak_track.iterator();
|
||||||
|
while (it.next()) |kv| {
|
||||||
|
if (kv.value_ptr.* != 0) {
|
||||||
|
std.debug.print("ArenaPool leak detected: '{s}' count={d}\n", .{ kv.key_ptr.*, kv.value_ptr.* });
|
||||||
|
has_leaks = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (has_leaks) {
|
||||||
|
@panic("ArenaPool: leaked arenas detected");
|
||||||
|
}
|
||||||
|
self._leak_track.deinit(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
var entry = self.free_list;
|
var entry = self.free_list;
|
||||||
while (entry) |e| {
|
while (entry) |e| {
|
||||||
entry = e.next;
|
entry = e.next;
|
||||||
@@ -54,13 +80,21 @@ pub fn deinit(self: *ArenaPool) void {
|
|||||||
self.entry_pool.deinit();
|
self.entry_pool.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn acquire(self: *ArenaPool) !Allocator {
|
pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator {
|
||||||
self.mutex.lock();
|
self.mutex.lock();
|
||||||
defer self.mutex.unlock();
|
defer self.mutex.unlock();
|
||||||
|
|
||||||
if (self.free_list) |entry| {
|
if (self.free_list) |entry| {
|
||||||
self.free_list = entry.next;
|
self.free_list = entry.next;
|
||||||
self.free_list_len -= 1;
|
self.free_list_len -= 1;
|
||||||
|
if (IS_DEBUG) {
|
||||||
|
entry.debug = dbg.debug;
|
||||||
|
const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug);
|
||||||
|
if (!gop.found_existing) {
|
||||||
|
gop.value_ptr.* = 0;
|
||||||
|
}
|
||||||
|
gop.value_ptr.* += 1;
|
||||||
|
}
|
||||||
return entry.arena.allocator();
|
return entry.arena.allocator();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +102,16 @@ pub fn acquire(self: *ArenaPool) !Allocator {
|
|||||||
entry.* = .{
|
entry.* = .{
|
||||||
.next = null,
|
.next = null,
|
||||||
.arena = ArenaAllocator.init(self.allocator),
|
.arena = ArenaAllocator.init(self.allocator),
|
||||||
|
.debug = if (IS_DEBUG) dbg.debug else {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (IS_DEBUG) {
|
||||||
|
const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug);
|
||||||
|
if (!gop.found_existing) {
|
||||||
|
gop.value_ptr.* = 0;
|
||||||
|
}
|
||||||
|
gop.value_ptr.* += 1;
|
||||||
|
}
|
||||||
return entry.arena.allocator();
|
return entry.arena.allocator();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +125,19 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
|
|||||||
self.mutex.lock();
|
self.mutex.lock();
|
||||||
defer self.mutex.unlock();
|
defer self.mutex.unlock();
|
||||||
|
|
||||||
|
if (IS_DEBUG) {
|
||||||
|
if (self._leak_track.getPtr(entry.debug)) |count| {
|
||||||
|
count.* -= 1;
|
||||||
|
if (count.* < 0) {
|
||||||
|
std.debug.print("ArenaPool double-free detected: '{s}'\n", .{entry.debug});
|
||||||
|
@panic("ArenaPool: double-free detected");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std.debug.print("ArenaPool release of untracked arena: '{s}'\n", .{entry.debug});
|
||||||
|
@panic("ArenaPool: release of untracked arena");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const free_list_len = self.free_list_len;
|
const free_list_len = self.free_list_len;
|
||||||
if (free_list_len == self.free_list_max) {
|
if (free_list_len == self.free_list_max) {
|
||||||
arena.deinit();
|
arena.deinit();
|
||||||
@@ -99,3 +154,119 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
|
|||||||
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
|
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
|
||||||
_ = arena.reset(.{ .retain_with_limit = retain });
|
_ = arena.reset(.{ .retain_with_limit = retain });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resetRetain(_: *const ArenaPool, allocator: Allocator) void {
|
||||||
|
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
|
||||||
|
_ = arena.reset(.retain_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(.{ .debug = "test" });
|
||||||
|
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(.{ .debug = "test" });
|
||||||
|
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(.{ .debug = "test" });
|
||||||
|
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(.{ .debug = "test1" });
|
||||||
|
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||||
|
const a3 = try pool.acquire(.{ .debug = "test3" });
|
||||||
|
|
||||||
|
// 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(.{ .debug = "test1" });
|
||||||
|
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||||
|
|
||||||
|
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(.{ .debug = "test" });
|
||||||
|
|
||||||
|
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(.{ .debug = "test1" });
|
||||||
|
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||||
|
_ = 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();
|
||||||
|
}
|
||||||
|
|||||||
348
src/Config.zig
348
src/Config.zig
@@ -23,13 +23,17 @@ const Allocator = std.mem.Allocator;
|
|||||||
const log = @import("log.zig");
|
const log = @import("log.zig");
|
||||||
const dump = @import("browser/dump.zig");
|
const dump = @import("browser/dump.zig");
|
||||||
|
|
||||||
|
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||||
|
|
||||||
pub const RunMode = enum {
|
pub const RunMode = enum {
|
||||||
help,
|
help,
|
||||||
fetch,
|
fetch,
|
||||||
serve,
|
serve,
|
||||||
version,
|
version,
|
||||||
|
mcp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const MAX_LISTENERS = 16;
|
||||||
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
|
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
|
||||||
|
|
||||||
// max message size
|
// max message size
|
||||||
@@ -59,56 +63,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void {
|
|||||||
|
|
||||||
pub fn tlsVerifyHost(self: *const Config) bool {
|
pub fn tlsVerifyHost(self: *const Config) bool {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.tls_verify_host,
|
inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn obeyRobots(self: *const Config) bool {
|
pub fn obeyRobots(self: *const Config) bool {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.obey_robots,
|
inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
|
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_proxy,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
|
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.proxy_bearer_token,
|
inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
|
||||||
.help, .version => null,
|
.help, .version => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn httpMaxConcurrent(self: *const Config) u8 {
|
pub fn httpMaxConcurrent(self: *const Config) u8 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_max_concurrent orelse 10,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn httpMaxHostOpen(self: *const Config) u8 {
|
pub fn httpMaxHostOpen(self: *const Config) u8 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_max_host_open orelse 4,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn httpConnectTimeout(self: *const Config) u31 {
|
pub fn httpConnectTimeout(self: *const Config) u31 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_connect_timeout orelse 0,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn httpTimeout(self: *const Config) u31 {
|
pub fn httpTimeout(self: *const Config) u31 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_timeout orelse 5000,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -119,35 +123,67 @@ pub fn httpMaxRedirects(_: *const Config) u8 {
|
|||||||
|
|
||||||
pub fn httpMaxResponseSize(self: *const Config) ?usize {
|
pub fn httpMaxResponseSize(self: *const Config) ?usize {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.http_max_response_size,
|
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logLevel(self: *const Config) ?log.Level {
|
pub fn logLevel(self: *const Config) ?log.Level {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.log_level,
|
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logFormat(self: *const Config) ?log.Format {
|
pub fn logFormat(self: *const Config) ?log.Format {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.log_format,
|
inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
|
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.log_filter_scopes,
|
inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
|
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
inline .serve, .fetch => |opts| opts.common.user_agent_suffix,
|
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
|
||||||
|
.help, .version => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cdpTimeout(self: *const Config) usize {
|
||||||
|
return switch (self.mode) {
|
||||||
|
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
|
||||||
|
else => unreachable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(self: *const Config) u16 {
|
||||||
|
return switch (self.mode) {
|
||||||
|
.serve => |opts| opts.port,
|
||||||
|
else => unreachable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advertiseHost(self: *const Config) []const u8 {
|
||||||
|
return switch (self.mode) {
|
||||||
|
.serve => |opts| opts.advertise_host orelse opts.host,
|
||||||
|
else => unreachable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||||
|
return switch (self.mode) {
|
||||||
|
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
|
||||||
|
.key_file = opts.common.web_bot_auth_key_file orelse return null,
|
||||||
|
.keyid = opts.common.web_bot_auth_keyid orelse return null,
|
||||||
|
.domain = opts.common.web_bot_auth_domain orelse return null,
|
||||||
|
},
|
||||||
.help, .version => null,
|
.help, .version => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,28 +207,47 @@ pub const Mode = union(RunMode) {
|
|||||||
fetch: Fetch,
|
fetch: Fetch,
|
||||||
serve: Serve,
|
serve: Serve,
|
||||||
version: void,
|
version: void,
|
||||||
|
mcp: Mcp,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Serve = struct {
|
pub const Serve = struct {
|
||||||
host: []const u8 = "127.0.0.1",
|
host: []const u8 = "127.0.0.1",
|
||||||
port: u16 = 9222,
|
port: u16 = 9222,
|
||||||
|
advertise_host: ?[]const u8 = null,
|
||||||
timeout: u31 = 10,
|
timeout: u31 = 10,
|
||||||
cdp_max_connections: u16 = 16,
|
cdp_max_connections: u16 = 16,
|
||||||
cdp_max_pending_connections: u16 = 128,
|
cdp_max_pending_connections: u16 = 128,
|
||||||
common: Common = .{},
|
common: Common = .{},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const Mcp = struct {
|
||||||
|
common: Common = .{},
|
||||||
|
};
|
||||||
|
|
||||||
pub const DumpFormat = enum {
|
pub const DumpFormat = enum {
|
||||||
html,
|
html,
|
||||||
markdown,
|
markdown,
|
||||||
|
wpt,
|
||||||
|
semantic_tree,
|
||||||
|
semantic_tree_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const WaitUntil = enum {
|
||||||
|
load,
|
||||||
|
domcontentloaded,
|
||||||
|
networkidle,
|
||||||
|
done,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Fetch = struct {
|
pub const Fetch = struct {
|
||||||
url: [:0]const u8,
|
url: [:0]const u8,
|
||||||
dump_mode: ?DumpFormat = null,
|
dump_mode: ?DumpFormat = null,
|
||||||
common: Common = .{},
|
common: Common = .{},
|
||||||
withbase: bool = false,
|
with_base: bool = false,
|
||||||
|
with_frames: bool = false,
|
||||||
strip: dump.Opts.Strip = .{},
|
strip: dump.Opts.Strip = .{},
|
||||||
|
wait_ms: u32 = 5000,
|
||||||
|
wait_until: WaitUntil = .load,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Common = struct {
|
pub const Common = struct {
|
||||||
@@ -209,6 +264,10 @@ pub const Common = struct {
|
|||||||
log_format: ?log.Format = null,
|
log_format: ?log.Format = null,
|
||||||
log_filter_scopes: ?[]log.Scope = null,
|
log_filter_scopes: ?[]log.Scope = null,
|
||||||
user_agent_suffix: ?[]const u8 = null,
|
user_agent_suffix: ?[]const u8 = null,
|
||||||
|
|
||||||
|
web_bot_auth_key_file: ?[]const u8 = null,
|
||||||
|
web_bot_auth_keyid: ?[]const u8 = null,
|
||||||
|
web_bot_auth_domain: ?[]const u8 = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pre-formatted HTTP headers for reuse across Http and Client.
|
/// Pre-formatted HTTP headers for reuse across Http and Client.
|
||||||
@@ -258,71 +317,79 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
// MAX_HELP_LEN|
|
// MAX_HELP_LEN|
|
||||||
const common_options =
|
const common_options =
|
||||||
\\
|
\\
|
||||||
\\--insecure_disable_tls_host_verification
|
\\--insecure-disable-tls-host-verification
|
||||||
\\ Disables host verification on all HTTP requests. This is an
|
\\ Disables host verification on all HTTP requests. This is an
|
||||||
\\ advanced option which should only be set if you understand
|
\\ advanced option which should only be set if you understand
|
||||||
\\ and accept the risk of disabling host verification.
|
\\ and accept the risk of disabling host verification.
|
||||||
\\
|
\\
|
||||||
\\--obey_robots
|
\\--obey-robots
|
||||||
\\ Fetches and obeys the robots.txt (if available) of the web pages
|
\\ Fetches and obeys the robots.txt (if available) of the web pages
|
||||||
\\ we make requests towards.
|
\\ we make requests towards.
|
||||||
\\ Defaults to false.
|
\\ Defaults to false.
|
||||||
\\
|
\\
|
||||||
\\--http_proxy The HTTP proxy to use for all HTTP requests.
|
\\--http-proxy The HTTP proxy to use for all HTTP requests.
|
||||||
\\ A username:password can be included for basic authentication.
|
\\ A username:password can be included for basic authentication.
|
||||||
\\ Defaults to none.
|
\\ Defaults to none.
|
||||||
\\
|
\\
|
||||||
\\--proxy_bearer_token
|
\\--proxy-bearer-token
|
||||||
\\ The <token> to send for bearer authentication with the proxy
|
\\ The <token> to send for bearer authentication with the proxy
|
||||||
\\ Proxy-Authorization: Bearer <token>
|
\\ Proxy-Authorization: Bearer <token>
|
||||||
\\
|
\\
|
||||||
\\--http_max_concurrent
|
\\--http-max-concurrent
|
||||||
\\ The maximum number of concurrent HTTP requests.
|
\\ The maximum number of concurrent HTTP requests.
|
||||||
\\ Defaults to 10.
|
\\ Defaults to 10.
|
||||||
\\
|
\\
|
||||||
\\--http_max_host_open
|
\\--http-max-host-open
|
||||||
\\ The maximum number of open connection to a given host:port.
|
\\ The maximum number of open connection to a given host:port.
|
||||||
\\ Defaults to 4.
|
\\ Defaults to 4.
|
||||||
\\
|
\\
|
||||||
\\--http_connect_timeout
|
\\--http-connect-timeout
|
||||||
\\ The time, in milliseconds, for establishing an HTTP connection
|
\\ The time, in milliseconds, for establishing an HTTP connection
|
||||||
\\ before timing out. 0 means it never times out.
|
\\ before timing out. 0 means it never times out.
|
||||||
\\ Defaults to 0.
|
\\ Defaults to 0.
|
||||||
\\
|
\\
|
||||||
\\--http_timeout
|
\\--http-timeout
|
||||||
\\ The maximum time, in milliseconds, the transfer is allowed
|
\\ The maximum time, in milliseconds, the transfer is allowed
|
||||||
\\ to complete. 0 means it never times out.
|
\\ to complete. 0 means it never times out.
|
||||||
\\ Defaults to 10000.
|
\\ Defaults to 10000.
|
||||||
\\
|
\\
|
||||||
\\--http_max_response_size
|
\\--http-max-response-size
|
||||||
\\ Limits the acceptable response size for any request
|
\\ Limits the acceptable response size for any request
|
||||||
\\ (e.g. XHR, fetch, script loading, ...).
|
\\ (e.g. XHR, fetch, script loading, ...).
|
||||||
\\ Defaults to no limit.
|
\\ Defaults to no limit.
|
||||||
\\
|
\\
|
||||||
\\--log_level The log level: debug, info, warn, error or fatal.
|
\\--log-level The log level: debug, info, warn, error or fatal.
|
||||||
\\ Defaults to
|
\\ Defaults to
|
||||||
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
|
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
|
||||||
\\
|
\\
|
||||||
\\
|
\\
|
||||||
\\--log_format The log format: pretty or logfmt.
|
\\--log-format The log format: pretty or logfmt.
|
||||||
\\ Defaults to
|
\\ Defaults to
|
||||||
++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++
|
++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++
|
||||||
\\
|
\\
|
||||||
\\
|
\\
|
||||||
\\--log_filter_scopes
|
\\--log-filter-scopes
|
||||||
\\ Filter out too verbose logs per scope:
|
\\ Filter out too verbose logs per scope:
|
||||||
\\ http, unknown_prop, event, ...
|
\\ http, unknown_prop, event, ...
|
||||||
\\
|
\\
|
||||||
\\--user_agent_suffix
|
\\--user-agent-suffix
|
||||||
\\ Suffix to append to the Lightpanda/X.Y User-Agent
|
\\ Suffix to append to the Lightpanda/X.Y User-Agent
|
||||||
\\
|
\\
|
||||||
|
\\--web-bot-auth-key-file
|
||||||
|
\\ Path to the Ed25519 private key PEM file.
|
||||||
|
\\
|
||||||
|
\\--web-bot-auth-keyid
|
||||||
|
\\ The JWK thumbprint of your public key.
|
||||||
|
\\
|
||||||
|
\\--web-bot-auth-domain
|
||||||
|
\\ Your domain e.g. yourdomain.com
|
||||||
;
|
;
|
||||||
|
|
||||||
// MAX_HELP_LEN|
|
// MAX_HELP_LEN|
|
||||||
const usage =
|
const usage =
|
||||||
\\usage: {s} command [options] [URL]
|
\\usage: {s} command [options] [URL]
|
||||||
\\
|
\\
|
||||||
\\Command can be either 'fetch', 'serve' or 'help'
|
\\Command can be either 'fetch', 'serve', 'mcp' or 'help'
|
||||||
\\
|
\\
|
||||||
\\fetch command
|
\\fetch command
|
||||||
\\Fetches the specified URL
|
\\Fetches the specified URL
|
||||||
@@ -330,17 +397,26 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
\\
|
\\
|
||||||
\\Options:
|
\\Options:
|
||||||
\\--dump Dumps document to stdout.
|
\\--dump Dumps document to stdout.
|
||||||
\\ Argument must be 'html' or 'markdown'.
|
\\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'.
|
||||||
\\ Defaults to no dump.
|
\\ Defaults to no dump.
|
||||||
\\
|
\\
|
||||||
\\--strip_mode Comma separated list of tag groups to remove from dump
|
\\--strip-mode Comma separated list of tag groups to remove from dump
|
||||||
\\ the dump. e.g. --strip_mode js,css
|
\\ the dump. e.g. --strip-mode js,css
|
||||||
\\ - "js" script and link[as=script, rel=preload]
|
\\ - "js" script and link[as=script, rel=preload]
|
||||||
\\ - "ui" includes img, picture, video, css and svg
|
\\ - "ui" includes img, picture, video, css and svg
|
||||||
\\ - "css" includes style and link[rel=stylesheet]
|
\\ - "css" includes style and link[rel=stylesheet]
|
||||||
\\ - "full" includes js, ui and css
|
\\ - "full" includes js, ui and css
|
||||||
\\
|
\\
|
||||||
\\--with_base Add a <base> tag in dump. Defaults to false.
|
\\--with-base Add a <base> tag in dump. Defaults to false.
|
||||||
|
\\
|
||||||
|
\\--with-frames Includes the contents of iframes. Defaults to false.
|
||||||
|
\\
|
||||||
|
\\--wait-ms Wait time in milliseconds.
|
||||||
|
\\ Defaults to 5000.
|
||||||
|
\\
|
||||||
|
\\--wait-until Wait until the specified event.
|
||||||
|
\\ Supported events: load, domcontentloaded, networkidle, done.
|
||||||
|
\\ Defaults to 'done'.
|
||||||
\\
|
\\
|
||||||
++ common_options ++
|
++ common_options ++
|
||||||
\\
|
\\
|
||||||
@@ -355,17 +431,28 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
\\--port Port of the CDP server
|
\\--port Port of the CDP server
|
||||||
\\ Defaults to 9222
|
\\ Defaults to 9222
|
||||||
\\
|
\\
|
||||||
|
\\--advertise-host
|
||||||
|
\\ The host to advertise, e.g. in the /json/version response.
|
||||||
|
\\ Useful, for example, when --host is 0.0.0.0.
|
||||||
|
\\ Defaults to --host value
|
||||||
|
\\
|
||||||
\\--timeout Inactivity timeout in seconds before disconnecting clients
|
\\--timeout Inactivity timeout in seconds before disconnecting clients
|
||||||
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
|
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
|
||||||
\\
|
\\
|
||||||
\\--cdp_max_connections
|
\\--cdp-max-connections
|
||||||
\\ Maximum number of simultaneous CDP connections.
|
\\ Maximum number of simultaneous CDP connections.
|
||||||
\\ Defaults to 16.
|
\\ Defaults to 16.
|
||||||
\\
|
\\
|
||||||
\\--cdp_max_pending_connections
|
\\--cdp-max-pending-connections
|
||||||
\\ Maximum pending connections in the accept queue.
|
\\ Maximum pending connections in the accept queue.
|
||||||
\\ Defaults to 128.
|
\\ Defaults to 128.
|
||||||
\\
|
\\
|
||||||
|
++ common_options ++
|
||||||
|
\\
|
||||||
|
\\mcp command
|
||||||
|
\\Starts an MCP (Model Context Protocol) server over stdio
|
||||||
|
\\Example: {s} mcp
|
||||||
|
\\
|
||||||
++ common_options ++
|
++ common_options ++
|
||||||
\\
|
\\
|
||||||
\\version command
|
\\version command
|
||||||
@@ -375,7 +462,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
\\Displays this message
|
\\Displays this message
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name });
|
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
|
||||||
if (success) {
|
if (success) {
|
||||||
return std.process.cleanExit();
|
return std.process.cleanExit();
|
||||||
}
|
}
|
||||||
@@ -410,6 +497,8 @@ pub fn parseArgs(allocator: Allocator) !Config {
|
|||||||
return init(allocator, exec_name, .{ .help = false }) },
|
return init(allocator, exec_name, .{ .help = false }) },
|
||||||
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
|
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
|
||||||
return init(allocator, exec_name, .{ .help = false }) },
|
return init(allocator, exec_name, .{ .help = false }) },
|
||||||
|
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
|
||||||
|
return init(allocator, exec_name, .{ .help = false }) },
|
||||||
.version => .{ .version = {} },
|
.version => .{ .version = {} },
|
||||||
};
|
};
|
||||||
return init(allocator, exec_name, mode);
|
return init(allocator, exec_name, mode);
|
||||||
@@ -432,11 +521,15 @@ fn inferMode(opt: []const u8) ?RunMode {
|
|||||||
return .fetch;
|
return .fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, opt, "--strip_mode")) {
|
if (std.mem.eql(u8, opt, "--strip-mode") or std.mem.eql(u8, opt, "--strip_mode")) {
|
||||||
return .fetch;
|
return .fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, opt, "--with_base")) {
|
if (std.mem.eql(u8, opt, "--with-base") or std.mem.eql(u8, opt, "--with_base")) {
|
||||||
|
return .fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, opt, "--with-frames") or std.mem.eql(u8, opt, "--with_frames")) {
|
||||||
return .fetch;
|
return .fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +577,15 @@ fn parseServeArgs(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--advertise-host", opt) or std.mem.eql(u8, "--advertise_host", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
serve.advertise_host = try allocator.dupe(u8, str);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--timeout", opt)) {
|
if (std.mem.eql(u8, "--timeout", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
|
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
|
||||||
@@ -497,27 +599,27 @@ fn parseServeArgs(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
|
if (std.mem.eql(u8, "--cdp-max-connections", opt) or std.mem.eql(u8, "--cdp_max_connections", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||||
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
|
if (std.mem.eql(u8, "--cdp-max-pending-connections", opt) or std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
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 });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
@@ -534,17 +636,62 @@ fn parseServeArgs(
|
|||||||
return serve;
|
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(
|
fn parseFetchArgs(
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
args: *std.process.ArgIterator,
|
args: *std.process.ArgIterator,
|
||||||
) !Fetch {
|
) !Fetch {
|
||||||
var dump_mode: ?DumpFormat = null;
|
var dump_mode: ?DumpFormat = null;
|
||||||
var withbase: bool = false;
|
var with_base: bool = false;
|
||||||
|
var with_frames: bool = false;
|
||||||
var url: ?[:0]const u8 = null;
|
var url: ?[:0]const u8 = null;
|
||||||
var common: Common = .{};
|
var common: Common = .{};
|
||||||
var strip: dump.Opts.Strip = .{};
|
var strip: dump.Opts.Strip = .{};
|
||||||
|
var wait_ms: u32 = 5000;
|
||||||
|
var wait_until: WaitUntil = .load;
|
||||||
|
|
||||||
while (args.next()) |opt| {
|
while (args.next()) |opt| {
|
||||||
|
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
wait_ms = std.fmt.parseInt(u32, str, 10) catch |err| {
|
||||||
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--wait-until", opt) or std.mem.eql(u8, "--wait_until", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
wait_until = std.meta.stringToEnum(WaitUntil, str) orelse {
|
||||||
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--dump", opt)) {
|
if (std.mem.eql(u8, "--dump", opt)) {
|
||||||
var peek_args = args.*;
|
var peek_args = args.*;
|
||||||
if (peek_args.next()) |next_arg| {
|
if (peek_args.next()) |next_arg| {
|
||||||
@@ -563,20 +710,25 @@ fn parseFetchArgs(
|
|||||||
if (std.mem.eql(u8, "--noscript", opt)) {
|
if (std.mem.eql(u8, "--noscript", opt)) {
|
||||||
log.warn(.app, "deprecation warning", .{
|
log.warn(.app, "deprecation warning", .{
|
||||||
.feature = "--noscript argument",
|
.feature = "--noscript argument",
|
||||||
.hint = "use '--strip_mode js' instead",
|
.hint = "use '--strip-mode js' instead",
|
||||||
});
|
});
|
||||||
strip.js = true;
|
strip.js = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--with_base", opt)) {
|
if (std.mem.eql(u8, "--with-base", opt) or std.mem.eql(u8, "--with_base", opt)) {
|
||||||
withbase = true;
|
with_base = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--strip_mode", opt)) {
|
if (std.mem.eql(u8, "--with-frames", opt) or std.mem.eql(u8, "--with_frames", opt)) {
|
||||||
|
with_frames = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--strip-mode", opt) or std.mem.eql(u8, "--strip_mode", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -594,7 +746,7 @@ fn parseFetchArgs(
|
|||||||
strip.ui = true;
|
strip.ui = true;
|
||||||
strip.css = true;
|
strip.css = true;
|
||||||
} else {
|
} else {
|
||||||
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed });
|
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = trimmed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -626,7 +778,10 @@ fn parseFetchArgs(
|
|||||||
.dump_mode = dump_mode,
|
.dump_mode = dump_mode,
|
||||||
.strip = strip,
|
.strip = strip,
|
||||||
.common = common,
|
.common = common,
|
||||||
.withbase = withbase,
|
.with_base = with_base,
|
||||||
|
.with_frames = with_frames,
|
||||||
|
.wait_ms = wait_ms,
|
||||||
|
.wait_until = wait_until,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,102 +791,102 @@ fn parseCommonArg(
|
|||||||
args: *std.process.ArgIterator,
|
args: *std.process.ArgIterator,
|
||||||
common: *Common,
|
common: *Common,
|
||||||
) !bool {
|
) !bool {
|
||||||
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
|
if (std.mem.eql(u8, "--insecure-disable-tls-host-verification", opt) or std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
|
||||||
common.tls_verify_host = false;
|
common.tls_verify_host = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--obey_robots", opt)) {
|
if (std.mem.eql(u8, "--obey-robots", opt) or std.mem.eql(u8, "--obey_robots", opt)) {
|
||||||
common.obey_robots = true;
|
common.obey_robots = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_proxy", opt)) {
|
if (std.mem.eql(u8, "--http-proxy", opt) or std.mem.eql(u8, "--http_proxy", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
common.http_proxy = try allocator.dupeZ(u8, str);
|
common.http_proxy = try allocator.dupeZ(u8, str);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
if (std.mem.eql(u8, "--proxy-bearer-token", opt) or std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
|
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
|
if (std.mem.eql(u8, "--http-max-concurrent", opt) or std.mem.eql(u8, "--http_max_concurrent", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
|
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
|
||||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
|
if (std.mem.eql(u8, "--http-max-host-open", opt) or std.mem.eql(u8, "--http_max_host_open", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
|
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 });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
|
if (std.mem.eql(u8, "--http-connect-timeout", opt) or std.mem.eql(u8, "--http_connect_timeout", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_timeout", opt)) {
|
if (std.mem.eql(u8, "--http-timeout", opt) or std.mem.eql(u8, "--http_timeout", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
|
if (std.mem.eql(u8, "--http-max-response-size", opt) or std.mem.eql(u8, "--http_max_response_size", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
|
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 });
|
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--log_level", opt)) {
|
if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -739,26 +894,26 @@ fn parseCommonArg(
|
|||||||
if (std.mem.eql(u8, str, "error")) {
|
if (std.mem.eql(u8, str, "error")) {
|
||||||
break :blk .err;
|
break :blk .err;
|
||||||
}
|
}
|
||||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
|
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--log_format", opt)) {
|
if (std.mem.eql(u8, "--log-format", opt) or std.mem.eql(u8, "--log_format", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
|
|
||||||
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
|
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
|
||||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
|
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
|
if (std.mem.eql(u8, "--log-filter-scopes", opt) or std.mem.eql(u8, "--log_filter_scopes", opt)) {
|
||||||
if (builtin.mode != .Debug) {
|
if (builtin.mode != .Debug) {
|
||||||
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
|
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
|
||||||
return false;
|
return false;
|
||||||
@@ -775,7 +930,7 @@ fn parseCommonArg(
|
|||||||
var it = std.mem.splitScalar(u8, str, ',');
|
var it = std.mem.splitScalar(u8, str, ',');
|
||||||
while (it.next()) |part| {
|
while (it.next()) |part| {
|
||||||
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
|
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
|
||||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
|
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = part });
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -783,14 +938,14 @@ fn parseCommonArg(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
|
if (std.mem.eql(u8, "--user-agent-suffix", opt) or std.mem.eql(u8, "--user_agent_suffix", opt)) {
|
||||||
const str = args.next() orelse {
|
const str = args.next() orelse {
|
||||||
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
};
|
};
|
||||||
for (str) |c| {
|
for (str) |c| {
|
||||||
if (!std.ascii.isPrint(c)) {
|
if (!std.ascii.isPrint(c)) {
|
||||||
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
|
log.fatal(.app, "not printable character", .{ .arg = opt });
|
||||||
return error.InvalidArgument;
|
return error.InvalidArgument;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,5 +953,32 @@ fn parseCommonArg(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--web-bot-auth-key-file", opt) or std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
common.web_bot_auth_key_file = try allocator.dupe(u8, str);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--web-bot-auth-keyid", opt) or std.mem.eql(u8, "--web_bot_auth_keyid", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
common.web_bot_auth_keyid = try allocator.dupe(u8, str);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, "--web-bot-auth-domain", opt) or std.mem.eql(u8, "--web_bot_auth_domain", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
common.web_bot_auth_domain = try allocator.dupe(u8, str);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
|
|||||||
|
|
||||||
const log = @import("log.zig");
|
const log = @import("log.zig");
|
||||||
const Page = @import("browser/Page.zig");
|
const Page = @import("browser/Page.zig");
|
||||||
const Transfer = @import("http/Client.zig").Transfer;
|
const Transfer = @import("browser/HttpClient.zig").Transfer;
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ const EventListeners = struct {
|
|||||||
page_navigated: List = .{},
|
page_navigated: List = .{},
|
||||||
page_network_idle: List = .{},
|
page_network_idle: List = .{},
|
||||||
page_network_almost_idle: List = .{},
|
page_network_almost_idle: List = .{},
|
||||||
|
page_frame_created: List = .{},
|
||||||
http_request_fail: List = .{},
|
http_request_fail: List = .{},
|
||||||
http_request_start: List = .{},
|
http_request_start: List = .{},
|
||||||
http_request_intercept: List = .{},
|
http_request_intercept: List = .{},
|
||||||
@@ -89,6 +90,7 @@ const Events = union(enum) {
|
|||||||
page_navigated: *const PageNavigated,
|
page_navigated: *const PageNavigated,
|
||||||
page_network_idle: *const PageNetworkIdle,
|
page_network_idle: *const PageNetworkIdle,
|
||||||
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
||||||
|
page_frame_created: *const PageFrameCreated,
|
||||||
http_request_fail: *const RequestFail,
|
http_request_fail: *const RequestFail,
|
||||||
http_request_start: *const RequestStart,
|
http_request_start: *const RequestStart,
|
||||||
http_request_intercept: *const RequestIntercept,
|
http_request_intercept: *const RequestIntercept,
|
||||||
@@ -102,24 +104,36 @@ const EventType = std.meta.FieldEnum(Events);
|
|||||||
pub const PageRemove = struct {};
|
pub const PageRemove = struct {};
|
||||||
|
|
||||||
pub const PageNavigate = struct {
|
pub const PageNavigate = struct {
|
||||||
req_id: usize,
|
req_id: u32,
|
||||||
|
frame_id: u32,
|
||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
url: [:0]const u8,
|
url: [:0]const u8,
|
||||||
opts: Page.NavigateOpts,
|
opts: Page.NavigateOpts,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const PageNavigated = struct {
|
pub const PageNavigated = struct {
|
||||||
req_id: usize,
|
req_id: u32,
|
||||||
|
frame_id: u32,
|
||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
url: [:0]const u8,
|
url: [:0]const u8,
|
||||||
opts: Page.NavigatedOpts,
|
opts: Page.NavigatedOpts,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const PageNetworkIdle = struct {
|
pub const PageNetworkIdle = struct {
|
||||||
|
req_id: u32,
|
||||||
|
frame_id: u32,
|
||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const PageNetworkAlmostIdle = struct {
|
pub const PageNetworkAlmostIdle = struct {
|
||||||
|
req_id: u32,
|
||||||
|
frame_id: u32,
|
||||||
|
timestamp: u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const PageFrameCreated = struct {
|
||||||
|
frame_id: u32,
|
||||||
|
parent_id: u32,
|
||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +319,7 @@ test "Notification" {
|
|||||||
|
|
||||||
// noop
|
// noop
|
||||||
notifier.dispatch(.page_navigate, &.{
|
notifier.dispatch(.page_navigate, &.{
|
||||||
|
.frame_id = 0,
|
||||||
.req_id = 1,
|
.req_id = 1,
|
||||||
.timestamp = 4,
|
.timestamp = 4,
|
||||||
.url = undefined,
|
.url = undefined,
|
||||||
@@ -315,6 +330,7 @@ test "Notification" {
|
|||||||
|
|
||||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||||
notifier.dispatch(.page_navigate, &.{
|
notifier.dispatch(.page_navigate, &.{
|
||||||
|
.frame_id = 0,
|
||||||
.req_id = 1,
|
.req_id = 1,
|
||||||
.timestamp = 4,
|
.timestamp = 4,
|
||||||
.url = undefined,
|
.url = undefined,
|
||||||
@@ -324,6 +340,7 @@ test "Notification" {
|
|||||||
|
|
||||||
notifier.unregisterAll(&tc);
|
notifier.unregisterAll(&tc);
|
||||||
notifier.dispatch(.page_navigate, &.{
|
notifier.dispatch(.page_navigate, &.{
|
||||||
|
.frame_id = 0,
|
||||||
.req_id = 1,
|
.req_id = 1,
|
||||||
.timestamp = 10,
|
.timestamp = 10,
|
||||||
.url = undefined,
|
.url = undefined,
|
||||||
@@ -334,23 +351,25 @@ test "Notification" {
|
|||||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||||
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
||||||
notifier.dispatch(.page_navigate, &.{
|
notifier.dispatch(.page_navigate, &.{
|
||||||
|
.frame_id = 0,
|
||||||
.req_id = 1,
|
.req_id = 1,
|
||||||
.timestamp = 10,
|
.timestamp = 10,
|
||||||
.url = undefined,
|
.url = undefined,
|
||||||
.opts = .{},
|
.opts = .{},
|
||||||
});
|
});
|
||||||
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 6, .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(14, tc.page_navigate);
|
||||||
try testing.expectEqual(6, tc.page_navigated);
|
try testing.expectEqual(6, tc.page_navigated);
|
||||||
|
|
||||||
notifier.unregisterAll(&tc);
|
notifier.unregisterAll(&tc);
|
||||||
notifier.dispatch(.page_navigate, &.{
|
notifier.dispatch(.page_navigate, &.{
|
||||||
|
.frame_id = 0,
|
||||||
.req_id = 1,
|
.req_id = 1,
|
||||||
.timestamp = 100,
|
.timestamp = 100,
|
||||||
.url = undefined,
|
.url = undefined,
|
||||||
.opts = .{},
|
.opts = .{},
|
||||||
});
|
});
|
||||||
notifier.dispatch(.page_navigated, &.{ .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(14, tc.page_navigate);
|
||||||
try testing.expectEqual(6, tc.page_navigated);
|
try testing.expectEqual(6, tc.page_navigated);
|
||||||
|
|
||||||
@@ -358,27 +377,27 @@ test "Notification" {
|
|||||||
// unregister
|
// unregister
|
||||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||||
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
||||||
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||||
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .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(114, tc.page_navigate);
|
||||||
try testing.expectEqual(1006, tc.page_navigated);
|
try testing.expectEqual(1006, tc.page_navigated);
|
||||||
|
|
||||||
notifier.unregister(.page_navigate, &tc);
|
notifier.unregister(.page_navigate, &tc);
|
||||||
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||||
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .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(114, tc.page_navigate);
|
||||||
try testing.expectEqual(2006, tc.page_navigated);
|
try testing.expectEqual(2006, tc.page_navigated);
|
||||||
|
|
||||||
notifier.unregister(.page_navigated, &tc);
|
notifier.unregister(.page_navigated, &tc);
|
||||||
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||||
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .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(114, tc.page_navigate);
|
||||||
try testing.expectEqual(2006, tc.page_navigated);
|
try testing.expectEqual(2006, tc.page_navigated);
|
||||||
|
|
||||||
// already unregistered, try anyways
|
// already unregistered, try anyways
|
||||||
notifier.unregister(.page_navigated, &tc);
|
notifier.unregister(.page_navigated, &tc);
|
||||||
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||||
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .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(114, tc.page_navigate);
|
||||||
try testing.expectEqual(2006, tc.page_navigated);
|
try testing.expectEqual(2006, tc.page_navigated);
|
||||||
}
|
}
|
||||||
|
|||||||
563
src/SemanticTree.zig
Normal file
563
src/SemanticTree.zig
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
// 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. See <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const lp = @import("lightpanda");
|
||||||
|
const log = @import("log.zig");
|
||||||
|
const isAllWhitespace = @import("string.zig").isAllWhitespace;
|
||||||
|
const Page = lp.Page;
|
||||||
|
const interactive = @import("browser/interactive.zig");
|
||||||
|
|
||||||
|
const CData = @import("browser/webapi/CData.zig");
|
||||||
|
const Element = @import("browser/webapi/Element.zig");
|
||||||
|
const Node = @import("browser/webapi/Node.zig");
|
||||||
|
const AXNode = @import("cdp/AXNode.zig");
|
||||||
|
const CDPNode = @import("cdp/Node.zig");
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
dom_node: *Node,
|
||||||
|
registry: *CDPNode.Registry,
|
||||||
|
page: *Page,
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
prune: bool = true,
|
||||||
|
interactive_only: bool = false,
|
||||||
|
max_depth: u32 = std.math.maxInt(u32) - 1,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
|
||||||
|
var visitor = JsonVisitor{ .jw = jw, .tree = self };
|
||||||
|
var xpath_buffer: std.ArrayList(u8) = .{};
|
||||||
|
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
|
||||||
|
log.err(.app, "listener map failed", .{ .err = err });
|
||||||
|
return error.WriteFailed;
|
||||||
|
};
|
||||||
|
var visibility_cache: Element.VisibilityCache = .empty;
|
||||||
|
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||||
|
var ctx: WalkContext = .{
|
||||||
|
.xpath_buffer = &xpath_buffer,
|
||||||
|
.listener_targets = listener_targets,
|
||||||
|
.visibility_cache = &visibility_cache,
|
||||||
|
.pointer_events_cache = &pointer_events_cache,
|
||||||
|
};
|
||||||
|
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||||
|
log.err(.app, "semantic tree json dump failed", .{ .err = err });
|
||||||
|
return error.WriteFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void {
|
||||||
|
var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 };
|
||||||
|
var xpath_buffer: std.ArrayList(u8) = .empty;
|
||||||
|
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
|
||||||
|
log.err(.app, "listener map failed", .{ .err = err });
|
||||||
|
return error.WriteFailed;
|
||||||
|
};
|
||||||
|
var visibility_cache: Element.VisibilityCache = .empty;
|
||||||
|
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||||
|
var ctx: WalkContext = .{
|
||||||
|
.xpath_buffer = &xpath_buffer,
|
||||||
|
.listener_targets = listener_targets,
|
||||||
|
.visibility_cache = &visibility_cache,
|
||||||
|
.pointer_events_cache = &pointer_events_cache,
|
||||||
|
};
|
||||||
|
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||||
|
log.err(.app, "semantic tree text dump failed", .{ .err = err });
|
||||||
|
return error.WriteFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OptionData = struct {
|
||||||
|
value: []const u8,
|
||||||
|
text: []const u8,
|
||||||
|
selected: bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeData = struct {
|
||||||
|
id: CDPNode.Id,
|
||||||
|
axn: AXNode,
|
||||||
|
role: []const u8,
|
||||||
|
name: ?[]const u8,
|
||||||
|
value: ?[]const u8,
|
||||||
|
options: ?[]OptionData = null,
|
||||||
|
xpath: []const u8,
|
||||||
|
is_interactive: bool,
|
||||||
|
node_name: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WalkContext = struct {
|
||||||
|
xpath_buffer: *std.ArrayList(u8),
|
||||||
|
listener_targets: interactive.ListenerTargetMap,
|
||||||
|
visibility_cache: *Element.VisibilityCache,
|
||||||
|
pointer_events_cache: *Element.PointerEventsCache,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn walk(
|
||||||
|
self: @This(),
|
||||||
|
ctx: *WalkContext,
|
||||||
|
node: *Node,
|
||||||
|
parent_name: ?[]const u8,
|
||||||
|
visitor: anytype,
|
||||||
|
index: usize,
|
||||||
|
current_depth: u32,
|
||||||
|
) !void {
|
||||||
|
if (current_depth > self.max_depth) return;
|
||||||
|
|
||||||
|
// 1. Skip non-content nodes
|
||||||
|
if (node.is(Element)) |el| {
|
||||||
|
const tag = el.getTag();
|
||||||
|
if (tag.isMetadata() or tag == .svg) return;
|
||||||
|
|
||||||
|
// We handle options/optgroups natively inside their parents, skip them in the general walk
|
||||||
|
if (tag == .datalist or tag == .option or tag == .optgroup) return;
|
||||||
|
|
||||||
|
// Check visibility using the engine's checkVisibility which handles CSS display: none
|
||||||
|
if (!el.checkVisibilityCached(ctx.visibility_cache, self.page)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.is(Element.Html)) |html_el| {
|
||||||
|
if (html_el.getHidden()) return;
|
||||||
|
}
|
||||||
|
} else if (node.is(CData.Text)) |text_node| {
|
||||||
|
const text = text_node.getWholeText();
|
||||||
|
if (isAllWhitespace(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (node._type != .document and node._type != .document_fragment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdp_node = try self.registry.register(node);
|
||||||
|
const axn = AXNode.fromNode(node);
|
||||||
|
const role = try axn.getRole();
|
||||||
|
|
||||||
|
var is_interactive = false;
|
||||||
|
var value: ?[]const u8 = null;
|
||||||
|
var options: ?[]OptionData = null;
|
||||||
|
var node_name: []const u8 = "text";
|
||||||
|
|
||||||
|
if (node.is(Element)) |el| {
|
||||||
|
node_name = el.getTagNameLower();
|
||||||
|
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
value = input.getValue();
|
||||||
|
if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| {
|
||||||
|
options = try extractDataListOptions(list_id, self.page, self.arena);
|
||||||
|
}
|
||||||
|
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
||||||
|
value = textarea.getValue();
|
||||||
|
} else if (el.is(Element.Html.Select)) |select| {
|
||||||
|
value = select.getValue(self.page);
|
||||||
|
options = try extractSelectOptions(el.asNode(), self.page, self.arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.is(Element.Html)) |html_el| {
|
||||||
|
if (interactive.classifyInteractivity(self.page, el, html_el, ctx.listener_targets, ctx.pointer_events_cache) != null) {
|
||||||
|
is_interactive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node._type == .document or node._type == .document_fragment) {
|
||||||
|
node_name = "root";
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial_xpath_len = ctx.xpath_buffer.items.len;
|
||||||
|
try appendXPathSegment(node, ctx.xpath_buffer.writer(self.arena), index);
|
||||||
|
const xpath = ctx.xpath_buffer.items;
|
||||||
|
|
||||||
|
var name = try axn.getName(self.page, self.arena);
|
||||||
|
|
||||||
|
const has_explicit_label = if (node.is(Element)) |el|
|
||||||
|
el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null
|
||||||
|
else
|
||||||
|
false;
|
||||||
|
|
||||||
|
const structural = isStructuralRole(role);
|
||||||
|
|
||||||
|
// Filter out computed concatenated names for generic containers without explicit labels.
|
||||||
|
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
|
||||||
|
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
|
||||||
|
if (name != null and structural and !has_explicit_label) {
|
||||||
|
name = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var should_visit = true;
|
||||||
|
if (self.interactive_only) {
|
||||||
|
var keep = false;
|
||||||
|
if (interactive.isInteractiveRole(role)) {
|
||||||
|
keep = true;
|
||||||
|
} else if (interactive.isContentRole(role)) {
|
||||||
|
if (name != null and name.?.len > 0) {
|
||||||
|
keep = true;
|
||||||
|
}
|
||||||
|
} else if (std.mem.eql(u8, role, "RootWebArea")) {
|
||||||
|
keep = true;
|
||||||
|
} else if (is_interactive) {
|
||||||
|
keep = true;
|
||||||
|
}
|
||||||
|
if (!keep) {
|
||||||
|
should_visit = false;
|
||||||
|
}
|
||||||
|
} else if (self.prune) {
|
||||||
|
if (structural and !is_interactive and !has_explicit_label) {
|
||||||
|
should_visit = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, role, "StaticText") and node._parent != null) {
|
||||||
|
if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) {
|
||||||
|
should_visit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var did_visit = false;
|
||||||
|
var should_walk_children = true;
|
||||||
|
var data: NodeData = .{
|
||||||
|
.id = cdp_node.id,
|
||||||
|
.axn = axn,
|
||||||
|
.role = role,
|
||||||
|
.name = name,
|
||||||
|
.value = value,
|
||||||
|
.options = options,
|
||||||
|
.xpath = xpath,
|
||||||
|
.is_interactive = is_interactive,
|
||||||
|
.node_name = node_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (should_visit) {
|
||||||
|
should_walk_children = try visitor.visit(node, &data);
|
||||||
|
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
|
||||||
|
} else {
|
||||||
|
// If we skip the node, we must NOT tell the visitor to close it later
|
||||||
|
did_visit = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (should_walk_children) {
|
||||||
|
// If we are printing this node normally OR skipping it and unrolling its children,
|
||||||
|
// we walk the children iterator.
|
||||||
|
var it = node.childrenIterator();
|
||||||
|
var tag_counts = std.StringArrayHashMap(usize).init(self.arena);
|
||||||
|
while (it.next()) |child| {
|
||||||
|
var tag: []const u8 = "text()";
|
||||||
|
if (child.is(Element)) |el| {
|
||||||
|
tag = el.getTagNameLower();
|
||||||
|
}
|
||||||
|
|
||||||
|
const gop = try tag_counts.getOrPut(tag);
|
||||||
|
if (!gop.found_existing) {
|
||||||
|
gop.value_ptr.* = 0;
|
||||||
|
}
|
||||||
|
gop.value_ptr.* += 1;
|
||||||
|
|
||||||
|
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (did_visit) {
|
||||||
|
try visitor.leave();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
|
||||||
|
var options: std.ArrayList(OptionData) = .empty;
|
||||||
|
var it = node.childrenIterator();
|
||||||
|
while (it.next()) |child| {
|
||||||
|
if (child.is(Element)) |el| {
|
||||||
|
if (el.getTag() == .option) {
|
||||||
|
if (el.is(Element.Html.Option)) |opt| {
|
||||||
|
const text = opt.getText(page);
|
||||||
|
const value = opt.getValue(page);
|
||||||
|
const selected = opt.getSelected();
|
||||||
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||||
|
}
|
||||||
|
} else if (el.getTag() == .optgroup) {
|
||||||
|
var group_it = child.childrenIterator();
|
||||||
|
while (group_it.next()) |group_child| {
|
||||||
|
if (group_child.is(Element.Html.Option)) |opt| {
|
||||||
|
const text = opt.getText(page);
|
||||||
|
const value = opt.getValue(page);
|
||||||
|
const selected = opt.getSelected();
|
||||||
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData {
|
||||||
|
if (page.document.getElementById(list_id, page)) |referenced_el| {
|
||||||
|
if (referenced_el.getTag() == .datalist) {
|
||||||
|
return try extractSelectOptions(referenced_el.asNode(), page, arena);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void {
|
||||||
|
if (node.is(Element)) |el| {
|
||||||
|
const tag = el.getTagNameLower();
|
||||||
|
try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index });
|
||||||
|
} else if (node.is(CData.Text)) |_| {
|
||||||
|
try std.fmt.format(writer, "/text()[{d}]", .{index});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonVisitor = struct {
|
||||||
|
jw: *std.json.Stringify,
|
||||||
|
tree: Self,
|
||||||
|
|
||||||
|
pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool {
|
||||||
|
try self.jw.beginObject();
|
||||||
|
|
||||||
|
try self.jw.objectField("nodeId");
|
||||||
|
try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id}));
|
||||||
|
|
||||||
|
try self.jw.objectField("backendDOMNodeId");
|
||||||
|
try self.jw.write(data.id);
|
||||||
|
|
||||||
|
try self.jw.objectField("nodeName");
|
||||||
|
try self.jw.write(data.node_name);
|
||||||
|
|
||||||
|
try self.jw.objectField("xpath");
|
||||||
|
try self.jw.write(data.xpath);
|
||||||
|
|
||||||
|
if (node.is(Element)) |el| {
|
||||||
|
try self.jw.objectField("nodeType");
|
||||||
|
try self.jw.write(1);
|
||||||
|
|
||||||
|
try self.jw.objectField("isInteractive");
|
||||||
|
try self.jw.write(data.is_interactive);
|
||||||
|
|
||||||
|
try self.jw.objectField("role");
|
||||||
|
try self.jw.write(data.role);
|
||||||
|
|
||||||
|
if (data.name) |name| {
|
||||||
|
if (name.len > 0) {
|
||||||
|
try self.jw.objectField("name");
|
||||||
|
try self.jw.write(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.value) |value| {
|
||||||
|
try self.jw.objectField("value");
|
||||||
|
try self.jw.write(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el._attributes) |attrs| {
|
||||||
|
try self.jw.objectField("attributes");
|
||||||
|
try self.jw.beginObject();
|
||||||
|
var iter = attrs.iterator();
|
||||||
|
while (iter.next()) |attr| {
|
||||||
|
try self.jw.objectField(attr._name.str());
|
||||||
|
try self.jw.write(attr._value.str());
|
||||||
|
}
|
||||||
|
try self.jw.endObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.options) |options| {
|
||||||
|
try self.jw.objectField("options");
|
||||||
|
try self.jw.beginArray();
|
||||||
|
for (options) |opt| {
|
||||||
|
try self.jw.beginObject();
|
||||||
|
try self.jw.objectField("value");
|
||||||
|
try self.jw.write(opt.value);
|
||||||
|
try self.jw.objectField("text");
|
||||||
|
try self.jw.write(opt.text);
|
||||||
|
try self.jw.objectField("selected");
|
||||||
|
try self.jw.write(opt.selected);
|
||||||
|
try self.jw.endObject();
|
||||||
|
}
|
||||||
|
try self.jw.endArray();
|
||||||
|
}
|
||||||
|
} else if (node.is(CData.Text)) |text_node| {
|
||||||
|
try self.jw.objectField("nodeType");
|
||||||
|
try self.jw.write(3);
|
||||||
|
try self.jw.objectField("nodeValue");
|
||||||
|
try self.jw.write(text_node.getWholeText());
|
||||||
|
} else {
|
||||||
|
try self.jw.objectField("nodeType");
|
||||||
|
try self.jw.write(9);
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.jw.objectField("children");
|
||||||
|
try self.jw.beginArray();
|
||||||
|
|
||||||
|
if (data.options != null) {
|
||||||
|
// Signal to not walk children, as we handled them natively
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(self: *JsonVisitor) !void {
|
||||||
|
try self.jw.endArray();
|
||||||
|
try self.jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn isStructuralRole(role: []const u8) bool {
|
||||||
|
const structural_roles = std.StaticStringMap(void).initComptime(.{
|
||||||
|
.{ "none", {} },
|
||||||
|
.{ "generic", {} },
|
||||||
|
.{ "InlineTextBox", {} },
|
||||||
|
.{ "banner", {} },
|
||||||
|
.{ "navigation", {} },
|
||||||
|
.{ "main", {} },
|
||||||
|
.{ "list", {} },
|
||||||
|
.{ "listitem", {} },
|
||||||
|
.{ "table", {} },
|
||||||
|
.{ "rowgroup", {} },
|
||||||
|
.{ "row", {} },
|
||||||
|
.{ "cell", {} },
|
||||||
|
.{ "region", {} },
|
||||||
|
});
|
||||||
|
return structural_roles.has(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextVisitor = struct {
|
||||||
|
writer: *std.Io.Writer,
|
||||||
|
tree: Self,
|
||||||
|
depth: usize,
|
||||||
|
|
||||||
|
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
|
||||||
|
for (0..self.depth) |_| {
|
||||||
|
try self.writer.writeByte(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
var name_to_print: ?[]const u8 = null;
|
||||||
|
if (data.name) |n| {
|
||||||
|
if (n.len > 0) {
|
||||||
|
name_to_print = n;
|
||||||
|
}
|
||||||
|
} else if (node.is(CData.Text)) |text_node| {
|
||||||
|
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
|
||||||
|
if (trimmed.len > 0) {
|
||||||
|
name_to_print = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
|
||||||
|
|
||||||
|
try self.writer.print("{d}", .{data.id});
|
||||||
|
if (!is_text_only) {
|
||||||
|
try self.writer.print(" {s}", .{data.role});
|
||||||
|
}
|
||||||
|
if (name_to_print) |n| {
|
||||||
|
try self.writer.print(" '{s}'", .{n});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.value) |v| {
|
||||||
|
if (v.len > 0) {
|
||||||
|
try self.writer.print(" value='{s}'", .{v});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.options) |options| {
|
||||||
|
try self.writer.writeAll(" options=[");
|
||||||
|
for (options, 0..) |opt, i| {
|
||||||
|
if (i > 0) try self.writer.writeAll(",");
|
||||||
|
try self.writer.print("'{s}'", .{opt.value});
|
||||||
|
if (opt.selected) {
|
||||||
|
try self.writer.writeAll("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try self.writer.writeAll("]\n");
|
||||||
|
self.depth += 1;
|
||||||
|
return false; // Native handling complete, do not walk children
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.writer.writeByte('\n');
|
||||||
|
self.depth += 1;
|
||||||
|
|
||||||
|
// If this is a leaf-like semantic node and we already have a name,
|
||||||
|
// skip children to avoid redundant StaticText or noise.
|
||||||
|
const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or
|
||||||
|
std.mem.eql(u8, data.role, "button") or
|
||||||
|
std.mem.eql(u8, data.role, "heading") or
|
||||||
|
std.mem.eql(u8, data.role, "code");
|
||||||
|
if (is_leaf_semantic and data.name != null and data.name.?.len > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(self: *TextVisitor) !void {
|
||||||
|
if (self.depth > 0) {
|
||||||
|
self.depth -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testing = @import("testing.zig");
|
||||||
|
|
||||||
|
test "SemanticTree backendDOMNodeId" {
|
||||||
|
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||||
|
defer registry.deinit();
|
||||||
|
|
||||||
|
var page = try testing.pageTest("cdp/registry1.html");
|
||||||
|
defer testing.reset();
|
||||||
|
defer page._session.removePage();
|
||||||
|
|
||||||
|
const st: Self = .{
|
||||||
|
.dom_node = page.window._document.asNode(),
|
||||||
|
.registry = ®istry,
|
||||||
|
.page = page,
|
||||||
|
.arena = testing.arena_allocator,
|
||||||
|
.prune = false,
|
||||||
|
.interactive_only = false,
|
||||||
|
.max_depth = std.math.maxInt(u32) - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
||||||
|
defer testing.allocator.free(json_str);
|
||||||
|
|
||||||
|
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "SemanticTree max_depth" {
|
||||||
|
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||||
|
defer registry.deinit();
|
||||||
|
|
||||||
|
var page = try testing.pageTest("cdp/registry1.html");
|
||||||
|
defer testing.reset();
|
||||||
|
defer page._session.removePage();
|
||||||
|
|
||||||
|
const st: Self = .{
|
||||||
|
.dom_node = page.window._document.asNode(),
|
||||||
|
.registry = ®istry,
|
||||||
|
.page = page,
|
||||||
|
.arena = testing.arena_allocator,
|
||||||
|
.prune = false,
|
||||||
|
.interactive_only = false,
|
||||||
|
.max_depth = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||||
|
defer aw.deinit();
|
||||||
|
|
||||||
|
try st.textStringify(&aw.writer);
|
||||||
|
const text_str = aw.written();
|
||||||
|
|
||||||
|
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
||||||
|
}
|
||||||
911
src/Server.zig
911
src/Server.zig
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const URL = @import("browser/URL.zig");
|
||||||
|
|
||||||
const TestHTTPServer = @This();
|
const TestHTTPServer = @This();
|
||||||
|
|
||||||
@@ -97,7 +98,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
|
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) {
|
var url_buf: [1024]u8 = undefined;
|
||||||
|
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
|
||||||
|
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
|
||||||
|
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
|
||||||
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
|
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
|
||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
|
|||||||
const js = @import("js/js.zig");
|
const js = @import("js/js.zig");
|
||||||
const log = @import("../log.zig");
|
const log = @import("../log.zig");
|
||||||
const App = @import("../App.zig");
|
const App = @import("../App.zig");
|
||||||
const HttpClient = @import("../http/Client.zig");
|
const HttpClient = @import("HttpClient.zig");
|
||||||
|
|
||||||
const ArenaPool = App.ArenaPool;
|
const ArenaPool = App.ArenaPool;
|
||||||
|
|
||||||
@@ -87,19 +87,36 @@ pub fn closeSession(self: *Browser) void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runMicrotasks(self: *const Browser) void {
|
pub fn runMicrotasks(self: *Browser) void {
|
||||||
self.env.runMicrotasks();
|
self.env.runMicrotasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runMacrotasks(self: *Browser) !?u64 {
|
pub fn runMacrotasks(self: *Browser) !void {
|
||||||
return try self.env.runMacrotasks();
|
const env = &self.env;
|
||||||
|
|
||||||
|
try self.env.runMacrotasks();
|
||||||
|
env.pumpMessageLoop();
|
||||||
|
|
||||||
|
// either of the above could have queued more microtasks
|
||||||
|
env.runMicrotasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runMessageLoop(self: *const Browser) void {
|
pub fn hasBackgroundTasks(self: *Browser) bool {
|
||||||
while (self.env.pumpMessageLoop()) {
|
return self.env.hasBackgroundTasks();
|
||||||
if (comptime IS_DEBUG) {
|
|
||||||
log.debug(.browser, "pumpMessageLoop", .{});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn waitForBackgroundTasks(self: *Browser) void {
|
||||||
|
self.env.waitForBackgroundTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn msToNextMacrotask(self: *Browser) ?u64 {
|
||||||
|
return self.env.msToNextMacrotask();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn msTo(self: *Browser) bool {
|
||||||
|
return self.env.hasBackgroundTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runIdleTasks(self: *const Browser) void {
|
||||||
self.env.runIdleTasks();
|
self.env.runIdleTasks();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ pub const EventManager = @This();
|
|||||||
|
|
||||||
page: *Page,
|
page: *Page,
|
||||||
arena: Allocator,
|
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),
|
listener_pool: std.heap.MemoryPool(Listener),
|
||||||
|
ignore_list: std.ArrayList(*Listener),
|
||||||
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
|
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
|
||||||
lookup: std.HashMapUnmanaged(
|
lookup: std.HashMapUnmanaged(
|
||||||
EventKey,
|
EventKey,
|
||||||
@@ -72,10 +77,12 @@ pub fn init(arena: Allocator, page: *Page) EventManager {
|
|||||||
.page = page,
|
.page = page,
|
||||||
.lookup = .{},
|
.lookup = .{},
|
||||||
.arena = arena,
|
.arena = arena,
|
||||||
|
.ignore_list = .{},
|
||||||
.list_pool = .init(arena),
|
.list_pool = .init(arena),
|
||||||
.listener_pool = .init(arena),
|
.listener_pool = .init(arena),
|
||||||
.dispatch_depth = 0,
|
.dispatch_depth = 0,
|
||||||
.deferred_removals = .{},
|
.deferred_removals = .{},
|
||||||
|
.has_dom_load_listener = false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +113,10 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
|
|||||||
// Allocate the type string we'll use in both listener and key
|
// Allocate the type string we'll use in both listener and key
|
||||||
const type_string = try String.init(self.arena, typ, .{});
|
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, .{
|
const gop = try self.lookup.getOrPut(self.arena, .{
|
||||||
.type_string = type_string,
|
.type_string = type_string,
|
||||||
.event_target = @intFromPtr(target),
|
.event_target = @intFromPtr(target),
|
||||||
@@ -146,6 +157,11 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
|
|||||||
};
|
};
|
||||||
// append the listener to the list of listeners for this target
|
// append the listener to the list of listeners for this target
|
||||||
gop.value_ptr.*.append(&listener.node);
|
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 {
|
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
|
||||||
@@ -158,6 +174,10 @@ pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// 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
|
// give it an explicit error set so that other parts of the code can use and
|
||||||
// inferred error.
|
// inferred error.
|
||||||
@@ -169,42 +189,31 @@ const DispatchError = error{
|
|||||||
ExecutionError,
|
ExecutionError,
|
||||||
JsException,
|
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 {
|
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._session);
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
|
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
|
||||||
}
|
}
|
||||||
|
|
||||||
event._target = target;
|
|
||||||
event._dispatch_target = target; // Store original target for composedPath()
|
|
||||||
var was_handled = false;
|
|
||||||
|
|
||||||
defer if (was_handled) {
|
|
||||||
var ls: js.Local.Scope = undefined;
|
|
||||||
self.page.js.localScope(&ls);
|
|
||||||
defer ls.deinit();
|
|
||||||
ls.local.runMicrotasks();
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (target._type) {
|
switch (target._type) {
|
||||||
.node => |node| try self.dispatchNode(node, event, &was_handled),
|
.node => |node| try self.dispatchNode(node, event, opts),
|
||||||
.xhr,
|
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
|
||||||
.window,
|
|
||||||
.abort_signal,
|
|
||||||
.media_query_list,
|
|
||||||
.message_port,
|
|
||||||
.text_track_cue,
|
|
||||||
.navigation,
|
|
||||||
.screen,
|
|
||||||
.screen_orientation,
|
|
||||||
.visual_viewport,
|
|
||||||
.generic,
|
|
||||||
=> {
|
|
||||||
const list = self.lookup.get(.{
|
|
||||||
.event_target = @intFromPtr(target),
|
|
||||||
.type_string = event._type_string,
|
|
||||||
}) orelse return;
|
|
||||||
try self.dispatchAll(list, target, event, &was_handled);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,13 +222,28 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
|
|||||||
// property is just a shortcut for calling addEventListener, but they are distinct.
|
// property is just a shortcut for calling addEventListener, but they are distinct.
|
||||||
// An event set via property cannot be removed by removeEventListener. If you
|
// An event set via property cannot be removed by removeEventListener. If you
|
||||||
// set both the property and add a listener, they both execute.
|
// set both the property and add a listener, they both execute.
|
||||||
const DispatchWithFunctionOptions = struct {
|
const DispatchDirectOptions = struct {
|
||||||
context: []const u8,
|
context: []const u8,
|
||||||
inject_target: bool = true,
|
inject_target: bool = true,
|
||||||
};
|
};
|
||||||
pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void {
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||||
|
const window = page.window;
|
||||||
|
const prev_event = window._current_event;
|
||||||
|
window._current_event = event;
|
||||||
|
defer window._current_event = prev_event;
|
||||||
|
|
||||||
|
event.acquireRef();
|
||||||
|
defer event.deinit(false, page._session);
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null });
|
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comptime opts.inject_target) {
|
if (comptime opts.inject_target) {
|
||||||
@@ -228,14 +252,15 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
|
|||||||
}
|
}
|
||||||
|
|
||||||
var was_dispatched = false;
|
var was_dispatched = false;
|
||||||
defer if (was_dispatched) {
|
|
||||||
var ls: js.Local.Scope = undefined;
|
|
||||||
self.page.js.localScope(&ls);
|
|
||||||
defer ls.deinit();
|
|
||||||
ls.local.runMicrotasks();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (function_) |func| {
|
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;
|
event._current_target = target;
|
||||||
if (func.callWithThis(void, target, .{event})) {
|
if (func.callWithThis(void, target, .{event})) {
|
||||||
was_dispatched = true;
|
was_dispatched = true;
|
||||||
@@ -245,23 +270,169 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listeners reigstered via addEventListener
|
||||||
const list = self.lookup.get(.{
|
const list = self.lookup.get(.{
|
||||||
.event_target = @intFromPtr(target),
|
.event_target = @intFromPtr(target),
|
||||||
.type_string = event._type_string,
|
.type_string = event._type_string,
|
||||||
}) orelse return;
|
}) orelse return;
|
||||||
try self.dispatchAll(list, target, event, &was_dispatched);
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
|
// 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))?"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there are any listeners for a direct dispatch (non-DOM target).
|
||||||
|
/// Use this to avoid creating an event when there are no listeners.
|
||||||
|
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
|
||||||
|
if (hasHandler(handler)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return self.lookup.get(.{
|
||||||
|
.event_target = @intFromPtr(target),
|
||||||
|
.type_string = .wrap(typ),
|
||||||
|
}) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hasHandler(handler: anytype) bool {
|
||||||
|
const ti = @typeInfo(@TypeOf(handler));
|
||||||
|
if (ti == .null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ti == .optional) {
|
||||||
|
return handler != null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
|
||||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
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;
|
const page = self.page;
|
||||||
|
|
||||||
|
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||||
|
const window = page.window;
|
||||||
|
const prev_event = window._current_event;
|
||||||
|
window._current_event = event;
|
||||||
|
defer window._current_event = prev_event;
|
||||||
|
|
||||||
|
var was_handled = false;
|
||||||
|
|
||||||
|
// Create a single scope for all event handlers in this dispatch.
|
||||||
|
// This ensures function handles passed to queueMicrotask remain valid
|
||||||
|
// throughout the entire dispatch, preventing crashes when microtasks run.
|
||||||
|
var ls: js.Local.Scope = undefined;
|
||||||
|
page.js.localScope(&ls);
|
||||||
|
defer {
|
||||||
|
if (was_handled) {
|
||||||
|
ls.local.runMicrotasks();
|
||||||
|
}
|
||||||
|
ls.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
const activation_state = ActivationState.create(event, target, page);
|
const activation_state = ActivationState.create(event, target, page);
|
||||||
|
|
||||||
// Defer runs even on early return - ensures event phase is reset
|
// Defer runs even on early return - ensures event phase is reset
|
||||||
// and default actions execute (unless prevented)
|
// and default actions execute (unless prevented)
|
||||||
defer {
|
defer {
|
||||||
event._event_phase = .none;
|
event._event_phase = .none;
|
||||||
|
event._stop_propagation = false;
|
||||||
|
event._stop_immediate_propagation = false;
|
||||||
// Handle checkbox/radio activation rollback or commit
|
// Handle checkbox/radio activation rollback or commit
|
||||||
if (activation_state) |state| {
|
if (activation_state) |state| {
|
||||||
state.restore(event, page);
|
state.restore(event, page);
|
||||||
@@ -307,12 +478,15 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
|
|||||||
node = n._parent;
|
node = n._parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Even though the window isn't part of the DOM, events always propagate
|
// 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)
|
// 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) {
|
if (path_len < path_buffer.len) {
|
||||||
path_buffer[path_len] = page.window.asEventTarget();
|
path_buffer[path_len] = page.window.asEventTarget();
|
||||||
path_len += 1;
|
path_len += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const path = path_buffer[0..path_len];
|
const path = path_buffer[0..path_len];
|
||||||
|
|
||||||
@@ -322,32 +496,27 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
|
|||||||
var i: usize = path_len;
|
var i: usize = path_len;
|
||||||
while (i > 1) {
|
while (i > 1) {
|
||||||
i -= 1;
|
i -= 1;
|
||||||
|
if (event._stop_propagation) return;
|
||||||
const current_target = path[i];
|
const current_target = path[i];
|
||||||
if (self.lookup.get(.{
|
if (self.lookup.get(.{
|
||||||
.event_target = @intFromPtr(current_target),
|
.event_target = @intFromPtr(current_target),
|
||||||
.type_string = event._type_string,
|
.type_string = event._type_string,
|
||||||
})) |list| {
|
})) |list| {
|
||||||
try self.dispatchPhase(list, current_target, event, was_handled, true);
|
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts));
|
||||||
if (event._stop_propagation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: At target
|
// Phase 2: At target
|
||||||
|
if (event._stop_propagation) return;
|
||||||
event._event_phase = .at_target;
|
event._event_phase = .at_target;
|
||||||
const target_et = target.asEventTarget();
|
const target_et = target.asEventTarget();
|
||||||
|
|
||||||
blk: {
|
blk: {
|
||||||
// Get inline handler (e.g., onclick property) for this target
|
// Get inline handler (e.g., onclick property) for this target
|
||||||
if (self.getInlineHandler(target_et, event)) |inline_handler| {
|
if (self.getInlineHandler(target_et, event)) |inline_handler| {
|
||||||
was_handled.* = true;
|
was_handled = true;
|
||||||
event._current_target = target_et;
|
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});
|
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
|
||||||
|
|
||||||
if (event._stop_propagation) {
|
if (event._stop_propagation) {
|
||||||
@@ -363,7 +532,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
|
|||||||
.type_string = event._type_string,
|
.type_string = event._type_string,
|
||||||
.event_target = @intFromPtr(target_et),
|
.event_target = @intFromPtr(target_et),
|
||||||
})) |list| {
|
})) |list| {
|
||||||
try self.dispatchPhase(list, target_et, event, was_handled, null);
|
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
|
||||||
if (event._stop_propagation) {
|
if (event._stop_propagation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -375,20 +544,30 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
|
|||||||
if (event._bubbles) {
|
if (event._bubbles) {
|
||||||
event._event_phase = .bubbling_phase;
|
event._event_phase = .bubbling_phase;
|
||||||
for (path[1..]) |current_target| {
|
for (path[1..]) |current_target| {
|
||||||
|
if (event._stop_propagation) break;
|
||||||
if (self.lookup.get(.{
|
if (self.lookup.get(.{
|
||||||
.type_string = event._type_string,
|
.type_string = event._type_string,
|
||||||
.event_target = @intFromPtr(current_target),
|
.event_target = @intFromPtr(current_target),
|
||||||
})) |list| {
|
})) |list| {
|
||||||
try self.dispatchPhase(list, current_target, event, was_handled, false);
|
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
|
||||||
if (event._stop_propagation) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
|
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, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void {
|
||||||
const page = self.page;
|
const page = self.page;
|
||||||
|
|
||||||
// Track dispatch depth for deferred removal
|
// Track dispatch depth for deferred removal
|
||||||
@@ -414,7 +593,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
|||||||
// Iterate through the list, stopping after we've encountered the last_listener
|
// Iterate through the list, stopping after we've encountered the last_listener
|
||||||
var node = list.first;
|
var node = list.first;
|
||||||
var is_done = false;
|
var is_done = false;
|
||||||
while (node) |n| {
|
node_loop: while (node) |n| {
|
||||||
if (is_done) {
|
if (is_done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -424,7 +603,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
|||||||
node = n.next;
|
node = n.next;
|
||||||
|
|
||||||
// Skip non-matching listeners
|
// Skip non-matching listeners
|
||||||
if (comptime capture_only) |capture| {
|
if (comptime opts.capture_only) |capture| {
|
||||||
if (listener.capture != capture) {
|
if (listener.capture != capture) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -443,6 +622,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
|
||||||
if (listener.once) {
|
if (listener.once) {
|
||||||
self.removeListener(list, listener);
|
self.removeListener(list, listener);
|
||||||
@@ -457,18 +644,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
|||||||
event._target = getAdjustedTarget(original_target, current_target);
|
event._target = getAdjustedTarget(original_target, current_target);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ls: js.Local.Scope = undefined;
|
|
||||||
page.js.localScope(&ls);
|
|
||||||
defer ls.deinit();
|
|
||||||
|
|
||||||
switch (listener.function) {
|
switch (listener.function) {
|
||||||
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
|
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
|
||||||
.string => |string| {
|
.string => |string| {
|
||||||
const str = try page.call_arena.dupeZ(u8, string.str());
|
const str = try page.call_arena.dupeZ(u8, string.str());
|
||||||
try ls.local.eval(str, null);
|
try local.eval(str, null);
|
||||||
},
|
},
|
||||||
.object => |obj_global| {
|
.object => |obj_global| {
|
||||||
const obj = ls.toLocal(obj_global);
|
const obj = local.toLocal(obj_global);
|
||||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||||
try handleEvent.callWithThis(void, obj, .{event});
|
try handleEvent.callWithThis(void, obj, .{event});
|
||||||
}
|
}
|
||||||
@@ -486,22 +669,20 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-Node dispatching (XHR, Window without propagation)
|
|
||||||
fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void {
|
|
||||||
return self.dispatchPhase(list, current_target, event, was_handled, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
|
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
|
||||||
const global_event_handlers = @import("webapi/global_event_handlers.zig");
|
const global_event_handlers = @import("webapi/global_event_handlers.zig");
|
||||||
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
|
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
|
||||||
|
|
||||||
// Look up the inline handler for this target
|
// Look up the inline handler for this target
|
||||||
const element = switch (target._type) {
|
const html_element = switch (target._type) {
|
||||||
.node => |n| n.is(Element) orelse return null,
|
.node => |n| n.is(Element.Html) orelse return null,
|
||||||
else => return null,
|
else => return null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return self.page.getAttrListener(element, handler_type);
|
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 {
|
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
|
||||||
@@ -682,9 +863,10 @@ const ActivationState = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit: fire input and change events only if state actually changed
|
// 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.
|
// For checkboxes, state always changes. For radios, only if was unchecked.
|
||||||
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
|
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
|
||||||
if (state_changed) {
|
if (state_changed and input.asElement().asNode().isConnected()) {
|
||||||
fireEvent(page, input, "input") catch |err| {
|
fireEvent(page, input, "input") catch |err| {
|
||||||
log.warn(.event, "input event", .{ .err = err });
|
log.warn(.event, "input event", .{ .err = err });
|
||||||
};
|
};
|
||||||
@@ -754,7 +936,6 @@ const ActivationState = struct {
|
|||||||
.bubbles = true,
|
.bubbles = true,
|
||||||
.cancelable = false,
|
.cancelable = false,
|
||||||
}, page);
|
}, page);
|
||||||
defer if (!event._v8_handoff) event.deinit(false);
|
|
||||||
|
|
||||||
const target = input.asElement().asEventTarget();
|
const target = input.asElement().asEventTarget();
|
||||||
try page._event_manager.dispatch(target, event);
|
try page._event_manager.dispatch(target, event);
|
||||||
|
|||||||
@@ -42,12 +42,94 @@ const Allocator = std.mem.Allocator;
|
|||||||
const IS_DEBUG = builtin.mode == .Debug;
|
const IS_DEBUG = builtin.mode == .Debug;
|
||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
|
|
||||||
|
// Shared across all frames of a Page.
|
||||||
const Factory = @This();
|
const Factory = @This();
|
||||||
|
|
||||||
_page: *Page,
|
|
||||||
_arena: Allocator,
|
_arena: Allocator,
|
||||||
_slab: SlabAllocator,
|
_slab: SlabAllocator,
|
||||||
|
|
||||||
|
pub fn init(arena: Allocator) Factory {
|
||||||
|
return .{
|
||||||
|
._arena = arena,
|
||||||
|
._slab = SlabAllocator.init(arena, 128),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
fn PrototypeChain(comptime types: []const type) type {
|
||||||
return struct {
|
return struct {
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
@@ -151,110 +233,29 @@ fn AutoPrototypeChain(comptime types: []const type) type {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(arena: Allocator, page: *Page) Factory {
|
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
|
||||||
return .{
|
|
||||||
._page = page,
|
|
||||||
._arena = arena,
|
|
||||||
._slab = SlabAllocator.init(arena, 128),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is a root object
|
|
||||||
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
|
|
||||||
const allocator = self._slab.allocator();
|
|
||||||
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(self: *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 self.eventInit(arena, typ, chain.get(1));
|
|
||||||
chain.setLeaf(1, child);
|
|
||||||
|
|
||||||
return chain.get(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uiEvent(self: *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 self.eventInit(arena, typ, chain.get(1));
|
|
||||||
chain.setMiddle(1, UIEvent.Type);
|
|
||||||
chain.setLeaf(2, child);
|
|
||||||
|
|
||||||
return chain.get(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mouseEvent(self: *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 self.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 eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
|
|
||||||
// Round to 2ms for privacy (browsers do this)
|
// Round to 2ms for privacy (browsers do this)
|
||||||
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
|
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
|
||||||
const time_stamp = (raw_timestamp / 2) * 2;
|
const time_stamp = (raw_timestamp / 2) * 2;
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
|
._rc = 0,
|
||||||
._arena = arena,
|
._arena = arena,
|
||||||
._page = self._page,
|
|
||||||
._type = unionInit(Event.Type, value),
|
._type = unionInit(Event.Type, value),
|
||||||
._type_string = typ,
|
._type_string = typ,
|
||||||
._time_stamp = time_stamp,
|
._time_stamp = time_stamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
|
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
|
||||||
const allocator = self._slab.allocator();
|
|
||||||
|
|
||||||
// Special case: Blob has slice and mime fields, so we need manual setup
|
// Special case: Blob has slice and mime fields, so we need manual setup
|
||||||
const chain = try PrototypeChain(
|
const chain = try PrototypeChain(
|
||||||
&.{ Blob, @TypeOf(child) },
|
&.{ Blob, @TypeOf(child) },
|
||||||
).allocate(allocator);
|
).allocate(arena);
|
||||||
|
|
||||||
const blob_ptr = chain.get(0);
|
const blob_ptr = chain.get(0);
|
||||||
blob_ptr.* = .{
|
blob_ptr.* = .{
|
||||||
|
._arena = arena,
|
||||||
._type = unionInit(Blob.Type, chain.get(1)),
|
._type = unionInit(Blob.Type, chain.get(1)),
|
||||||
._slice = "",
|
._slice = "",
|
||||||
._mime = "",
|
._mime = "",
|
||||||
@@ -264,19 +265,23 @@ pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
|
|||||||
return chain.get(1);
|
return chain.get(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(child) {
|
pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, page: *Page) !*@TypeOf(child) {
|
||||||
const allocator = self._slab.allocator();
|
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(arena);
|
||||||
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
|
|
||||||
|
|
||||||
const doc = page.document.asNode();
|
const doc = page.document.asNode();
|
||||||
chain.set(0, AbstractRange{
|
const abstract_range = chain.get(0);
|
||||||
|
abstract_range.* = AbstractRange{
|
||||||
|
._rc = 0,
|
||||||
|
._arena = arena,
|
||||||
|
._page_id = page.id,
|
||||||
._type = unionInit(AbstractRange.Type, chain.get(1)),
|
._type = unionInit(AbstractRange.Type, chain.get(1)),
|
||||||
._end_offset = 0,
|
._end_offset = 0,
|
||||||
._start_offset = 0,
|
._start_offset = 0,
|
||||||
._end_container = doc,
|
._end_container = doc,
|
||||||
._start_container = doc,
|
._start_container = doc,
|
||||||
});
|
};
|
||||||
chain.setLeaf(1, child);
|
chain.setLeaf(1, child);
|
||||||
|
page._live_ranges.append(&abstract_range._range_link);
|
||||||
return chain.get(1);
|
return chain.get(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +389,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (comptime @hasField(S, "_proto")) {
|
if (comptime @hasField(S, "_proto")) {
|
||||||
self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
|
self.destroyChain(value, 0, std.mem.Alignment.@"1");
|
||||||
} else {
|
} else {
|
||||||
self.destroyStandalone(value);
|
self.destroyStandalone(value);
|
||||||
}
|
}
|
||||||
@@ -398,7 +403,6 @@ pub fn destroyStandalone(self: *Factory, value: anytype) void {
|
|||||||
fn destroyChain(
|
fn destroyChain(
|
||||||
self: *Factory,
|
self: *Factory,
|
||||||
value: anytype,
|
value: anytype,
|
||||||
comptime first: bool,
|
|
||||||
old_size: usize,
|
old_size: usize,
|
||||||
old_align: std.mem.Alignment,
|
old_align: std.mem.Alignment,
|
||||||
) void {
|
) void {
|
||||||
@@ -410,23 +414,8 @@ fn destroyChain(
|
|||||||
const new_size = current_size + @sizeOf(S);
|
const new_size = current_size + @sizeOf(S);
|
||||||
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
|
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
|
||||||
|
|
||||||
// This is initially called from a deinit. We don't want to call that
|
|
||||||
// same deinit. So when this is the first time destroyChain is called
|
|
||||||
// we don't call deinit (because we're in that deinit)
|
|
||||||
if (!comptime first) {
|
|
||||||
// But if it isn't the first time
|
|
||||||
if (@hasDecl(S, "deinit")) {
|
|
||||||
// And it has a deinit, we'll call it
|
|
||||||
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
|
|
||||||
1 => value.deinit(),
|
|
||||||
2 => value.deinit(self._page),
|
|
||||||
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (@hasField(S, "_proto")) {
|
if (@hasField(S, "_proto")) {
|
||||||
self.destroyChain(value._proto, false, new_size, new_align);
|
self.destroyChain(value._proto, new_size, new_align);
|
||||||
} else {
|
} else {
|
||||||
// no proto so this is the head of the chain.
|
// no proto so this is the head of the chain.
|
||||||
// we use this as the ptr to the start of the chain.
|
// we use this as the ptr to the start of the chain.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,12 @@ params: []const u8 = "",
|
|||||||
// IANA defines max. charset value length as 40.
|
// IANA defines max. charset value length as 40.
|
||||||
// We keep 41 for null-termination since HTML parser expects in this format.
|
// We keep 41 for null-termination since HTML parser expects in this format.
|
||||||
charset: [41]u8 = default_charset,
|
charset: [41]u8 = default_charset,
|
||||||
charset_len: usize = 5,
|
charset_len: usize = default_charset_len,
|
||||||
|
is_default_charset: bool = true,
|
||||||
|
|
||||||
/// String "UTF-8" continued by null characters.
|
/// String "UTF-8" continued by null characters.
|
||||||
pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
|
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
|
||||||
|
const default_charset_len = 5;
|
||||||
|
|
||||||
/// Mime with unknown Content-Type, empty params and empty charset.
|
/// Mime with unknown Content-Type, empty params and empty charset.
|
||||||
pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
|
pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
|
||||||
@@ -127,17 +129,18 @@ pub fn parse(input: []u8) !Mime {
|
|||||||
|
|
||||||
const params = trimLeft(normalized[type_len..]);
|
const params = trimLeft(normalized[type_len..]);
|
||||||
|
|
||||||
var charset: [41]u8 = undefined;
|
var charset: [41]u8 = default_charset;
|
||||||
var charset_len: usize = undefined;
|
var charset_len: usize = default_charset_len;
|
||||||
|
var has_explicit_charset = false;
|
||||||
|
|
||||||
var it = std.mem.splitScalar(u8, params, ';');
|
var it = std.mem.splitScalar(u8, params, ';');
|
||||||
while (it.next()) |attr| {
|
while (it.next()) |attr| {
|
||||||
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid;
|
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
|
||||||
const name = trimLeft(attr[0..i]);
|
const name = trimLeft(attr[0..i]);
|
||||||
|
|
||||||
const value = trimRight(attr[i + 1 ..]);
|
const value = trimRight(attr[i + 1 ..]);
|
||||||
if (value.len == 0) {
|
if (value.len == 0) {
|
||||||
return error.Invalid;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attribute_name = std.meta.stringToEnum(enum {
|
const attribute_name = std.meta.stringToEnum(enum {
|
||||||
@@ -150,11 +153,12 @@ pub fn parse(input: []u8) !Mime {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attribute_value = try parseCharset(value);
|
const attribute_value = parseCharset(value) catch continue;
|
||||||
@memcpy(charset[0..attribute_value.len], attribute_value);
|
@memcpy(charset[0..attribute_value.len], attribute_value);
|
||||||
// Null-terminate right after attribute value.
|
// Null-terminate right after attribute value.
|
||||||
charset[attribute_value.len] = 0;
|
charset[attribute_value.len] = 0;
|
||||||
charset_len = attribute_value.len;
|
charset_len = attribute_value.len;
|
||||||
|
has_explicit_charset = true;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,9 +168,137 @@ pub fn parse(input: []u8) !Mime {
|
|||||||
.charset = charset,
|
.charset = charset,
|
||||||
.charset_len = charset_len,
|
.charset_len = charset_len,
|
||||||
.content_type = content_type,
|
.content_type = content_type,
|
||||||
|
.is_default_charset = !has_explicit_charset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
|
||||||
|
/// Looks for `<meta charset="X">` and `<meta http-equiv="Content-Type" content="...;charset=X">`.
|
||||||
|
/// Returns the charset value or null if none found.
|
||||||
|
/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations
|
||||||
|
pub fn prescanCharset(html: []const u8) ?[]const u8 {
|
||||||
|
const limit = @min(html.len, 1024);
|
||||||
|
const data = html[0..limit];
|
||||||
|
|
||||||
|
// Scan for <meta tags
|
||||||
|
var pos: usize = 0;
|
||||||
|
while (pos < data.len) {
|
||||||
|
// Find next '<'
|
||||||
|
pos = std.mem.indexOfScalarPos(u8, data, pos, '<') orelse return null;
|
||||||
|
pos += 1;
|
||||||
|
if (pos >= data.len) return null;
|
||||||
|
|
||||||
|
// Check for "meta" (case-insensitive)
|
||||||
|
if (pos + 4 >= data.len) return null;
|
||||||
|
var tag_buf: [4]u8 = undefined;
|
||||||
|
_ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]);
|
||||||
|
if (!std.mem.eql(u8, &tag_buf, "meta")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
// Must be followed by whitespace or end of tag
|
||||||
|
if (pos >= data.len) return null;
|
||||||
|
if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and
|
||||||
|
data[pos] != '\r' and data[pos] != '/')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan attributes within this meta tag
|
||||||
|
const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null;
|
||||||
|
const attrs = data[pos..tag_end];
|
||||||
|
|
||||||
|
// Look for charset= attribute directly
|
||||||
|
if (findAttrValue(attrs, "charset")) |charset| {
|
||||||
|
if (charset.len > 0 and charset.len <= 40) return charset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for http-equiv="content-type" with content="...;charset=X"
|
||||||
|
if (findAttrValue(attrs, "http-equiv")) |he| {
|
||||||
|
if (std.ascii.eqlIgnoreCase(he, "content-type")) {
|
||||||
|
if (findAttrValue(attrs, "content")) |content| {
|
||||||
|
if (extractCharsetFromContentType(content)) |charset| {
|
||||||
|
return charset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = tag_end + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 {
|
||||||
|
var pos: usize = 0;
|
||||||
|
while (pos < attrs.len) {
|
||||||
|
// Skip whitespace
|
||||||
|
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or
|
||||||
|
attrs[pos] == '\n' or attrs[pos] == '\r'))
|
||||||
|
{
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
if (pos >= attrs.len) return null;
|
||||||
|
|
||||||
|
// Read attribute name
|
||||||
|
const attr_start = pos;
|
||||||
|
while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and
|
||||||
|
attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/')
|
||||||
|
{
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
const attr_name = attrs[attr_start..pos];
|
||||||
|
|
||||||
|
// Skip whitespace around =
|
||||||
|
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||||
|
if (pos >= attrs.len or attrs[pos] != '=') {
|
||||||
|
// No '=' found - skip this token. Advance at least one byte to avoid infinite loop.
|
||||||
|
if (pos == attr_start) pos += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pos += 1; // skip '='
|
||||||
|
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||||
|
if (pos >= attrs.len) return null;
|
||||||
|
|
||||||
|
// Read attribute value
|
||||||
|
const value = blk: {
|
||||||
|
if (attrs[pos] == '"' or attrs[pos] == '\'') {
|
||||||
|
const quote = attrs[pos];
|
||||||
|
pos += 1;
|
||||||
|
const val_start = pos;
|
||||||
|
while (pos < attrs.len and attrs[pos] != quote) pos += 1;
|
||||||
|
const val = attrs[val_start..pos];
|
||||||
|
if (pos < attrs.len) pos += 1; // skip closing quote
|
||||||
|
break :blk val;
|
||||||
|
} else {
|
||||||
|
const val_start = pos;
|
||||||
|
while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and
|
||||||
|
attrs[pos] != '>' and attrs[pos] != '/')
|
||||||
|
{
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
break :blk attrs[val_start..pos];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.ascii.eqlIgnoreCase(attr_name, name)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractCharsetFromContentType(content: []const u8) ?[]const u8 {
|
||||||
|
var it = std.mem.splitScalar(u8, content, ';');
|
||||||
|
while (it.next()) |part| {
|
||||||
|
const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' });
|
||||||
|
if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) {
|
||||||
|
const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' });
|
||||||
|
if (val.len > 0 and val.len <= 40) return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sniff(body: []const u8) ?Mime {
|
pub fn sniff(body: []const u8) ?Mime {
|
||||||
// 0x0C is form feed
|
// 0x0C is form feed
|
||||||
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
|
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
|
||||||
@@ -177,15 +309,30 @@ pub fn sniff(body: []const u8) ?Mime {
|
|||||||
if (content[0] != '<') {
|
if (content[0] != '<') {
|
||||||
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
|
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
|
||||||
// UTF-8 BOM
|
// UTF-8 BOM
|
||||||
return .{ .content_type = .{ .text_plain = {} } };
|
return .{
|
||||||
|
.content_type = .{ .text_plain = {} },
|
||||||
|
.charset = default_charset,
|
||||||
|
.charset_len = default_charset_len,
|
||||||
|
.is_default_charset = false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
|
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
|
||||||
// UTF-16 big-endian BOM
|
// UTF-16 big-endian BOM
|
||||||
return .{ .content_type = .{ .text_plain = {} } };
|
return .{
|
||||||
|
.content_type = .{ .text_plain = {} },
|
||||||
|
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33,
|
||||||
|
.charset_len = 8,
|
||||||
|
.is_default_charset = false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
|
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
|
||||||
// UTF-16 little-endian BOM
|
// UTF-16 little-endian BOM
|
||||||
return .{ .content_type = .{ .text_plain = {} } };
|
return .{
|
||||||
|
.content_type = .{ .text_plain = {} },
|
||||||
|
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33,
|
||||||
|
.charset_len = 8,
|
||||||
|
.is_default_charset = false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -239,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool {
|
|||||||
return self.content_type == .text_html;
|
return self.content_type == .text_html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn isText(mime: *const Mime) bool {
|
||||||
|
return switch (mime.content_type) {
|
||||||
|
.text_xml, .text_html, .text_javascript, .text_plain, .text_css => true,
|
||||||
|
.application_json => true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// we expect value to be lowercase
|
// we expect value to be lowercase
|
||||||
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||||
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
|
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
|
||||||
@@ -334,6 +489,19 @@ test "Mime: invalid" {
|
|||||||
"text/ html",
|
"text/ html",
|
||||||
"text / html",
|
"text / html",
|
||||||
"text/html other",
|
"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; x=",
|
||||||
"text/html; x= ",
|
"text/html; x= ",
|
||||||
@@ -342,11 +510,13 @@ test "Mime: invalid" {
|
|||||||
"text/html; charset=\"\"",
|
"text/html; charset=\"\"",
|
||||||
"text/html; charset=\"",
|
"text/html; charset=\"",
|
||||||
"text/html; charset=\"\\",
|
"text/html; charset=\"\\",
|
||||||
|
"text/html;\"",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (invalids) |invalid| {
|
for (valid_with_malformed_params) |input| {
|
||||||
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
|
const mutable_input = try testing.arena_allocator.dupe(u8, input);
|
||||||
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
|
const mime = try Mime.parse(mutable_input);
|
||||||
|
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +605,12 @@ test "Mime: parse charset" {
|
|||||||
.charset = "custom-non-standard-charset-value",
|
.charset = "custom-non-standard-charset-value",
|
||||||
.params = "charset=\"custom-non-standard-charset-value\"",
|
.params = "charset=\"custom-non-standard-charset-value\"",
|
||||||
}, "text/xml;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" {
|
test "Mime: isHTML" {
|
||||||
@@ -518,6 +694,24 @@ test "Mime: sniff" {
|
|||||||
|
|
||||||
try expectHTML("<!-->");
|
try expectHTML("<!-->");
|
||||||
try expectHTML(" \n\t <!-->");
|
try expectHTML(" \n\t <!-->");
|
||||||
|
|
||||||
|
{
|
||||||
|
const mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?;
|
||||||
|
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||||
|
try testing.expectEqual("UTF-8", mime.charsetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?;
|
||||||
|
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||||
|
try testing.expectEqual("UTF-16BE", mime.charsetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?;
|
||||||
|
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||||
|
try testing.expectEqual("UTF-16LE", mime.charsetString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Expectation = struct {
|
const Expectation = struct {
|
||||||
@@ -554,3 +748,35 @@ fn expect(expected: Expectation, input: []const u8) !void {
|
|||||||
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
|
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "Mime: prescanCharset" {
|
||||||
|
// <meta charset="X">
|
||||||
|
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
|
||||||
|
try testing.expectEqual("iso-8859-1", Mime.prescanCharset("<html><head><meta charset=\"iso-8859-1\">").?);
|
||||||
|
try testing.expectEqual("shift_jis", Mime.prescanCharset("<meta charset='shift_jis'>").?);
|
||||||
|
|
||||||
|
// Case-insensitive tag matching
|
||||||
|
try testing.expectEqual("utf-8", Mime.prescanCharset("<META charset=\"utf-8\">").?);
|
||||||
|
try testing.expectEqual("utf-8", Mime.prescanCharset("<Meta charset=\"utf-8\">").?);
|
||||||
|
|
||||||
|
// <meta http-equiv="Content-Type" content="text/html; charset=X">
|
||||||
|
try testing.expectEqual(
|
||||||
|
"iso-8859-1",
|
||||||
|
Mime.prescanCharset("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">").?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No charset found
|
||||||
|
try testing.expectEqual(null, Mime.prescanCharset("<html><head><title>Test</title>"));
|
||||||
|
try testing.expectEqual(null, Mime.prescanCharset(""));
|
||||||
|
try testing.expectEqual(null, Mime.prescanCharset("no html here"));
|
||||||
|
|
||||||
|
// Self-closing meta without charset must not loop forever
|
||||||
|
try testing.expectEqual(null, Mime.prescanCharset("<meta foo=\"bar\"/>"));
|
||||||
|
|
||||||
|
// Charset after 1024 bytes should not be found
|
||||||
|
var long_html: [1100]u8 = undefined;
|
||||||
|
@memset(&long_html, ' ');
|
||||||
|
const suffix = "<meta charset=\"windows-1252\">";
|
||||||
|
@memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
|
||||||
|
try testing.expectEqual(null, Mime.prescanCharset(&long_html));
|
||||||
|
}
|
||||||
|
|||||||
1487
src/browser/Page.zig
1487
src/browser/Page.zig
File diff suppressed because it is too large
Load Diff
241
src/browser/Runner.zig
Normal file
241
src/browser/Runner.zig
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// 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 App = @import("../App.zig");
|
||||||
|
|
||||||
|
const Page = @import("Page.zig");
|
||||||
|
const Session = @import("Session.zig");
|
||||||
|
const Browser = @import("Browser.zig");
|
||||||
|
const Factory = @import("Factory.zig");
|
||||||
|
const HttpClient = @import("HttpClient.zig");
|
||||||
|
|
||||||
|
const IS_DEBUG = builtin.mode == .Debug;
|
||||||
|
|
||||||
|
const Runner = @This();
|
||||||
|
|
||||||
|
page: *Page,
|
||||||
|
session: *Session,
|
||||||
|
http_client: *HttpClient,
|
||||||
|
|
||||||
|
pub const Opts = struct {};
|
||||||
|
|
||||||
|
pub fn init(session: *Session, _: Opts) !Runner {
|
||||||
|
const page = &(session.page orelse return error.NoPage);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.page = page,
|
||||||
|
.session = session,
|
||||||
|
.http_client = session.browser.http_client,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const WaitOpts = struct {
|
||||||
|
ms: u32,
|
||||||
|
until: lp.Config.WaitUntil = .done,
|
||||||
|
};
|
||||||
|
pub fn wait(self: *Runner, opts: WaitOpts) !void {
|
||||||
|
_ = try self._wait(false, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CDPWaitResult = enum {
|
||||||
|
done,
|
||||||
|
cdp_socket,
|
||||||
|
};
|
||||||
|
pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
|
||||||
|
return self._wait(true, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
|
||||||
|
var timer = try std.time.Timer.start();
|
||||||
|
var ms_remaining = opts.ms;
|
||||||
|
|
||||||
|
const tick_opts = TickOpts{
|
||||||
|
.ms = 200,
|
||||||
|
.until = opts.until,
|
||||||
|
};
|
||||||
|
while (true) {
|
||||||
|
const tick_result = self._tick(is_cdp, tick_opts) catch |err| {
|
||||||
|
switch (err) {
|
||||||
|
error.JsError => {}, // already logged (with hopefully more context)
|
||||||
|
else => log.err(.browser, "session wait", .{
|
||||||
|
.err = err,
|
||||||
|
.url = self.page.url,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next_ms = switch (tick_result) {
|
||||||
|
.ok => |next_ms| next_ms,
|
||||||
|
.done => return .done,
|
||||||
|
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ms_elapsed = timer.lap() / 1_000_000;
|
||||||
|
if (ms_elapsed >= ms_remaining) {
|
||||||
|
return .done;
|
||||||
|
}
|
||||||
|
ms_remaining -= @intCast(ms_elapsed);
|
||||||
|
if (next_ms > 0) {
|
||||||
|
std.Thread.sleep(std.time.ns_per_ms * next_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const TickOpts = struct {
|
||||||
|
ms: u32,
|
||||||
|
until: lp.Config.WaitUntil = .done,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const TickResult = union(enum) {
|
||||||
|
done,
|
||||||
|
ok: u32,
|
||||||
|
};
|
||||||
|
pub fn tick(self: *Runner, opts: TickOpts) !TickResult {
|
||||||
|
return switch (try self._tick(false, opts)) {
|
||||||
|
.ok => |ms| .{ .ok = ms },
|
||||||
|
.done => .done,
|
||||||
|
.cdp_socket => unreachable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CDPTickResult = union(enum) {
|
||||||
|
done,
|
||||||
|
cdp_socket,
|
||||||
|
ok: u32,
|
||||||
|
};
|
||||||
|
pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult {
|
||||||
|
return self._tick(true, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
|
||||||
|
const page = self.page;
|
||||||
|
const http_client = self.http_client;
|
||||||
|
|
||||||
|
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 (comptime is_cdp) == false) {
|
||||||
|
// 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
|
||||||
|
const http_result = try http_client.tick(@intCast(opts.ms));
|
||||||
|
if ((comptime is_cdp) and http_result == .cdp_socket) {
|
||||||
|
return .cdp_socket;
|
||||||
|
}
|
||||||
|
return .{ .ok = 0 };
|
||||||
|
},
|
||||||
|
.html, .complete => {
|
||||||
|
const session = self.session;
|
||||||
|
if (session.queued_navigation.items.len != 0) {
|
||||||
|
try session.processQueuedNavigation();
|
||||||
|
self.page = &session.page.?; // might have changed
|
||||||
|
return .{ .ok = 0 };
|
||||||
|
}
|
||||||
|
const browser = session.browser;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 (comptime is_cdp == false)) {
|
||||||
|
// we don't need to consider http_client.intercepted here
|
||||||
|
// because is_cdp 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser.hasBackgroundTasks()) {
|
||||||
|
// _we_ have nothing to run, but v8 is working on
|
||||||
|
// background tasks. We'll wait for them.
|
||||||
|
browser.waitForBackgroundTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (opts.until) {
|
||||||
|
.done => {},
|
||||||
|
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
|
||||||
|
return .done;
|
||||||
|
},
|
||||||
|
.load => if (page._load_state == .complete) {
|
||||||
|
return .done;
|
||||||
|
},
|
||||||
|
.networkidle => if (page._notified_network_idle == .done) {
|
||||||
|
return .done;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// We never advertise a wait time of more than 20, there can
|
||||||
|
// always be new background tasks to run.
|
||||||
|
if (browser.msToNextMacrotask()) |ms_to_next_task| {
|
||||||
|
return .{ .ok = @min(ms_to_next_task, 20) };
|
||||||
|
}
|
||||||
|
return .done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're here because we either have active HTTP
|
||||||
|
// connections, or is_cdp == false (aka, there's
|
||||||
|
// an cdp_socket registered with the http client).
|
||||||
|
// We should continue to run tasks, so we minimize how long
|
||||||
|
// we'll poll for network I/O.
|
||||||
|
var ms_to_wait = @min(opts.ms, browser.msToNextMacrotask() 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;
|
||||||
|
}
|
||||||
|
const http_result = try http_client.tick(@intCast(@min(opts.ms, ms_to_wait)));
|
||||||
|
if ((comptime is_cdp) and http_result == .cdp_socket) {
|
||||||
|
return .cdp_socket;
|
||||||
|
}
|
||||||
|
return .{ .ok = 0 };
|
||||||
|
},
|
||||||
|
.err => |err| {
|
||||||
|
page._parse_state = .{ .raw_done = @errorName(err) };
|
||||||
|
return err;
|
||||||
|
},
|
||||||
|
.raw_done => return .done,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,15 @@ const std = @import("std");
|
|||||||
const lp = @import("lightpanda");
|
const lp = @import("lightpanda");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
const js = @import("js/js.zig");
|
|
||||||
const log = @import("../log.zig");
|
const log = @import("../log.zig");
|
||||||
|
const HttpClient = @import("HttpClient.zig");
|
||||||
|
const net_http = @import("../network/http.zig");
|
||||||
|
const String = @import("../string.zig").String;
|
||||||
|
|
||||||
|
const js = @import("js/js.zig");
|
||||||
const URL = @import("URL.zig");
|
const URL = @import("URL.zig");
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
const Browser = @import("Browser.zig");
|
const Browser = @import("Browser.zig");
|
||||||
const Http = @import("../http/Http.zig");
|
|
||||||
|
|
||||||
const Element = @import("webapi/Element.zig");
|
const Element = @import("webapi/Element.zig");
|
||||||
|
|
||||||
@@ -59,11 +61,8 @@ ready_scripts: std.DoublyLinkedList,
|
|||||||
|
|
||||||
shutdown: bool = false,
|
shutdown: bool = false,
|
||||||
|
|
||||||
client: *Http.Client,
|
client: *HttpClient,
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
buffer_pool: BufferPool,
|
|
||||||
|
|
||||||
script_pool: std.heap.MemoryPool(Script),
|
|
||||||
|
|
||||||
// We can download multiple sync modules in parallel, but we want to process
|
// We can download multiple sync modules in parallel, but we want to process
|
||||||
// them in order. We can't use an std.DoublyLinkedList, like the other script types,
|
// them in order. We can't use an std.DoublyLinkedList, like the other script types,
|
||||||
@@ -83,7 +82,11 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule),
|
|||||||
// importmap contains resolved urls.
|
// importmap contains resolved urls.
|
||||||
importmap: std.StringHashMapUnmanaged([:0]const u8),
|
importmap: std.StringHashMapUnmanaged([:0]const u8),
|
||||||
|
|
||||||
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
|
// have we notified the page that all scripts are loaded (used to fire the "load"
|
||||||
|
// event).
|
||||||
|
page_notified_of_completion: bool,
|
||||||
|
|
||||||
|
pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager {
|
||||||
return .{
|
return .{
|
||||||
.page = page,
|
.page = page,
|
||||||
.async_scripts = .{},
|
.async_scripts = .{},
|
||||||
@@ -95,17 +98,14 @@ pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) Script
|
|||||||
.imported_modules = .empty,
|
.imported_modules = .empty,
|
||||||
.client = http_client,
|
.client = http_client,
|
||||||
.static_scripts_done = false,
|
.static_scripts_done = false,
|
||||||
.buffer_pool = BufferPool.init(allocator, 5),
|
.page_notified_of_completion = false,
|
||||||
.script_pool = std.heap.MemoryPool(Script).init(allocator),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *ScriptManager) void {
|
pub fn deinit(self: *ScriptManager) void {
|
||||||
// necessary to free any buffers scripts may be referencing
|
// necessary to free any arenas scripts may be referencing
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
||||||
self.buffer_pool.deinit();
|
|
||||||
self.script_pool.deinit();
|
|
||||||
self.imported_modules.deinit(self.allocator);
|
self.imported_modules.deinit(self.allocator);
|
||||||
// we don't deinit self.importmap b/c we use the page's arena for its
|
// we don't deinit self.importmap b/c we use the page's arena for its
|
||||||
// allocations.
|
// allocations.
|
||||||
@@ -114,7 +114,10 @@ pub fn deinit(self: *ScriptManager) void {
|
|||||||
pub fn reset(self: *ScriptManager) void {
|
pub fn reset(self: *ScriptManager) void {
|
||||||
var it = self.imported_modules.valueIterator();
|
var it = self.imported_modules.valueIterator();
|
||||||
while (it.next()) |value_ptr| {
|
while (it.next()) |value_ptr| {
|
||||||
self.buffer_pool.release(value_ptr.buffer);
|
switch (value_ptr.state) {
|
||||||
|
.done => |script| script.deinit(),
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.imported_modules.clearRetainingCapacity();
|
self.imported_modules.clearRetainingCapacity();
|
||||||
|
|
||||||
@@ -131,13 +134,13 @@ pub fn reset(self: *ScriptManager) void {
|
|||||||
fn clearList(list: *std.DoublyLinkedList) void {
|
fn clearList(list: *std.DoublyLinkedList) void {
|
||||||
while (list.popFirst()) |n| {
|
while (list.popFirst()) |n| {
|
||||||
const script: *Script = @fieldParentPtr("node", n);
|
const script: *Script = @fieldParentPtr("node", n);
|
||||||
script.deinit(true);
|
script.deinit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
|
fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers {
|
||||||
var headers = try self.client.newHeaders();
|
var headers = try self.client.newHeaders();
|
||||||
try self.page.headersForRequest(self.page.arena, url, &headers);
|
try self.page.headersForRequest(arena, url, &headers);
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +155,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
// <script> has already been processed.
|
// <script> has already been processed.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
script_element._executed = true;
|
|
||||||
|
|
||||||
const element = script_element.asElement();
|
const element = script_element.asElement();
|
||||||
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
|
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
|
||||||
@@ -185,30 +187,48 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var handover = false;
|
||||||
const page = self.page;
|
const page = self.page;
|
||||||
|
|
||||||
|
const arena = try page.getArena(.{ .debug = "addFromElement" });
|
||||||
|
errdefer if (!handover) {
|
||||||
|
page.releaseArena(arena);
|
||||||
|
};
|
||||||
|
|
||||||
var source: Script.Source = undefined;
|
var source: Script.Source = undefined;
|
||||||
var remote_url: ?[:0]const u8 = null;
|
var remote_url: ?[:0]const u8 = null;
|
||||||
const base_url = page.base();
|
const base_url = page.base();
|
||||||
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
|
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||||
if (try parseDataURI(page.arena, src)) |data_uri| {
|
if (try parseDataURI(arena, src)) |data_uri| {
|
||||||
source = .{ .@"inline" = data_uri };
|
source = .{ .@"inline" = data_uri };
|
||||||
} else {
|
} else {
|
||||||
remote_url = try URL.resolve(page.arena, base_url, src, .{});
|
remote_url = try URL.resolve(arena, base_url, src, .{});
|
||||||
source = .{ .remote = .{} };
|
source = .{ .remote = .{} };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const inline_source = try element.asNode().getTextContentAlloc(page.arena);
|
var buf = std.Io.Writer.Allocating.init(arena);
|
||||||
|
try element.asNode().getChildTextContent(&buf.writer);
|
||||||
|
try buf.writer.writeByte(0);
|
||||||
|
const data = buf.written();
|
||||||
|
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
|
||||||
|
if (inline_source.len == 0) {
|
||||||
|
// we haven't set script_element._executed = true yet, which is good.
|
||||||
|
// If content is appended to the script, we will execute it then.
|
||||||
|
page.releaseArena(arena);
|
||||||
|
return;
|
||||||
|
}
|
||||||
source = .{ .@"inline" = inline_source };
|
source = .{ .@"inline" = inline_source };
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = try self.script_pool.create();
|
// Only set _executed (already-started) when we actually have content to execute
|
||||||
errdefer self.script_pool.destroy(script);
|
script_element._executed = true;
|
||||||
|
|
||||||
const is_inline = source == .@"inline";
|
const is_inline = source == .@"inline";
|
||||||
|
|
||||||
|
const script = try arena.create(Script);
|
||||||
script.* = .{
|
script.* = .{
|
||||||
.kind = kind,
|
.kind = kind,
|
||||||
.node = .{},
|
.node = .{},
|
||||||
|
.arena = arena,
|
||||||
.manager = self,
|
.manager = self,
|
||||||
.source = source,
|
.source = source,
|
||||||
.script_element = script_element,
|
.script_element = script_element,
|
||||||
@@ -252,14 +272,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
if (is_blocking == false) {
|
if (is_blocking == false) {
|
||||||
self.scriptList(script).remove(&script.node);
|
self.scriptList(script).remove(&script.node);
|
||||||
}
|
}
|
||||||
script.deinit(true);
|
// Let the outer errdefer handle releasing the arena if client.request fails
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.client.request(.{
|
try self.client.request(.{
|
||||||
.url = url,
|
.url = url,
|
||||||
.ctx = script,
|
.ctx = script,
|
||||||
.method = .GET,
|
.method = .GET,
|
||||||
.headers = try self.getHeaders(url),
|
.frame_id = page._frame_id,
|
||||||
|
.headers = try self.getHeaders(arena, url),
|
||||||
.blocking = is_blocking,
|
.blocking = is_blocking,
|
||||||
.cookie_jar = &page._session.cookie_jar,
|
.cookie_jar = &page._session.cookie_jar,
|
||||||
.resource_type = .script,
|
.resource_type = .script,
|
||||||
@@ -270,6 +291,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
.done_callback = Script.doneCallback,
|
.done_callback = Script.doneCallback,
|
||||||
.error_callback = Script.errorCallback,
|
.error_callback = Script.errorCallback,
|
||||||
});
|
});
|
||||||
|
handover = true;
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
@@ -299,7 +321,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
}
|
}
|
||||||
if (script.status == 0) {
|
if (script.status == 0) {
|
||||||
// an error (that we already logged)
|
// an error (that we already logged)
|
||||||
script.deinit(true);
|
script.deinit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +330,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
self.is_evaluating = true;
|
self.is_evaluating = true;
|
||||||
defer {
|
defer {
|
||||||
self.is_evaluating = was_evaluating;
|
self.is_evaluating = was_evaluating;
|
||||||
script.deinit(true);
|
script.deinit();
|
||||||
}
|
}
|
||||||
return script.eval(page);
|
return script.eval(page);
|
||||||
}
|
}
|
||||||
@@ -340,11 +362,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
|||||||
}
|
}
|
||||||
errdefer _ = self.imported_modules.remove(url);
|
errdefer _ = self.imported_modules.remove(url);
|
||||||
|
|
||||||
const script = try self.script_pool.create();
|
const page = self.page;
|
||||||
errdefer self.script_pool.destroy(script);
|
const arena = try page.getArena(.{ .debug = "preloadImport" });
|
||||||
|
errdefer page.releaseArena(arena);
|
||||||
|
|
||||||
|
const script = try arena.create(Script);
|
||||||
script.* = .{
|
script.* = .{
|
||||||
.kind = .module,
|
.kind = .module,
|
||||||
|
.arena = arena,
|
||||||
.url = url,
|
.url = url,
|
||||||
.node = .{},
|
.node = .{},
|
||||||
.manager = self,
|
.manager = self,
|
||||||
@@ -354,13 +379,11 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
|||||||
.mode = .import,
|
.mode = .import,
|
||||||
};
|
};
|
||||||
|
|
||||||
gop.value_ptr.* = ImportedModule{
|
gop.value_ptr.* = ImportedModule{};
|
||||||
.manager = self,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
self.page.js.localScope(&ls);
|
page.js.localScope(&ls);
|
||||||
defer ls.deinit();
|
defer ls.deinit();
|
||||||
|
|
||||||
log.debug(.http, "script queue", .{
|
log.debug(.http, "script queue", .{
|
||||||
@@ -371,26 +394,30 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.client.request(.{
|
|
||||||
.url = url,
|
|
||||||
.ctx = script,
|
|
||||||
.method = .GET,
|
|
||||||
.headers = try self.getHeaders(url),
|
|
||||||
.cookie_jar = &self.page._session.cookie_jar,
|
|
||||||
.resource_type = .script,
|
|
||||||
.notification = self.page._session.notification,
|
|
||||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
|
||||||
.header_callback = Script.headerCallback,
|
|
||||||
.data_callback = Script.dataCallback,
|
|
||||||
.done_callback = Script.doneCallback,
|
|
||||||
.error_callback = Script.errorCallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
// This seems wrong since we're not dealing with an async import (unlike
|
// This seems wrong since we're not dealing with an async import (unlike
|
||||||
// getAsyncModule below), but all we're trying to do here is pre-load the
|
// getAsyncModule below), but all we're trying to do here is pre-load the
|
||||||
// script for execution at some point in the future (when waitForImport is
|
// script for execution at some point in the future (when waitForImport is
|
||||||
// called).
|
// called).
|
||||||
self.async_scripts.append(&script.node);
|
self.async_scripts.append(&script.node);
|
||||||
|
|
||||||
|
self.client.request(.{
|
||||||
|
.url = url,
|
||||||
|
.ctx = script,
|
||||||
|
.method = .GET,
|
||||||
|
.frame_id = page._frame_id,
|
||||||
|
.headers = try self.getHeaders(arena, url),
|
||||||
|
.cookie_jar = &page._session.cookie_jar,
|
||||||
|
.resource_type = .script,
|
||||||
|
.notification = page._session.notification,
|
||||||
|
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||||
|
.header_callback = Script.headerCallback,
|
||||||
|
.data_callback = Script.dataCallback,
|
||||||
|
.done_callback = Script.doneCallback,
|
||||||
|
.error_callback = Script.errorCallback,
|
||||||
|
}) catch |err| {
|
||||||
|
self.async_scripts.remove(&script.node);
|
||||||
|
return err;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
||||||
@@ -411,12 +438,12 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
|||||||
_ = try client.tick(200);
|
_ = try client.tick(200);
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
.done => {
|
.done => |script| {
|
||||||
var shared = false;
|
var shared = false;
|
||||||
const buffer = entry.value_ptr.buffer;
|
const buffer = entry.value_ptr.buffer;
|
||||||
const waiters = entry.value_ptr.waiters;
|
const waiters = entry.value_ptr.waiters;
|
||||||
|
|
||||||
if (waiters == 0) {
|
if (waiters == 1) {
|
||||||
self.imported_modules.removeByPtr(entry.key_ptr);
|
self.imported_modules.removeByPtr(entry.key_ptr);
|
||||||
} else {
|
} else {
|
||||||
shared = true;
|
shared = true;
|
||||||
@@ -425,7 +452,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
|||||||
return .{
|
return .{
|
||||||
.buffer = buffer,
|
.buffer = buffer,
|
||||||
.shared = shared,
|
.shared = shared,
|
||||||
.buffer_pool = &self.buffer_pool,
|
.script = script,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
.err => return error.Failed,
|
.err => return error.Failed,
|
||||||
@@ -434,11 +461,14 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
|
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
|
||||||
const script = try self.script_pool.create();
|
const page = self.page;
|
||||||
errdefer self.script_pool.destroy(script);
|
const arena = try page.getArena(.{ .debug = "getAsyncImport" });
|
||||||
|
errdefer page.releaseArena(arena);
|
||||||
|
|
||||||
|
const script = try arena.create(Script);
|
||||||
script.* = .{
|
script.* = .{
|
||||||
.kind = .module,
|
.kind = .module,
|
||||||
|
.arena = arena,
|
||||||
.url = url,
|
.url = url,
|
||||||
.node = .{},
|
.node = .{},
|
||||||
.manager = self,
|
.manager = self,
|
||||||
@@ -453,7 +483,7 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
|||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
self.page.js.localScope(&ls);
|
page.js.localScope(&ls);
|
||||||
defer ls.deinit();
|
defer ls.deinit();
|
||||||
|
|
||||||
log.debug(.http, "script queue", .{
|
log.debug(.http, "script queue", .{
|
||||||
@@ -473,22 +503,25 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
|||||||
self.is_evaluating = true;
|
self.is_evaluating = true;
|
||||||
defer self.is_evaluating = was_evaluating;
|
defer self.is_evaluating = was_evaluating;
|
||||||
|
|
||||||
try self.client.request(.{
|
self.async_scripts.append(&script.node);
|
||||||
|
self.client.request(.{
|
||||||
.url = url,
|
.url = url,
|
||||||
.method = .GET,
|
.method = .GET,
|
||||||
.headers = try self.getHeaders(url),
|
.frame_id = page._frame_id,
|
||||||
|
.headers = try self.getHeaders(arena, url),
|
||||||
.ctx = script,
|
.ctx = script,
|
||||||
.resource_type = .script,
|
.resource_type = .script,
|
||||||
.cookie_jar = &self.page._session.cookie_jar,
|
.cookie_jar = &page._session.cookie_jar,
|
||||||
.notification = self.page._session.notification,
|
.notification = page._session.notification,
|
||||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||||
.header_callback = Script.headerCallback,
|
.header_callback = Script.headerCallback,
|
||||||
.data_callback = Script.dataCallback,
|
.data_callback = Script.dataCallback,
|
||||||
.done_callback = Script.doneCallback,
|
.done_callback = Script.doneCallback,
|
||||||
.error_callback = Script.errorCallback,
|
.error_callback = Script.errorCallback,
|
||||||
});
|
}) catch |err| {
|
||||||
|
self.async_scripts.remove(&script.node);
|
||||||
self.async_scripts.append(&script.node);
|
return err;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from the Page to let us know it's done parsing the HTML. Necessary that
|
// Called from the Page to let us know it's done parsing the HTML. Necessary that
|
||||||
@@ -513,18 +546,18 @@ fn evaluate(self: *ScriptManager) void {
|
|||||||
var script: *Script = @fieldParentPtr("node", n);
|
var script: *Script = @fieldParentPtr("node", n);
|
||||||
switch (script.mode) {
|
switch (script.mode) {
|
||||||
.async => {
|
.async => {
|
||||||
defer script.deinit(true);
|
defer script.deinit();
|
||||||
script.eval(page);
|
script.eval(page);
|
||||||
},
|
},
|
||||||
.import_async => |ia| {
|
.import_async => |ia| {
|
||||||
defer script.deinit(false);
|
|
||||||
if (script.status < 200 or script.status > 299) {
|
if (script.status < 200 or script.status > 299) {
|
||||||
|
script.deinit();
|
||||||
ia.callback(ia.data, error.FailedToLoad);
|
ia.callback(ia.data, error.FailedToLoad);
|
||||||
} else {
|
} else {
|
||||||
ia.callback(ia.data, .{
|
ia.callback(ia.data, .{
|
||||||
.shared = false,
|
.shared = false,
|
||||||
|
.script = script,
|
||||||
.buffer = script.source.remote,
|
.buffer = script.source.remote,
|
||||||
.buffer_pool = &self.buffer_pool,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -550,7 +583,7 @@ fn evaluate(self: *ScriptManager) void {
|
|||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
_ = self.defer_scripts.popFirst();
|
_ = self.defer_scripts.popFirst();
|
||||||
script.deinit(true);
|
script.deinit();
|
||||||
}
|
}
|
||||||
script.eval(page);
|
script.eval(page);
|
||||||
}
|
}
|
||||||
@@ -564,19 +597,12 @@ fn evaluate(self: *ScriptManager) void {
|
|||||||
// Page makes this safe to call multiple times.
|
// Page makes this safe to call multiple times.
|
||||||
page.documentIsLoaded();
|
page.documentIsLoaded();
|
||||||
|
|
||||||
if (self.async_scripts.first == null) {
|
if (self.async_scripts.first == null and self.page_notified_of_completion == false) {
|
||||||
// Looks like all async scripts are done too!
|
self.page_notified_of_completion = true;
|
||||||
// Page makes this safe to call multiple times.
|
page.scriptsCompletedLoading();
|
||||||
page.documentIsComplete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn isDone(self: *const ScriptManager) bool {
|
|
||||||
return self.static_scripts_done and // page is done processing initial html
|
|
||||||
self.defer_scripts.first == null and // no deferred scripts
|
|
||||||
self.async_scripts.first == null; // no async scripts
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
|
fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
|
||||||
const content = script.source.content();
|
const content = script.source.content();
|
||||||
|
|
||||||
@@ -608,17 +634,31 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const Script = struct {
|
pub const Script = struct {
|
||||||
complete: bool,
|
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
|
complete: bool,
|
||||||
status: u16 = 0,
|
status: u16 = 0,
|
||||||
source: Source,
|
source: Source,
|
||||||
url: []const u8,
|
url: []const u8,
|
||||||
|
arena: Allocator,
|
||||||
mode: ExecutionMode,
|
mode: ExecutionMode,
|
||||||
node: std.DoublyLinkedList.Node,
|
node: std.DoublyLinkedList.Node,
|
||||||
script_element: ?*Element.Html.Script,
|
script_element: ?*Element.Html.Script,
|
||||||
manager: *ScriptManager,
|
manager: *ScriptManager,
|
||||||
|
|
||||||
|
// for debugging a rare production issue
|
||||||
header_callback_called: bool = false,
|
header_callback_called: bool = false,
|
||||||
|
|
||||||
|
// for debugging a rare production issue
|
||||||
|
debug_transfer_id: u32 = 0,
|
||||||
|
debug_transfer_tries: u8 = 0,
|
||||||
|
debug_transfer_aborted: bool = false,
|
||||||
|
debug_transfer_bytes_received: usize = 0,
|
||||||
|
debug_transfer_notified_fail: bool = false,
|
||||||
|
debug_transfer_redirecting: bool = false,
|
||||||
|
debug_transfer_intercept_state: u8 = 0,
|
||||||
|
debug_transfer_auth_challenge: bool = false,
|
||||||
|
debug_transfer_easy_id: usize = 0,
|
||||||
|
|
||||||
const Kind = enum {
|
const Kind = enum {
|
||||||
module,
|
module,
|
||||||
javascript,
|
javascript,
|
||||||
@@ -650,18 +690,15 @@ pub const Script = struct {
|
|||||||
import_async: ImportAsync,
|
import_async: ImportAsync,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn deinit(self: *Script, comptime release_buffer: bool) void {
|
fn deinit(self: *Script) void {
|
||||||
if ((comptime release_buffer) and self.source == .remote) {
|
self.manager.page.releaseArena(self.arena);
|
||||||
self.manager.buffer_pool.release(self.source.remote);
|
|
||||||
}
|
|
||||||
self.manager.script_pool.destroy(self);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn startCallback(transfer: *Http.Transfer) !void {
|
fn startCallback(transfer: *HttpClient.Transfer) !void {
|
||||||
log.debug(.http, "script fetch start", .{ .req = transfer });
|
log.debug(.http, "script fetch start", .{ .req = transfer });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn headerCallback(transfer: *Http.Transfer) !bool {
|
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
|
||||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||||
const header = &transfer.response_header.?;
|
const header = &transfer.response_header.?;
|
||||||
self.status = header.status;
|
self.status = header.status;
|
||||||
@@ -686,28 +723,57 @@ pub const Script = struct {
|
|||||||
// temp debug, trying to figure out why the next assert sometimes
|
// temp debug, trying to figure out why the next assert sometimes
|
||||||
// fails. Is the buffer just corrupt or is headerCallback really
|
// fails. Is the buffer just corrupt or is headerCallback really
|
||||||
// being called twice?
|
// being called twice?
|
||||||
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{});
|
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
|
||||||
|
.m = @tagName(std.meta.activeTag(self.mode)),
|
||||||
|
.a1 = self.debug_transfer_id,
|
||||||
|
.a2 = self.debug_transfer_tries,
|
||||||
|
.a3 = self.debug_transfer_aborted,
|
||||||
|
.a4 = self.debug_transfer_bytes_received,
|
||||||
|
.a5 = self.debug_transfer_notified_fail,
|
||||||
|
.a6 = self.debug_transfer_redirecting,
|
||||||
|
.a7 = self.debug_transfer_intercept_state,
|
||||||
|
.a8 = self.debug_transfer_auth_challenge,
|
||||||
|
.a9 = self.debug_transfer_easy_id,
|
||||||
|
.b1 = transfer.id,
|
||||||
|
.b2 = transfer._tries,
|
||||||
|
.b3 = transfer.aborted,
|
||||||
|
.b4 = transfer.bytes_received,
|
||||||
|
.b5 = transfer._notified_fail,
|
||||||
|
.b6 = transfer._redirecting,
|
||||||
|
.b7 = @intFromEnum(transfer._intercept_state),
|
||||||
|
.b8 = transfer._auth_challenge != null,
|
||||||
|
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
|
||||||
|
});
|
||||||
self.header_callback_called = true;
|
self.header_callback_called = true;
|
||||||
|
self.debug_transfer_id = transfer.id;
|
||||||
|
self.debug_transfer_tries = transfer._tries;
|
||||||
|
self.debug_transfer_aborted = transfer.aborted;
|
||||||
|
self.debug_transfer_bytes_received = transfer.bytes_received;
|
||||||
|
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||||
|
self.debug_transfer_redirecting = transfer._redirecting;
|
||||||
|
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
|
||||||
|
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||||
|
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
|
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
|
||||||
var buffer = self.manager.buffer_pool.get();
|
var buffer: std.ArrayList(u8) = .empty;
|
||||||
if (transfer.getContentLength()) |cl| {
|
if (transfer.getContentLength()) |cl| {
|
||||||
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
|
try buffer.ensureTotalCapacity(self.arena, cl);
|
||||||
}
|
}
|
||||||
self.source = .{ .remote = buffer };
|
self.source = .{ .remote = buffer };
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
|
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||||
self._dataCallback(transfer, data) catch |err| {
|
self._dataCallback(transfer, data) catch |err| {
|
||||||
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
|
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void {
|
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
|
||||||
try self.source.remote.appendSlice(self.manager.allocator, data);
|
try self.source.remote.appendSlice(self.arena, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn doneCallback(ctx: *anyopaque) !void {
|
fn doneCallback(ctx: *anyopaque) !void {
|
||||||
@@ -724,9 +790,8 @@ pub const Script = struct {
|
|||||||
} else if (self.mode == .import) {
|
} else if (self.mode == .import) {
|
||||||
manager.async_scripts.remove(&self.node);
|
manager.async_scripts.remove(&self.node);
|
||||||
const entry = manager.imported_modules.getPtr(self.url).?;
|
const entry = manager.imported_modules.getPtr(self.url).?;
|
||||||
entry.state = .done;
|
entry.state = .{ .done = self };
|
||||||
entry.buffer = self.source.remote;
|
entry.buffer = self.source.remote;
|
||||||
self.deinit(false);
|
|
||||||
}
|
}
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
}
|
}
|
||||||
@@ -752,7 +817,7 @@ pub const Script = struct {
|
|||||||
const manager = self.manager;
|
const manager = self.manager;
|
||||||
manager.scriptList(self).remove(&self.node);
|
manager.scriptList(self).remove(&self.node);
|
||||||
if (manager.shutdown) {
|
if (manager.shutdown) {
|
||||||
self.deinit(true);
|
self.deinit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +829,7 @@ pub const Script = struct {
|
|||||||
},
|
},
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
self.deinit(true);
|
self.deinit();
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,13 +885,15 @@ pub const Script = struct {
|
|||||||
.kind = self.kind,
|
.kind = self.kind,
|
||||||
.cacheable = cacheable,
|
.cacheable = cacheable,
|
||||||
});
|
});
|
||||||
self.executeCallback("error", local.toLocal(script_element._on_error), page);
|
self.executeCallback(comptime .wrap("error"), page);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.executeCallback("load", local.toLocal(script_element._on_load), page);
|
self.executeCallback(comptime .wrap("load"), page);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer page._event_manager.clearIgnoreList();
|
||||||
|
|
||||||
var try_catch: js.TryCatch = undefined;
|
var try_catch: js.TryCatch = undefined;
|
||||||
try_catch.init(local);
|
try_catch.init(local);
|
||||||
defer try_catch.deinit();
|
defer try_catch.deinit();
|
||||||
@@ -845,19 +912,18 @@ pub const Script = struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
log.debug(.browser, "executed script", .{ .src = url, .success = success, .on_load = script_element._on_load != null });
|
log.debug(.browser, "executed script", .{ .src = url, .success = success });
|
||||||
}
|
}
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
// We should run microtasks even if script execution fails.
|
local.runMacrotasks(); // also runs microtasks
|
||||||
local.runMicrotasks();
|
|
||||||
_ = page.js.scheduler.run() catch |err| {
|
_ = page.js.scheduler.run() catch |err| {
|
||||||
log.err(.page, "scheduler", .{ .err = err });
|
log.err(.page, "scheduler", .{ .err = err });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
self.executeCallback("load", local.toLocal(script_element._on_load), page);
|
self.executeCallback(comptime .wrap("load"), page);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,14 +934,12 @@ pub const Script = struct {
|
|||||||
.cacheable = cacheable,
|
.cacheable = cacheable,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.executeCallback("error", local.toLocal(script_element._on_error), page);
|
self.executeCallback(comptime .wrap("error"), page);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void {
|
fn executeCallback(self: *const Script, typ: String, page: *Page) void {
|
||||||
const cb = cb_ orelse return;
|
|
||||||
|
|
||||||
const Event = @import("webapi/Event.zig");
|
const Event = @import("webapi/Event.zig");
|
||||||
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| {
|
const event = Event.initTrusted(typ, .{}, page) catch |err| {
|
||||||
log.warn(.js, "script internal callback", .{
|
log.warn(.js, "script internal callback", .{
|
||||||
.url = self.url,
|
.url = self.url,
|
||||||
.type = typ,
|
.type = typ,
|
||||||
@@ -883,89 +947,16 @@ pub const Script = struct {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer if (!event._v8_handoff) event.deinit(false);
|
page._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
|
||||||
|
|
||||||
var caught: js.TryCatch.Caught = undefined;
|
|
||||||
cb.tryCall(void, .{event}, &caught) catch {
|
|
||||||
log.warn(.js, "script callback", .{
|
log.warn(.js, "script callback", .{
|
||||||
.url = self.url,
|
.url = self.url,
|
||||||
.type = typ,
|
.type = typ,
|
||||||
.caught = caught,
|
.err = err,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const BufferPool = struct {
|
|
||||||
count: usize,
|
|
||||||
available: List = .{},
|
|
||||||
allocator: Allocator,
|
|
||||||
max_concurrent_transfers: u8,
|
|
||||||
mem_pool: std.heap.MemoryPool(Container),
|
|
||||||
|
|
||||||
const List = std.SinglyLinkedList;
|
|
||||||
|
|
||||||
const Container = struct {
|
|
||||||
node: List.Node,
|
|
||||||
buf: std.ArrayList(u8),
|
|
||||||
};
|
|
||||||
|
|
||||||
fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
|
|
||||||
return .{
|
|
||||||
.available = .{},
|
|
||||||
.count = 0,
|
|
||||||
.allocator = allocator,
|
|
||||||
.max_concurrent_transfers = max_concurrent_transfers,
|
|
||||||
.mem_pool = std.heap.MemoryPool(Container).init(allocator),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deinit(self: *BufferPool) void {
|
|
||||||
const allocator = self.allocator;
|
|
||||||
|
|
||||||
var node = self.available.first;
|
|
||||||
while (node) |n| {
|
|
||||||
const container: *Container = @fieldParentPtr("node", n);
|
|
||||||
container.buf.deinit(allocator);
|
|
||||||
node = n.next;
|
|
||||||
}
|
|
||||||
self.mem_pool.deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(self: *BufferPool) std.ArrayList(u8) {
|
|
||||||
const node = self.available.popFirst() orelse {
|
|
||||||
// return a new buffer
|
|
||||||
return .{};
|
|
||||||
};
|
|
||||||
|
|
||||||
self.count -= 1;
|
|
||||||
const container: *Container = @fieldParentPtr("node", node);
|
|
||||||
defer self.mem_pool.destroy(container);
|
|
||||||
return container.buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn release(self: *BufferPool, buffer: ArrayList(u8)) void {
|
|
||||||
// create mutable copy
|
|
||||||
var b = buffer;
|
|
||||||
|
|
||||||
if (self.count == self.max_concurrent_transfers) {
|
|
||||||
b.deinit(self.allocator);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = self.mem_pool.create() catch |err| {
|
|
||||||
b.deinit(self.allocator);
|
|
||||||
log.err(.http, "SM BufferPool release", .{ .err = err });
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
b.clearRetainingCapacity();
|
|
||||||
container.* = .{ .buf = b, .node = .{} };
|
|
||||||
self.count += 1;
|
|
||||||
self.available.prepend(&container.node);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ImportAsync = struct {
|
const ImportAsync = struct {
|
||||||
data: *anyopaque,
|
data: *anyopaque,
|
||||||
callback: ImportAsync.Callback,
|
callback: ImportAsync.Callback,
|
||||||
@@ -975,12 +966,12 @@ const ImportAsync = struct {
|
|||||||
|
|
||||||
pub const ModuleSource = struct {
|
pub const ModuleSource = struct {
|
||||||
shared: bool,
|
shared: bool,
|
||||||
buffer_pool: *BufferPool,
|
script: *Script,
|
||||||
buffer: std.ArrayList(u8),
|
buffer: std.ArrayList(u8),
|
||||||
|
|
||||||
pub fn deinit(self: *ModuleSource) void {
|
pub fn deinit(self: *ModuleSource) void {
|
||||||
if (self.shared == false) {
|
if (self.shared == false) {
|
||||||
self.buffer_pool.release(self.buffer);
|
self.script.deinit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,15 +981,14 @@ pub const ModuleSource = struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ImportedModule = struct {
|
const ImportedModule = struct {
|
||||||
manager: *ScriptManager,
|
waiters: u16 = 1,
|
||||||
state: State = .loading,
|
state: State = .loading,
|
||||||
buffer: std.ArrayList(u8) = .{},
|
buffer: std.ArrayList(u8) = .{},
|
||||||
waiters: u16 = 1,
|
|
||||||
|
|
||||||
const State = enum {
|
const State = union(enum) {
|
||||||
err,
|
err,
|
||||||
done,
|
|
||||||
loading,
|
loading,
|
||||||
|
done: *Script,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1010,23 +1000,35 @@ fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
|
|||||||
|
|
||||||
const uri = src[5..];
|
const uri = src[5..];
|
||||||
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
|
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
|
||||||
|
const data = uri[data_starts + 1 ..];
|
||||||
|
|
||||||
var data = uri[data_starts + 1 ..];
|
const unescaped = try URL.unescape(allocator, data);
|
||||||
|
|
||||||
// Extract the encoding.
|
|
||||||
const metadata = uri[0..data_starts];
|
const metadata = uri[0..data_starts];
|
||||||
if (std.mem.endsWith(u8, metadata, ";base64")) {
|
if (std.mem.endsWith(u8, metadata, ";base64") == false) {
|
||||||
const decoder = std.base64.standard.Decoder;
|
return unescaped;
|
||||||
const decoded_size = try decoder.calcSizeForSlice(data);
|
|
||||||
|
|
||||||
const buffer = try allocator.alloc(u8, decoded_size);
|
|
||||||
errdefer allocator.free(buffer);
|
|
||||||
|
|
||||||
try decoder.decode(buffer, data);
|
|
||||||
data = buffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
// Forgiving base64 decode per WHATWG spec:
|
||||||
|
// https://infra.spec.whatwg.org/#forgiving-base64-decode
|
||||||
|
// Step 1: Remove all ASCII whitespace
|
||||||
|
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
|
||||||
|
for (unescaped) |c| {
|
||||||
|
if (!std.ascii.isWhitespace(c)) {
|
||||||
|
stripped.appendAssumeCapacity(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
|
||||||
|
|
||||||
|
// Length % 4 == 1 is invalid
|
||||||
|
if (trimmed.len % 4 == 1) {
|
||||||
|
return error.InvalidCharacterError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
|
||||||
|
const buffer = try allocator.alloc(u8, decoded_size);
|
||||||
|
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
|
||||||
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
|
|||||||
@@ -18,83 +18,122 @@
|
|||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const lp = @import("lightpanda");
|
const lp = @import("lightpanda");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
const log = @import("../log.zig");
|
const log = @import("../log.zig");
|
||||||
|
const App = @import("../App.zig");
|
||||||
|
|
||||||
const js = @import("js/js.zig");
|
const js = @import("js/js.zig");
|
||||||
|
const v8 = js.v8;
|
||||||
const storage = @import("webapi/storage/storage.zig");
|
const storage = @import("webapi/storage/storage.zig");
|
||||||
const Navigation = @import("webapi/navigation/Navigation.zig");
|
const Navigation = @import("webapi/navigation/Navigation.zig");
|
||||||
const History = @import("webapi/History.zig");
|
const History = @import("webapi/History.zig");
|
||||||
|
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
|
pub const Runner = @import("Runner.zig");
|
||||||
const Browser = @import("Browser.zig");
|
const Browser = @import("Browser.zig");
|
||||||
|
const Factory = @import("Factory.zig");
|
||||||
const Notification = @import("../Notification.zig");
|
const Notification = @import("../Notification.zig");
|
||||||
|
const QueuedNavigation = Page.QueuedNavigation;
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
const ArenaPool = App.ArenaPool;
|
||||||
|
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
|
// You can create successively multiple pages for a session, but you must
|
||||||
// deinit a page before running another one.
|
// deinit a page before running another one. It manages two distinct lifetimes.
|
||||||
|
//
|
||||||
|
// The first is the lifetime of the Session itself, where pages are created and
|
||||||
|
// removed, but share the same cookie jar and navigation history (etc...)
|
||||||
|
//
|
||||||
|
// The second is as a container the data needed by the full page hierarchy, i.e. \
|
||||||
|
// the root page and all of its frames (and all of their frames.)
|
||||||
const Session = @This();
|
const Session = @This();
|
||||||
|
|
||||||
|
// These are the fields that remain intact for the duration of the Session
|
||||||
browser: *Browser,
|
browser: *Browser,
|
||||||
notification: *Notification,
|
|
||||||
|
|
||||||
// Used to create our Inspector and in the BrowserContext.
|
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
|
|
||||||
// The page's arena is unsuitable for data that has to existing while
|
|
||||||
// navigating from one page to another. For example, if we're clicking
|
|
||||||
// on an HREF, the URL exists in the original page (where the click
|
|
||||||
// originated) but also has to exist in the new page.
|
|
||||||
// While we could use the Session's arena, this could accumulate a lot of
|
|
||||||
// memory if we do many navigation events. The `transfer_arena` is meant to
|
|
||||||
// bridge the gap: existing long enough to store any data needed to end one
|
|
||||||
// page and start another.
|
|
||||||
transfer_arena: Allocator,
|
|
||||||
|
|
||||||
cookie_jar: storage.Cookie.Jar,
|
|
||||||
storage_shed: storage.Shed,
|
|
||||||
|
|
||||||
history: History,
|
history: History,
|
||||||
navigation: Navigation,
|
navigation: Navigation,
|
||||||
|
storage_shed: storage.Shed,
|
||||||
|
notification: *Notification,
|
||||||
|
cookie_jar: storage.Cookie.Jar,
|
||||||
|
|
||||||
|
// These are the fields that get reset whenever the Session's page (the root) is reset.
|
||||||
|
factory: Factory,
|
||||||
|
|
||||||
|
page_arena: Allocator,
|
||||||
|
|
||||||
|
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
|
||||||
|
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
|
||||||
|
|
||||||
|
// Identity tracking for the main world. All main world contexts share this,
|
||||||
|
// ensuring object identity works across same-origin frames.
|
||||||
|
identity: js.Identity = .{},
|
||||||
|
|
||||||
|
// Shared resources for all pages in this session.
|
||||||
|
// These live for the duration of the page tree (root + frames).
|
||||||
|
arena_pool: *ArenaPool,
|
||||||
|
|
||||||
page: ?Page,
|
page: ?Page,
|
||||||
|
|
||||||
|
// Double buffer so that, as we process one list of queued navigations, new entries
|
||||||
|
// are added to the separate buffer. This ensures that we don't end up with
|
||||||
|
// endless navigation loops AND that we don't invalidate the list while iterating
|
||||||
|
// if a new entry gets appended
|
||||||
|
queued_navigation_1: std.ArrayList(*Page),
|
||||||
|
queued_navigation_2: std.ArrayList(*Page),
|
||||||
|
// pointer to either queued_navigation_1 or queued_navigation_2
|
||||||
|
queued_navigation: *std.ArrayList(*Page),
|
||||||
|
|
||||||
|
// Temporary buffer for about:blank navigations during processing.
|
||||||
|
// We process async navigations first (safe from re-entrance), then sync
|
||||||
|
// about:blank navigations (which may add to queued_navigation).
|
||||||
|
queued_queued_navigation: std.ArrayList(*Page),
|
||||||
|
|
||||||
|
page_id_gen: u32 = 0,
|
||||||
|
frame_id_gen: u32 = 0,
|
||||||
|
|
||||||
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
||||||
const allocator = browser.app.allocator;
|
const allocator = browser.app.allocator;
|
||||||
const arena = try browser.arena_pool.acquire();
|
const arena_pool = browser.arena_pool;
|
||||||
errdefer browser.arena_pool.release(arena);
|
|
||||||
|
|
||||||
const transfer_arena = try browser.arena_pool.acquire();
|
const arena = try arena_pool.acquire(.{ .debug = "Session" });
|
||||||
errdefer browser.arena_pool.release(transfer_arena);
|
errdefer arena_pool.release(arena);
|
||||||
|
|
||||||
|
const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" });
|
||||||
|
errdefer arena_pool.release(page_arena);
|
||||||
|
|
||||||
self.* = .{
|
self.* = .{
|
||||||
.page = null,
|
.page = null,
|
||||||
.arena = arena,
|
.arena = arena,
|
||||||
|
.arena_pool = arena_pool,
|
||||||
|
.page_arena = page_arena,
|
||||||
|
.factory = Factory.init(page_arena),
|
||||||
.history = .{},
|
.history = .{},
|
||||||
// The prototype (EventTarget) for Navigation is created when a Page is created.
|
// The prototype (EventTarget) for Navigation is created when a Page is created.
|
||||||
.navigation = .{ ._proto = undefined },
|
.navigation = .{ ._proto = undefined },
|
||||||
.storage_shed = .{},
|
.storage_shed = .{},
|
||||||
.browser = browser,
|
.browser = browser,
|
||||||
|
.queued_navigation = undefined,
|
||||||
|
.queued_navigation_1 = .{},
|
||||||
|
.queued_navigation_2 = .{},
|
||||||
|
.queued_queued_navigation = .{},
|
||||||
.notification = notification,
|
.notification = notification,
|
||||||
.transfer_arena = transfer_arena,
|
|
||||||
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
||||||
};
|
};
|
||||||
|
self.queued_navigation = &self.queued_navigation_1;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Session) void {
|
pub fn deinit(self: *Session) void {
|
||||||
if (self.page != null) {
|
if (self.page != null) {
|
||||||
self.removePage();
|
self.removePage();
|
||||||
}
|
}
|
||||||
const browser = self.browser;
|
|
||||||
|
|
||||||
self.cookie_jar.deinit();
|
self.cookie_jar.deinit();
|
||||||
self.storage_shed.deinit(browser.app.allocator);
|
|
||||||
browser.arena_pool.release(self.transfer_arena);
|
self.storage_shed.deinit(self.browser.app.allocator);
|
||||||
browser.arena_pool.release(self.arena);
|
self.arena_pool.release(self.page_arena);
|
||||||
|
self.arena_pool.release(self.arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: the caller is not the owner of the returned value,
|
// NOTE: the caller is not the owner of the returned value,
|
||||||
@@ -104,7 +143,7 @@ pub fn createPage(self: *Session) !*Page {
|
|||||||
|
|
||||||
self.page = @as(Page, undefined);
|
self.page = @as(Page, undefined);
|
||||||
const page = &self.page.?;
|
const page = &self.page.?;
|
||||||
try Page.init(page, self);
|
try Page.init(page, self.nextFrameId(), self, null);
|
||||||
|
|
||||||
// Creates a new NavigationEventTarget for this page.
|
// Creates a new NavigationEventTarget for this page.
|
||||||
try self.navigation.onNewPage(page);
|
try self.navigation.onNewPage(page);
|
||||||
@@ -124,28 +163,106 @@ pub fn removePage(self: *Session) void {
|
|||||||
self.notification.dispatch(.page_remove, .{});
|
self.notification.dispatch(.page_remove, .{});
|
||||||
lp.assert(self.page != null, "Session.removePage - page is null", .{});
|
lp.assert(self.page != null, "Session.removePage - page is null", .{});
|
||||||
|
|
||||||
self.page.?.deinit();
|
self.page.?.deinit(false);
|
||||||
self.page = null;
|
self.page = null;
|
||||||
|
|
||||||
self.navigation.onRemovePage();
|
self.navigation.onRemovePage();
|
||||||
|
self.resetPageResources();
|
||||||
|
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
log.debug(.browser, "remove page", .{});
|
log.debug(.browser, "remove page", .{});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const GetArenaOpts = struct {
|
||||||
|
debug: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
|
||||||
|
return self.arena_pool.acquire(.{ .debug = opts.debug });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn releaseArena(self: *Session, allocator: Allocator) void {
|
||||||
|
self.arena_pool.release(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
|
||||||
|
const key = key_ orelse {
|
||||||
|
var opaque_origin: [36]u8 = undefined;
|
||||||
|
@import("../id.zig").uuidv4(&opaque_origin);
|
||||||
|
// Origin.init will dupe opaque_origin. It's fine that this doesn't
|
||||||
|
// get added to self.origins. In fact, it further isolates it. When the
|
||||||
|
// context is freed, it'll call session.releaseOrigin which will free it.
|
||||||
|
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gop = try self.origins.getOrPut(self.arena, key);
|
||||||
|
if (gop.found_existing) {
|
||||||
|
const origin = gop.value_ptr.*;
|
||||||
|
origin.rc += 1;
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
errdefer _ = self.origins.remove(key);
|
||||||
|
|
||||||
|
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
|
||||||
|
gop.key_ptr.* = origin.key;
|
||||||
|
gop.value_ptr.* = origin;
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
|
||||||
|
const rc = origin.rc;
|
||||||
|
if (rc == 1) {
|
||||||
|
_ = self.origins.remove(origin.key);
|
||||||
|
origin.deinit(self.browser.app);
|
||||||
|
} else {
|
||||||
|
origin.rc = rc - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset page_arena and factory for a clean slate.
|
||||||
|
/// Called when root page is removed.
|
||||||
|
fn resetPageResources(self: *Session) void {
|
||||||
|
self.identity.deinit();
|
||||||
|
self.identity = .{};
|
||||||
|
|
||||||
|
if (comptime IS_DEBUG) {
|
||||||
|
std.debug.assert(self.origins.count() == 0);
|
||||||
|
}
|
||||||
|
// Defensive cleanup in case origins leaked
|
||||||
|
{
|
||||||
|
const app = self.browser.app;
|
||||||
|
var it = self.origins.valueIterator();
|
||||||
|
while (it.next()) |value| {
|
||||||
|
value.*.deinit(app);
|
||||||
|
}
|
||||||
|
self.origins = .empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.frame_id_gen = 0;
|
||||||
|
self.arena_pool.reset(self.page_arena, 64 * 1024);
|
||||||
|
self.factory = Factory.init(self.page_arena);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn replacePage(self: *Session) !*Page {
|
pub fn replacePage(self: *Session) !*Page {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
log.debug(.browser, "replace page", .{});
|
log.debug(.browser, "replace page", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
lp.assert(self.page != null, "Session.replacePage null page", .{});
|
lp.assert(self.page != null, "Session.replacePage null page", .{});
|
||||||
self.page.?.deinit();
|
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
|
||||||
|
|
||||||
|
var current = self.page.?;
|
||||||
|
const frame_id = current._frame_id;
|
||||||
|
current.deinit(true);
|
||||||
|
|
||||||
|
self.resetPageResources();
|
||||||
self.browser.env.memoryPressureNotification(.moderate);
|
self.browser.env.memoryPressureNotification(.moderate);
|
||||||
|
|
||||||
self.page = @as(Page, undefined);
|
self.page = @as(Page, undefined);
|
||||||
const page = &self.page.?;
|
const page = &self.page.?;
|
||||||
try Page.init(page, self);
|
try Page.init(page, frame_id, self, null);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,59 +270,222 @@ pub fn currentPage(self: *Session) ?*Page {
|
|||||||
return &(self.page orelse return null);
|
return &(self.page orelse return null);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const WaitResult = enum {
|
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
|
||||||
done,
|
const page = self.currentPage() orelse return null;
|
||||||
no_page,
|
return findPageBy(page, "_frame_id", frame_id);
|
||||||
cdp_socket,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
|
pub fn findPageById(self: *Session, id: u32) ?*Page {
|
||||||
while (true) {
|
const page = self.currentPage() orelse return null;
|
||||||
if (self.page) |*page| {
|
return findPageBy(page, "id", id);
|
||||||
switch (page.wait(wait_ms)) {
|
|
||||||
.done => {
|
|
||||||
if (page._queued_navigation == null) {
|
|
||||||
return .done;
|
|
||||||
}
|
}
|
||||||
self.processScheduledNavigation() catch return .done;
|
|
||||||
},
|
fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
|
||||||
else => |result| return result,
|
if (@field(page, field) == id) return page;
|
||||||
|
for (page.frames.items) |f| {
|
||||||
|
if (findPageBy(f, field, id)) |found| {
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
|
||||||
|
return Runner.init(self, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
||||||
|
const list = self.queued_navigation;
|
||||||
|
|
||||||
|
// Check if page is already queued
|
||||||
|
for (list.items) |existing| {
|
||||||
|
if (existing == page) {
|
||||||
|
// Already queued
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.append(self.arena, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn processQueuedNavigation(self: *Session) !void {
|
||||||
|
const navigations = self.queued_navigation;
|
||||||
|
if (self.queued_navigation == &self.queued_navigation_1) {
|
||||||
|
self.queued_navigation = &self.queued_navigation_2;
|
||||||
} else {
|
} else {
|
||||||
return .no_page;
|
self.queued_navigation = &self.queued_navigation_1;
|
||||||
}
|
}
|
||||||
// if we've successfull navigated, we'll give the new page another
|
|
||||||
// page.wait(wait_ms)
|
if (self.page.?._queued_navigation != null) {
|
||||||
|
// This is both an optimization and a simplification of sorts. If the
|
||||||
|
// root page is navigating, then we don't need to process any other
|
||||||
|
// navigation. Also, the navigation for the root page and for a frame
|
||||||
|
// is different enough that have two distinct code blocks is, imo,
|
||||||
|
// better. Yes, there will be duplication.
|
||||||
|
navigations.clearRetainingCapacity();
|
||||||
|
return self.processRootQueuedNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
const about_blank_queue = &self.queued_queued_navigation;
|
||||||
|
defer about_blank_queue.clearRetainingCapacity();
|
||||||
|
|
||||||
|
// First pass: process async navigations (non-about:blank)
|
||||||
|
for (navigations.items) |page| {
|
||||||
|
const qn = page._queued_navigation.?;
|
||||||
|
|
||||||
|
if (qn.is_about_blank) {
|
||||||
|
// Defer about:blank to second pass
|
||||||
|
try about_blank_queue.append(self.arena, page);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.processFrameNavigation(page, qn) catch |err| {
|
||||||
|
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
navigations.clearRetainingCapacity();
|
||||||
|
|
||||||
|
// Second pass: process synchronous navigations (about:blank)
|
||||||
|
// These may trigger new navigations which go into queued_navigation
|
||||||
|
for (about_blank_queue.items) |page| {
|
||||||
|
const qn = page._queued_navigation.?;
|
||||||
|
try self.processFrameNavigation(page, qn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: Remove any about:blank navigations that were queued during
|
||||||
|
// processing to prevent infinite loops. New navigations have been queued
|
||||||
|
// in the other buffer.
|
||||||
|
const new_navigations = self.queued_navigation;
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < new_navigations.items.len) {
|
||||||
|
const page = new_navigations.items[i];
|
||||||
|
if (page._queued_navigation) |qn| {
|
||||||
|
if (qn.is_about_blank) {
|
||||||
|
log.warn(.page, "recursive about blank", .{});
|
||||||
|
_ = self.queued_navigation.swapRemove(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn processScheduledNavigation(self: *Session) !void {
|
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
|
||||||
defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024);
|
lp.assert(page.parent != null, "root queued navigation", .{});
|
||||||
const url, const opts = blk: {
|
|
||||||
const qn = self.page.?._queued_navigation.?;
|
const iframe = page.iframe.?;
|
||||||
// qn might not be safe to use after self.removePage is called, hence
|
const parent = page.parent.?;
|
||||||
// this block;
|
|
||||||
const url = qn.url;
|
page._queued_navigation = null;
|
||||||
const opts = qn.opts;
|
defer self.releaseArena(qn.arena);
|
||||||
|
|
||||||
|
errdefer iframe._window = null;
|
||||||
|
|
||||||
|
const parent_notified = page._parent_notified;
|
||||||
|
if (parent_notified) {
|
||||||
|
// we already notified the parent that we had loaded
|
||||||
|
parent._pending_loads += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame_id = page._frame_id;
|
||||||
|
page.deinit(true);
|
||||||
|
page.* = undefined;
|
||||||
|
|
||||||
|
try Page.init(page, frame_id, self, parent);
|
||||||
|
errdefer {
|
||||||
|
for (parent.frames.items, 0..) |frame, i| {
|
||||||
|
if (frame == page) {
|
||||||
|
parent.frames_sorted = false;
|
||||||
|
_ = parent.frames.swapRemove(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent_notified) {
|
||||||
|
parent._pending_loads -= 1;
|
||||||
|
}
|
||||||
|
page.deinit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
page.iframe = iframe;
|
||||||
|
iframe._window = page.window;
|
||||||
|
|
||||||
|
page.navigate(qn.url, qn.opts) catch |err| {
|
||||||
|
log.err(.browser, "queued frame navigation error", .{ .err = err });
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn processRootQueuedNavigation(self: *Session) !void {
|
||||||
|
const current_page = &self.page.?;
|
||||||
|
const frame_id = current_page._frame_id;
|
||||||
|
|
||||||
|
// create a copy before the page is cleared
|
||||||
|
const qn = current_page._queued_navigation.?;
|
||||||
|
current_page._queued_navigation = null;
|
||||||
|
|
||||||
|
defer self.arena_pool.release(qn.arena);
|
||||||
|
|
||||||
// This was already aborted on the page, but it would be pretty
|
|
||||||
// bad if old requests went to the new page, so let's make double sure
|
|
||||||
self.browser.http_client.abort();
|
|
||||||
self.removePage();
|
self.removePage();
|
||||||
|
|
||||||
break :blk .{ url, opts };
|
self.page = @as(Page, undefined);
|
||||||
};
|
const new_page = &self.page.?;
|
||||||
|
try Page.init(new_page, frame_id, self, null);
|
||||||
|
|
||||||
const page = self.createPage() catch |err| {
|
// Creates a new NavigationEventTarget for this page.
|
||||||
log.err(.browser, "queued navigation page error", .{
|
try self.navigation.onNewPage(new_page);
|
||||||
.err = err,
|
|
||||||
.url = url,
|
|
||||||
});
|
|
||||||
return err;
|
|
||||||
};
|
|
||||||
|
|
||||||
page.navigate(url, opts) catch |err| {
|
// start JS env
|
||||||
log.err(.browser, "queued navigation error", .{ .err = err, .url = url });
|
// 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, new_page);
|
||||||
|
|
||||||
|
new_page.navigate(qn.url, qn.opts) catch |err| {
|
||||||
|
log.err(.browser, "queued navigation error", .{ .err = err });
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn nextFrameId(self: *Session) u32 {
|
||||||
|
const id = self.frame_id_gen +% 1;
|
||||||
|
self.frame_id_gen = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nextPageId(self: *Session) u32 {
|
||||||
|
const id = self.page_id_gen +% 1;
|
||||||
|
self.page_id_gen = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A type that has a finalizer can have its finalizer called one of two ways.
|
||||||
|
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
|
||||||
|
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
|
||||||
|
// page reset.
|
||||||
|
pub const FinalizerCallback = struct {
|
||||||
|
arena: Allocator,
|
||||||
|
session: *Session,
|
||||||
|
ptr: *anyopaque,
|
||||||
|
global: v8.Global,
|
||||||
|
identity: *js.Identity,
|
||||||
|
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
|
||||||
|
|
||||||
|
pub fn deinit(self: *FinalizerCallback) void {
|
||||||
|
self.zig_finalizer(self.ptr, self.session);
|
||||||
|
self.session.releaseArena(self.arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release this item from the identity tracking maps (called after finalizer runs from V8)
|
||||||
|
pub fn releaseIdentity(self: *FinalizerCallback) void {
|
||||||
|
const session = self.session;
|
||||||
|
const id = @intFromPtr(self.ptr);
|
||||||
|
|
||||||
|
if (self.identity.identity_map.fetchRemove(id)) |kv| {
|
||||||
|
var global = kv.value;
|
||||||
|
v8.v8__Global__Reset(&global);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = self.identity.finalizer_callbacks.remove(id);
|
||||||
|
|
||||||
|
session.releaseArena(self.arena);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
855
src/browser/StyleManager.zig
Normal file
855
src/browser/StyleManager.zig
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
// 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").String;
|
||||||
|
|
||||||
|
const Page = @import("Page.zig");
|
||||||
|
|
||||||
|
const CssParser = @import("css/Parser.zig");
|
||||||
|
const Element = @import("webapi/Element.zig");
|
||||||
|
|
||||||
|
const Selector = @import("webapi/selector/Selector.zig");
|
||||||
|
const SelectorParser = @import("webapi/selector/Parser.zig");
|
||||||
|
const SelectorList = @import("webapi/selector/List.zig");
|
||||||
|
|
||||||
|
const CSSStyleRule = @import("webapi/css/CSSStyleRule.zig");
|
||||||
|
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
|
||||||
|
const CSSStyleProperties = @import("webapi/css/CSSStyleProperties.zig");
|
||||||
|
const CSSStyleProperty = @import("webapi/css/CSSStyleDeclaration.zig").Property;
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
pub const VisibilityCache = std.AutoHashMapUnmanaged(*Element, bool);
|
||||||
|
pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
|
||||||
|
|
||||||
|
// Tracks visibility-relevant CSS rules from <style> elements.
|
||||||
|
// Rules are bucketed by their rightmost selector part for fast lookup.
|
||||||
|
const StyleManager = @This();
|
||||||
|
|
||||||
|
const Tag = Element.Tag;
|
||||||
|
const RuleList = std.MultiArrayList(VisibilityRule);
|
||||||
|
|
||||||
|
page: *Page,
|
||||||
|
|
||||||
|
arena: Allocator,
|
||||||
|
|
||||||
|
// Bucketed rules for fast lookup - keyed by rightmost selector part
|
||||||
|
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
|
||||||
|
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
|
||||||
|
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
|
||||||
|
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
|
||||||
|
|
||||||
|
// Document order counter for tie-breaking equal specificity
|
||||||
|
next_doc_order: u32 = 0,
|
||||||
|
|
||||||
|
// When true, rules need to be rebuilt
|
||||||
|
dirty: bool = false,
|
||||||
|
|
||||||
|
pub fn init(page: *Page) !StyleManager {
|
||||||
|
return .{
|
||||||
|
.page = page,
|
||||||
|
.arena = try page.getArena(.{ .debug = "StyleManager" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *StyleManager) void {
|
||||||
|
self.page.releaseArena(self.arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||||
|
if (sheet._css_rules) |css_rules| {
|
||||||
|
for (css_rules._rules.items) |rule| {
|
||||||
|
const style_rule = rule.is(CSSStyleRule) orelse continue;
|
||||||
|
try self.addRule(style_rule);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner_node = sheet.getOwnerNode() orelse return;
|
||||||
|
if (owner_node.is(Element.Html.Style)) |style| {
|
||||||
|
const text = try style.asNode().getTextContentAlloc(self.arena);
|
||||||
|
var it = CssParser.parseStylesheet(text);
|
||||||
|
while (it.next()) |parsed_rule| {
|
||||||
|
try self.addRawRule(parsed_rule.selector, parsed_rule.block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []const u8) !void {
|
||||||
|
if (selector_text.len == 0) return;
|
||||||
|
|
||||||
|
var props = VisibilityProperties{};
|
||||||
|
var it = CssParser.parseDeclarationsList(block_text);
|
||||||
|
while (it.next()) |decl| {
|
||||||
|
const name = decl.name;
|
||||||
|
const val = decl.value;
|
||||||
|
if (std.ascii.eqlIgnoreCase(name, "display")) {
|
||||||
|
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
|
||||||
|
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
|
||||||
|
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
|
||||||
|
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
|
||||||
|
props.opacity_zero = std.ascii.eqlIgnoreCase(val, "0");
|
||||||
|
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
|
||||||
|
props.pointer_events_none = std.ascii.eqlIgnoreCase(val, "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.isRelevant()) return;
|
||||||
|
|
||||||
|
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
|
||||||
|
for (selectors) |selector| {
|
||||||
|
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
|
||||||
|
const bucket_key = getBucketKey(rightmost) orelse continue;
|
||||||
|
const rule = VisibilityRule{
|
||||||
|
.props = props,
|
||||||
|
.selector = selector,
|
||||||
|
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||||
|
};
|
||||||
|
self.next_doc_order += 1;
|
||||||
|
|
||||||
|
switch (bucket_key) {
|
||||||
|
.id => |id| {
|
||||||
|
const gop = try self.id_rules.getOrPut(self.arena, id);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.class => |class| {
|
||||||
|
const gop = try self.class_rules.getOrPut(self.arena, class);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.tag => |tag| {
|
||||||
|
const gop = try self.tag_rules.getOrPut(self.arena, tag);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.other => {
|
||||||
|
try self.other_rules.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sheetRemoved(self: *StyleManager) void {
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sheetModified(self: *StyleManager) void {
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the rule list from all document stylesheets.
|
||||||
|
/// Called lazily when dirty flag is set and rules are needed.
|
||||||
|
fn rebuildIfDirty(self: *StyleManager) !void {
|
||||||
|
if (!self.dirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dirty = false;
|
||||||
|
errdefer self.dirty = true;
|
||||||
|
const id_rules_count = self.id_rules.count();
|
||||||
|
const class_rules_count = self.class_rules.count();
|
||||||
|
const tag_rules_count = self.tag_rules.count();
|
||||||
|
const other_rules_count = self.other_rules.len;
|
||||||
|
|
||||||
|
self.page._session.arena_pool.resetRetain(self.arena);
|
||||||
|
|
||||||
|
self.next_doc_order = 0;
|
||||||
|
|
||||||
|
self.id_rules = .empty;
|
||||||
|
try self.id_rules.ensureTotalCapacity(self.arena, id_rules_count);
|
||||||
|
|
||||||
|
self.class_rules = .empty;
|
||||||
|
try self.class_rules.ensureTotalCapacity(self.arena, class_rules_count);
|
||||||
|
|
||||||
|
self.tag_rules = .empty;
|
||||||
|
try self.tag_rules.ensureTotalCapacity(self.arena, tag_rules_count);
|
||||||
|
|
||||||
|
self.other_rules = .{};
|
||||||
|
try self.other_rules.ensureTotalCapacity(self.arena, other_rules_count);
|
||||||
|
|
||||||
|
const sheets = self.page.document._style_sheets orelse return;
|
||||||
|
for (sheets._sheets.items) |sheet| {
|
||||||
|
self.parseSheet(sheet) catch |err| {
|
||||||
|
log.err(.browser, "StyleManager parseSheet", .{ .err = err });
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an element is hidden based on options.
|
||||||
|
// By default only checks display:none.
|
||||||
|
// Walks up the tree to check ancestors.
|
||||||
|
pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, options: CheckVisibilityOptions) bool {
|
||||||
|
self.rebuildIfDirty() catch return false;
|
||||||
|
|
||||||
|
var current: ?*Element = el;
|
||||||
|
|
||||||
|
while (current) |elem| {
|
||||||
|
// Check cache first (only when checking all properties for caching consistency)
|
||||||
|
if (cache) |c| {
|
||||||
|
if (c.get(elem)) |hidden| {
|
||||||
|
if (hidden) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = elem.parentElement();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = self.isElementHidden(elem, options);
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
if (cache) |c| {
|
||||||
|
c.put(self.page.call_arena, elem, hidden) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = elem.parentElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single element (not ancestors) is hidden.
|
||||||
|
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
|
||||||
|
// Track best match per property (value + priority)
|
||||||
|
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
|
||||||
|
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
|
||||||
|
var display_none: ?bool = null;
|
||||||
|
var display_priority: u64 = 0;
|
||||||
|
|
||||||
|
var visibility_hidden: ?bool = null;
|
||||||
|
var visibility_priority: u64 = 0;
|
||||||
|
|
||||||
|
var opacity_zero: ?bool = null;
|
||||||
|
var opacity_priority: u64 = 0;
|
||||||
|
|
||||||
|
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
|
||||||
|
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
|
||||||
|
if (property._value.eql(comptime .wrap("none"))) {
|
||||||
|
return true; // Early exit for hiding value
|
||||||
|
}
|
||||||
|
display_none = false;
|
||||||
|
display_priority = INLINE_PRIORITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.check_visibility) {
|
||||||
|
if (getInlineStyleProperty(el, comptime .wrap("visibility"), self.page)) |property| {
|
||||||
|
if (property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
visibility_hidden = false;
|
||||||
|
visibility_priority = INLINE_PRIORITY;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This can't be beat. Setting this means that, when checking rules
|
||||||
|
// we no longer have to check if options.check_visibility is enabled.
|
||||||
|
// We can just compare the priority.
|
||||||
|
visibility_priority = INLINE_PRIORITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.check_opacity) {
|
||||||
|
if (getInlineStyleProperty(el, comptime .wrap("opacity"), self.page)) |property| {
|
||||||
|
if (property._value.eql(comptime .wrap("0"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
opacity_zero = false;
|
||||||
|
opacity_priority = INLINE_PRIORITY;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
opacity_priority = INLINE_PRIORITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check a single rule
|
||||||
|
const Ctx = struct {
|
||||||
|
display_none: *?bool,
|
||||||
|
display_priority: *u64,
|
||||||
|
visibility_hidden: *?bool,
|
||||||
|
visibility_priority: *u64,
|
||||||
|
opacity_zero: *?bool,
|
||||||
|
opacity_priority: *u64,
|
||||||
|
el: *Element,
|
||||||
|
page: *Page,
|
||||||
|
|
||||||
|
fn checkRules(ctx: @This(), rules: *const RuleList) void {
|
||||||
|
if (ctx.display_priority.* == INLINE_PRIORITY and
|
||||||
|
ctx.visibility_priority.* == INLINE_PRIORITY and
|
||||||
|
ctx.opacity_priority.* == INLINE_PRIORITY)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorities = rules.items(.priority);
|
||||||
|
const props_list = rules.items(.props);
|
||||||
|
const selectors = rules.items(.selector);
|
||||||
|
|
||||||
|
for (priorities, props_list, selectors) |p, props, selector| {
|
||||||
|
// Fast skip using packed u64 priority
|
||||||
|
if (p <= ctx.display_priority.* and p <= ctx.visibility_priority.* and p <= ctx.opacity_priority.*) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic for property dominance
|
||||||
|
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
|
||||||
|
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
|
||||||
|
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
|
||||||
|
|
||||||
|
if (dominated) continue;
|
||||||
|
|
||||||
|
if (matchesSelector(ctx.el, selector, ctx.page)) {
|
||||||
|
// Update best priorities
|
||||||
|
if (props.display_none != null and p > ctx.display_priority.*) {
|
||||||
|
ctx.display_none.* = props.display_none;
|
||||||
|
ctx.display_priority.* = p;
|
||||||
|
}
|
||||||
|
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
|
||||||
|
ctx.visibility_hidden.* = props.visibility_hidden;
|
||||||
|
ctx.visibility_priority.* = p;
|
||||||
|
}
|
||||||
|
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
|
||||||
|
ctx.opacity_zero.* = props.opacity_zero;
|
||||||
|
ctx.opacity_priority.* = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ctx = Ctx{
|
||||||
|
.display_none = &display_none,
|
||||||
|
.display_priority = &display_priority,
|
||||||
|
.visibility_hidden = &visibility_hidden,
|
||||||
|
.visibility_priority = &visibility_priority,
|
||||||
|
.opacity_zero = &opacity_zero,
|
||||||
|
.opacity_priority = &opacity_priority,
|
||||||
|
.el = el,
|
||||||
|
.page = self.page,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
||||||
|
if (self.id_rules.get(id)) |rules| {
|
||||||
|
ctx.checkRules(&rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
|
||||||
|
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
|
||||||
|
while (it.next()) |class| {
|
||||||
|
if (self.class_rules.get(class)) |rules| {
|
||||||
|
ctx.checkRules(&rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.tag_rules.get(el.getTag())) |rules| {
|
||||||
|
ctx.checkRules(&rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.checkRules(&self.other_rules);
|
||||||
|
|
||||||
|
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an element has pointer-events:none.
|
||||||
|
/// Checks inline style first - if set, skips stylesheet lookup.
|
||||||
|
/// Walks up the tree to check ancestors.
|
||||||
|
pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerEventsCache) bool {
|
||||||
|
self.rebuildIfDirty() catch return false;
|
||||||
|
|
||||||
|
var current: ?*Element = el;
|
||||||
|
|
||||||
|
while (current) |elem| {
|
||||||
|
// Check cache first
|
||||||
|
if (cache) |c| {
|
||||||
|
if (c.get(elem)) |pe_none| {
|
||||||
|
if (pe_none) return true;
|
||||||
|
current = elem.parentElement();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pe_none = self.elementHasPointerEventsNone(elem);
|
||||||
|
|
||||||
|
if (cache) |c| {
|
||||||
|
c.put(self.page.call_arena, elem, pe_none) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pe_none) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = elem.parentElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single element (not ancestors) has pointer-events:none.
|
||||||
|
fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
|
||||||
|
const page = self.page;
|
||||||
|
|
||||||
|
// Check inline style first
|
||||||
|
if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| {
|
||||||
|
if (property._value.eql(comptime .wrap("none"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: ?bool = null;
|
||||||
|
var best_priority: u64 = 0;
|
||||||
|
|
||||||
|
// Helper to check a single rule
|
||||||
|
const checkRules = struct {
|
||||||
|
fn check(rules: *const RuleList, res: *?bool, current_priority: *u64, elem: *Element, p: *Page) void {
|
||||||
|
if (current_priority.* == INLINE_PRIORITY) return;
|
||||||
|
|
||||||
|
const priorities = rules.items(.priority);
|
||||||
|
const props_list = rules.items(.props);
|
||||||
|
const selectors = rules.items(.selector);
|
||||||
|
|
||||||
|
for (priorities, props_list, selectors) |priority, props, selector| {
|
||||||
|
if (priority <= current_priority.*) continue;
|
||||||
|
if (props.pointer_events_none == null) continue;
|
||||||
|
|
||||||
|
if (matchesSelector(elem, selector, p)) {
|
||||||
|
res.* = props.pointer_events_none;
|
||||||
|
current_priority.* = priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.check;
|
||||||
|
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
||||||
|
if (self.id_rules.get(id)) |rules| {
|
||||||
|
checkRules(&rules, &result, &best_priority, el, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
|
||||||
|
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
|
||||||
|
while (it.next()) |class| {
|
||||||
|
if (self.class_rules.get(class)) |rules| {
|
||||||
|
checkRules(&rules, &result, &best_priority, el, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.tag_rules.get(el.getTag())) |rules| {
|
||||||
|
checkRules(&rules, &result, &best_priority, el, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRules(&self.other_rules, &result, &best_priority, el, page);
|
||||||
|
|
||||||
|
return result orelse false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts visibility-relevant rules from a CSS rule.
|
||||||
|
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
|
||||||
|
// Buckets rules by their rightmost selector part for fast lookup.
|
||||||
|
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
|
||||||
|
const selector_text = style_rule._selector_text;
|
||||||
|
if (selector_text.len == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the rule has visibility-relevant properties
|
||||||
|
const style = style_rule._style orelse return;
|
||||||
|
const props = extractVisibilityProperties(style);
|
||||||
|
if (!props.isRelevant()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the selector list
|
||||||
|
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
|
||||||
|
if (selectors.len == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create one rule per selector - each has its own specificity
|
||||||
|
// e.g., "#id, .class { display: none }" becomes two rules with different specificities
|
||||||
|
for (selectors) |selector| {
|
||||||
|
// Get the rightmost compound (last segment, or first if no segments)
|
||||||
|
const rightmost = if (selector.segments.len > 0)
|
||||||
|
selector.segments[selector.segments.len - 1].compound
|
||||||
|
else
|
||||||
|
selector.first;
|
||||||
|
|
||||||
|
// Find the bucketing key from rightmost compound
|
||||||
|
const bucket_key = getBucketKey(rightmost) orelse continue; // skip if dynamic pseudo-class
|
||||||
|
|
||||||
|
const rule = VisibilityRule{
|
||||||
|
.props = props,
|
||||||
|
.selector = selector,
|
||||||
|
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||||
|
};
|
||||||
|
self.next_doc_order += 1;
|
||||||
|
|
||||||
|
// Add to appropriate bucket
|
||||||
|
switch (bucket_key) {
|
||||||
|
.id => |id| {
|
||||||
|
const gop = try self.id_rules.getOrPut(self.arena, id);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.class => |class| {
|
||||||
|
const gop = try self.class_rules.getOrPut(self.arena, class);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.tag => |tag| {
|
||||||
|
const gop = try self.tag_rules.getOrPut(self.arena, tag);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||||
|
try gop.value_ptr.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
.other => {
|
||||||
|
try self.other_rules.append(self.arena, rule);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BucketKey = union(enum) {
|
||||||
|
id: []const u8,
|
||||||
|
class: []const u8,
|
||||||
|
tag: Tag,
|
||||||
|
other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Returns the best bucket key for a compound selector, or null if it contains
|
||||||
|
/// a dynamic pseudo-class we should skip (hover, active, focus, etc.)
|
||||||
|
/// Priority: id > class > tag > other
|
||||||
|
fn getBucketKey(compound: Selector.Compound) ?BucketKey {
|
||||||
|
var best_key: BucketKey = .other;
|
||||||
|
|
||||||
|
for (compound.parts) |part| {
|
||||||
|
switch (part) {
|
||||||
|
.id => |id| {
|
||||||
|
best_key = .{ .id = id };
|
||||||
|
},
|
||||||
|
.class => |class| {
|
||||||
|
if (best_key != .id) {
|
||||||
|
best_key = .{ .class = class };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.tag => |tag| {
|
||||||
|
if (best_key == .other) {
|
||||||
|
best_key = .{ .tag = tag };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.tag_name => {
|
||||||
|
// Custom tag - put in other bucket (can't efficiently look up)
|
||||||
|
// Keep current best_key if we have something better
|
||||||
|
},
|
||||||
|
.pseudo_class => |pc| {
|
||||||
|
// Skip dynamic pseudo-classes - they depend on interaction state
|
||||||
|
switch (pc) {
|
||||||
|
.hover, .active, .focus, .focus_within, .focus_visible, .visited, .target => {
|
||||||
|
return null; // Skip this selector entirely
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.universal, .attribute => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts visibility-relevant properties from a style declaration.
|
||||||
|
fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties {
|
||||||
|
var props = VisibilityProperties{};
|
||||||
|
const decl = style.asCSSStyleDeclaration();
|
||||||
|
|
||||||
|
if (decl.findProperty(comptime .wrap("display"))) |property| {
|
||||||
|
props.display_none = property._value.eql(comptime .wrap("none"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
|
||||||
|
props.visibility_hidden = property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decl.findProperty(comptime .wrap("opacity"))) |property| {
|
||||||
|
props.opacity_zero = property._value.eql(comptime .wrap("0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decl.findProperty(.wrap("pointer-events"))) |property| {
|
||||||
|
props.pointer_events_none = property._value.eql(comptime .wrap("none"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computes CSS specificity for a selector.
|
||||||
|
// Returns packed value: (id_count << 20) | (class_count << 10) | element_count
|
||||||
|
pub fn computeSpecificity(selector: Selector.Selector) u32 {
|
||||||
|
var ids: u32 = 0;
|
||||||
|
var classes: u32 = 0; // includes classes, attributes, pseudo-classes
|
||||||
|
var elements: u32 = 0; // includes elements, pseudo-elements
|
||||||
|
|
||||||
|
// Count specificity for first compound
|
||||||
|
countCompoundSpecificity(selector.first, &ids, &classes, &elements);
|
||||||
|
|
||||||
|
// Count specificity for subsequent segments
|
||||||
|
for (selector.segments) |segment| {
|
||||||
|
countCompoundSpecificity(segment.compound, &ids, &classes, &elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack into single u32: (ids << 20) | (classes << 10) | elements
|
||||||
|
// This gives us 10 bits each, supporting up to 1023 of each type
|
||||||
|
return (@as(u32, @min(ids, 1023)) << 20) | (@as(u32, @min(classes, 1023)) << 10) | @min(elements, 1023);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn countCompoundSpecificity(compound: Selector.Compound, ids: *u32, classes: *u32, elements: *u32) void {
|
||||||
|
for (compound.parts) |part| {
|
||||||
|
switch (part) {
|
||||||
|
.id => ids.* += 1,
|
||||||
|
.class => classes.* += 1,
|
||||||
|
.tag, .tag_name => elements.* += 1,
|
||||||
|
.universal => {}, // zero specificity
|
||||||
|
.attribute => classes.* += 1,
|
||||||
|
.pseudo_class => |pc| {
|
||||||
|
switch (pc) {
|
||||||
|
// :where() has zero specificity
|
||||||
|
.where => {},
|
||||||
|
// :not(), :is(), :has() take specificity of their most specific argument
|
||||||
|
.not, .is, .has => |nested| {
|
||||||
|
var max_nested: u32 = 0;
|
||||||
|
for (nested) |nested_sel| {
|
||||||
|
const spec = computeSpecificity(nested_sel);
|
||||||
|
if (spec > max_nested) max_nested = spec;
|
||||||
|
}
|
||||||
|
// Unpack and add to our counts
|
||||||
|
ids.* += (max_nested >> 20) & 0x3FF;
|
||||||
|
classes.* += (max_nested >> 10) & 0x3FF;
|
||||||
|
elements.* += max_nested & 0x3FF;
|
||||||
|
},
|
||||||
|
// All other pseudo-classes count as class-level specificity
|
||||||
|
else => classes.* += 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matchesSelector(el: *Element, selector: Selector.Selector, page: *Page) bool {
|
||||||
|
const node = el.asNode();
|
||||||
|
return SelectorList.matches(node, selector, node, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VisibilityProperties = struct {
|
||||||
|
display_none: ?bool = null,
|
||||||
|
visibility_hidden: ?bool = null,
|
||||||
|
opacity_zero: ?bool = null,
|
||||||
|
pointer_events_none: ?bool = null,
|
||||||
|
|
||||||
|
// returne true if any field in VisibilityProperties is not null
|
||||||
|
fn isRelevant(self: VisibilityProperties) bool {
|
||||||
|
return self.display_none != null or
|
||||||
|
self.visibility_hidden != null or
|
||||||
|
self.opacity_zero != null or
|
||||||
|
self.pointer_events_none != null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const VisibilityRule = struct {
|
||||||
|
selector: Selector.Selector, // Single selector, not a list
|
||||||
|
props: VisibilityProperties,
|
||||||
|
|
||||||
|
// Packed priority: (specificity << 32) | doc_order
|
||||||
|
priority: u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckVisibilityOptions = struct {
|
||||||
|
check_opacity: bool = false,
|
||||||
|
check_visibility: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline styles always win over stylesheets - use max u64 as sentinel
|
||||||
|
const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
|
||||||
|
|
||||||
|
fn getInlineStyleProperty(el: *Element, property_name: String, page: *Page) ?*CSSStyleProperty {
|
||||||
|
const style = el.getOrCreateStyle(page) catch |err| {
|
||||||
|
log.err(.browser, "StyleManager getOrCreateStyle", .{ .err = err });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return style.asCSSStyleDeclaration().findProperty(property_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
test "StyleManager: computeSpecificity: element selector" {
|
||||||
|
// div -> (0, 0, 1)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .tag = .div }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: class selector" {
|
||||||
|
// .foo -> (0, 1, 0)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: id selector" {
|
||||||
|
// #bar -> (1, 0, 0)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .id = "bar" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1 << 20, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: combined selector" {
|
||||||
|
// div.foo#bar -> (1, 1, 1)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .tag = .div },
|
||||||
|
.{ .class = "foo" },
|
||||||
|
.{ .id = "bar" },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual((1 << 20) | (1 << 10) | 1, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: universal selector" {
|
||||||
|
// * -> (0, 0, 0)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.universal} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(0, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: multiple classes" {
|
||||||
|
// .a.b.c -> (0, 3, 0)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .class = "a" },
|
||||||
|
.{ .class = "b" },
|
||||||
|
.{ .class = "c" },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(3 << 10, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: descendant combinator" {
|
||||||
|
// div span -> (0, 0, 2)
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .tag = .div }} },
|
||||||
|
.segments = &.{
|
||||||
|
.{ .combinator = .descendant, .compound = .{ .parts = &.{.{ .tag = .span }} } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(2, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: :where() has zero specificity" {
|
||||||
|
// :where(.foo) -> (0, 0, 0) regardless of what's inside
|
||||||
|
const inner_selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .pseudo_class = .{ .where = &.{inner_selector} } },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(0, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: :not() takes inner specificity" {
|
||||||
|
// :not(.foo) -> (0, 1, 0) - takes specificity of .foo
|
||||||
|
const inner_selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .pseudo_class = .{ .not = &.{inner_selector} } },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: :is() takes most specific inner" {
|
||||||
|
// :is(.foo, #bar) -> (1, 0, 0) - takes the most specific (#bar)
|
||||||
|
const class_selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
const id_selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{.{ .id = "bar" }} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .pseudo_class = .{ .is = &.{ class_selector, id_selector } } },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1 << 20, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: computeSpecificity: pseudo-class (general)" {
|
||||||
|
// :hover -> (0, 1, 0) - pseudo-classes count as class-level
|
||||||
|
const selector = Selector.Selector{
|
||||||
|
.first = .{ .parts = &.{
|
||||||
|
.{ .pseudo_class = .hover },
|
||||||
|
} },
|
||||||
|
.segments = &.{},
|
||||||
|
};
|
||||||
|
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "StyleManager: document order tie-breaking" {
|
||||||
|
// When specificity is equal, higher doc_order (later in document) wins
|
||||||
|
const beats = struct {
|
||||||
|
fn f(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
|
||||||
|
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
|
||||||
|
}
|
||||||
|
}.f;
|
||||||
|
|
||||||
|
// Higher specificity always wins regardless of doc_order
|
||||||
|
try testing.expect(beats(2, 0, 1, 10));
|
||||||
|
try testing.expect(!beats(1, 10, 2, 0));
|
||||||
|
|
||||||
|
// Equal specificity: higher doc_order wins
|
||||||
|
try testing.expect(beats(1, 5, 1, 3)); // doc_order 5 > 3
|
||||||
|
try testing.expect(!beats(1, 3, 1, 5)); // doc_order 3 < 5
|
||||||
|
|
||||||
|
// Equal specificity and doc_order: no win
|
||||||
|
try testing.expect(!beats(1, 5, 1, 5));
|
||||||
|
}
|
||||||
@@ -20,44 +20,61 @@ const std = @import("std");
|
|||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
const ResolveOpts = struct {
|
const ResolveOpts = struct {
|
||||||
|
encode: bool = false,
|
||||||
always_dupe: bool = false,
|
always_dupe: bool = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// path is anytype, so that it can be used with both []const u8 and [:0]const u8
|
// path is anytype, so that it can be used with both []const u8 and [:0]const u8
|
||||||
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
|
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
|
||||||
const PT = @TypeOf(path);
|
const PT = @TypeOf(path);
|
||||||
if (base.len == 0 or isCompleteHTTPUrl(path)) {
|
if (base.len == 0 or isCompleteHTTPUrl(path)) {
|
||||||
if (comptime opts.always_dupe or !isNullTerminated(PT)) {
|
if (comptime opts.always_dupe or !isNullTerminated(PT)) {
|
||||||
return allocator.dupeZ(u8, path);
|
const duped = try allocator.dupeZ(u8, path);
|
||||||
|
return processResolved(allocator, duped, opts);
|
||||||
|
}
|
||||||
|
if (comptime opts.encode) {
|
||||||
|
return processResolved(allocator, path, opts);
|
||||||
}
|
}
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.len == 0) {
|
if (path.len == 0) {
|
||||||
if (comptime opts.always_dupe) {
|
if (comptime opts.always_dupe) {
|
||||||
return allocator.dupeZ(u8, base);
|
const duped = try allocator.dupeZ(u8, base);
|
||||||
|
return processResolved(allocator, duped, opts);
|
||||||
|
}
|
||||||
|
if (comptime opts.encode) {
|
||||||
|
return processResolved(allocator, base, opts);
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path[0] == '?') {
|
if (path[0] == '?') {
|
||||||
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
|
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
|
||||||
return std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
|
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
|
||||||
|
return processResolved(allocator, result, opts);
|
||||||
}
|
}
|
||||||
if (path[0] == '#') {
|
if (path[0] == '#') {
|
||||||
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
|
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
|
||||||
return std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
|
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
|
||||||
|
return processResolved(allocator, result, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.startsWith(u8, path, "//")) {
|
if (std.mem.startsWith(u8, path, "//")) {
|
||||||
// network-path reference
|
// network-path reference
|
||||||
const index = std.mem.indexOfScalar(u8, base, ':') orelse {
|
const index = std.mem.indexOfScalar(u8, base, ':') orelse {
|
||||||
if (comptime isNullTerminated(PT)) {
|
if (comptime isNullTerminated(PT)) {
|
||||||
|
if (comptime opts.encode) {
|
||||||
|
return processResolved(allocator, path, opts);
|
||||||
|
}
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return allocator.dupeZ(u8, path);
|
const duped = try allocator.dupeZ(u8, path);
|
||||||
|
return processResolved(allocator, duped, opts);
|
||||||
};
|
};
|
||||||
const protocol = base[0 .. index + 1];
|
const protocol = base[0 .. index + 1];
|
||||||
return std.mem.joinZ(allocator, "", &.{ protocol, path });
|
const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
|
||||||
|
return processResolved(allocator, result, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheme_end = std.mem.indexOf(u8, base, "://");
|
const scheme_end = std.mem.indexOf(u8, base, "://");
|
||||||
@@ -65,7 +82,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
|
|||||||
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
|
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
|
||||||
|
|
||||||
if (path[0] == '/') {
|
if (path[0] == '/') {
|
||||||
return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
|
const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
|
||||||
|
return processResolved(allocator, result, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized_base: []const u8 = base[0..path_start];
|
var normalized_base: []const u8 = base[0..path_start];
|
||||||
@@ -127,7 +145,124 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
|
|||||||
|
|
||||||
// we always have an extra space
|
// we always have an extra space
|
||||||
out[out_i] = 0;
|
out[out_i] = 0;
|
||||||
return out[0..out_i :0];
|
return processResolved(allocator, out[0..out_i :0], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn processResolved(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 {
|
||||||
|
if (!comptime opts.encode) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return ensureEncoded(allocator, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
|
||||||
|
const scheme_end = std.mem.indexOf(u8, url, "://");
|
||||||
|
const authority_start = if (scheme_end) |end| end + 3 else 0;
|
||||||
|
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;
|
||||||
|
|
||||||
|
const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?');
|
||||||
|
const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#');
|
||||||
|
|
||||||
|
const path_end = query_start orelse fragment_start orelse url.len;
|
||||||
|
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
|
||||||
|
|
||||||
|
const path_to_encode = url[path_start..path_end];
|
||||||
|
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
|
||||||
|
|
||||||
|
const encoded_query = if (query_start) |qs| blk: {
|
||||||
|
const query_to_encode = url[qs + 1 .. query_end];
|
||||||
|
const encoded = try percentEncodeSegment(allocator, query_to_encode, .query);
|
||||||
|
break :blk encoded;
|
||||||
|
} else null;
|
||||||
|
|
||||||
|
const encoded_fragment = if (fragment_start) |fs| blk: {
|
||||||
|
const fragment_to_encode = url[fs + 1 ..];
|
||||||
|
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query);
|
||||||
|
break :blk encoded;
|
||||||
|
} else null;
|
||||||
|
|
||||||
|
if (encoded_path.ptr == path_to_encode.ptr and
|
||||||
|
(encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and
|
||||||
|
(encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr))
|
||||||
|
{
|
||||||
|
// nothing has changed
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20);
|
||||||
|
try buf.appendSlice(allocator, url[0..path_start]);
|
||||||
|
try buf.appendSlice(allocator, encoded_path);
|
||||||
|
if (encoded_query) |eq| {
|
||||||
|
try buf.append(allocator, '?');
|
||||||
|
try buf.appendSlice(allocator, eq);
|
||||||
|
}
|
||||||
|
if (encoded_fragment) |ef| {
|
||||||
|
try buf.append(allocator, '#');
|
||||||
|
try buf.appendSlice(allocator, ef);
|
||||||
|
}
|
||||||
|
try buf.append(allocator, 0);
|
||||||
|
return buf.items[0 .. buf.items.len - 1 :0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EncodeSet = enum { path, query, userinfo, fragment };
|
||||||
|
|
||||||
|
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
|
||||||
|
// Check if encoding is needed
|
||||||
|
var needs_encoding = false;
|
||||||
|
for (segment) |c| {
|
||||||
|
if (shouldPercentEncode(c, encode_set)) {
|
||||||
|
needs_encoding = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!needs_encoding) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf = try std.ArrayList(u8).initCapacity(allocator, segment.len + 10);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < segment.len) : (i += 1) {
|
||||||
|
const c = segment[i];
|
||||||
|
|
||||||
|
// Check if this is an already-encoded sequence (%XX)
|
||||||
|
if (c == '%' and i + 2 < segment.len) {
|
||||||
|
const end = i + 2;
|
||||||
|
const h1 = segment[i + 1];
|
||||||
|
const h2 = segment[end];
|
||||||
|
if (std.ascii.isHex(h1) and std.ascii.isHex(h2)) {
|
||||||
|
try buf.appendSlice(allocator, segment[i .. end + 1]);
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldPercentEncode(c, encode_set)) {
|
||||||
|
try buf.writer(allocator).print("%{X:0>2}", .{c});
|
||||||
|
} else {
|
||||||
|
try buf.append(allocator, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
|
||||||
|
return switch (c) {
|
||||||
|
// Unreserved characters (RFC 3986)
|
||||||
|
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
|
||||||
|
// sub-delims allowed in path/query but some must be encoded in userinfo
|
||||||
|
'!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
|
||||||
|
';', '=' => encode_set == .userinfo,
|
||||||
|
// Separators: userinfo must encode these
|
||||||
|
'/', ':', '@' => encode_set == .userinfo,
|
||||||
|
// '?' is allowed in queries only
|
||||||
|
'?' => encode_set != .query,
|
||||||
|
// '#' is allowed in fragments only
|
||||||
|
'#' => encode_set != .fragment,
|
||||||
|
// Everything else needs encoding (including space)
|
||||||
|
else => true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isNullTerminated(comptime value: type) bool {
|
fn isNullTerminated(comptime value: type) bool {
|
||||||
@@ -144,6 +279,11 @@ pub fn isCompleteHTTPUrl(url: []const u8) bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blob: and data: URLs are complete but don't follow scheme:// pattern
|
||||||
|
if (std.mem.startsWith(u8, url, "blob:") or std.mem.startsWith(u8, url, "data:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's a scheme (protocol) ending with ://
|
// Check if there's a scheme (protocol) ending with ://
|
||||||
const colon_pos = std.mem.indexOfScalar(u8, url, ':') orelse return false;
|
const colon_pos = std.mem.indexOfScalar(u8, url, ':') orelse return false;
|
||||||
|
|
||||||
@@ -185,14 +325,22 @@ pub fn getPassword(raw: [:0]const u8) []const u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn getPathname(raw: [:0]const u8) []const u8 {
|
pub fn getPathname(raw: [:0]const u8) []const u8 {
|
||||||
const protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0;
|
const protocol_end = std.mem.indexOf(u8, raw, "://");
|
||||||
const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len;
|
|
||||||
|
// Handle scheme:path URLs like about:blank (no "://")
|
||||||
|
if (protocol_end == null) {
|
||||||
|
const colon_pos = std.mem.indexOfScalar(u8, raw, ':') orelse return "";
|
||||||
|
const path = raw[colon_pos + 1 ..];
|
||||||
|
const query_or_hash = std.mem.indexOfAny(u8, path, "?#") orelse path.len;
|
||||||
|
return path[0..query_or_hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
const path_start = std.mem.indexOfScalarPos(u8, raw, protocol_end.? + 3, '/') orelse raw.len;
|
||||||
|
|
||||||
const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len;
|
const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len;
|
||||||
|
|
||||||
if (path_start >= query_or_hash_start) {
|
if (path_start >= query_or_hash_start) {
|
||||||
if (std.mem.indexOf(u8, raw, "://") != null) return "/";
|
return "/";
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return raw[path_start..query_or_hash_start];
|
return raw[path_start..query_or_hash_start];
|
||||||
@@ -384,7 +532,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
|||||||
const search = getSearch(current);
|
const search = getSearch(current);
|
||||||
const hash = getHash(current);
|
const hash = getHash(current);
|
||||||
|
|
||||||
// Check if the host includes a port
|
// Check if the new value includes a port
|
||||||
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
|
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
|
||||||
const clean_host = if (colon_pos) |pos| blk: {
|
const clean_host = if (colon_pos) |pos| blk: {
|
||||||
const port_str = value[pos + 1 ..];
|
const port_str = value[pos + 1 ..];
|
||||||
@@ -396,7 +544,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
|||||||
break :blk value[0..pos];
|
break :blk value[0..pos];
|
||||||
}
|
}
|
||||||
break :blk value;
|
break :blk value;
|
||||||
} else value;
|
} else blk: {
|
||||||
|
// No port in new value - preserve existing port
|
||||||
|
const current_port = getPort(current);
|
||||||
|
if (current_port.len > 0) {
|
||||||
|
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port });
|
||||||
|
}
|
||||||
|
break :blk value;
|
||||||
|
};
|
||||||
|
|
||||||
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
|
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
|
||||||
}
|
}
|
||||||
@@ -414,6 +569,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato
|
|||||||
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
|
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
|
||||||
const hostname = getHostname(current);
|
const hostname = getHostname(current);
|
||||||
const protocol = getProtocol(current);
|
const protocol = getProtocol(current);
|
||||||
|
const pathname = getPathname(current);
|
||||||
|
const search = getSearch(current);
|
||||||
|
const hash = getHash(current);
|
||||||
|
|
||||||
// Handle null or default ports
|
// Handle null or default ports
|
||||||
const new_host = if (value) |port_str| blk: {
|
const new_host = if (value) |port_str| blk: {
|
||||||
@@ -430,7 +588,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator)
|
|||||||
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
|
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
|
||||||
} else hostname;
|
} else hostname;
|
||||||
|
|
||||||
return setHost(current, new_host, allocator);
|
return buildUrl(allocator, protocol, new_host, pathname, search, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||||
@@ -439,11 +597,13 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato
|
|||||||
const search = getSearch(current);
|
const search = getSearch(current);
|
||||||
const hash = getHash(current);
|
const hash = getHash(current);
|
||||||
|
|
||||||
|
const encoded = try percentEncodeSegment(allocator, value, .path);
|
||||||
|
|
||||||
// Add / prefix if not present and value is not empty
|
// Add / prefix if not present and value is not empty
|
||||||
const pathname = if (value.len > 0 and value[0] != '/')
|
const pathname = if (encoded.len > 0 and encoded[0] != '/')
|
||||||
try std.fmt.allocPrint(allocator, "/{s}", .{value})
|
try std.fmt.allocPrint(allocator, "/{s}", .{encoded})
|
||||||
else
|
else
|
||||||
value;
|
encoded;
|
||||||
|
|
||||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||||
}
|
}
|
||||||
@@ -454,11 +614,13 @@ pub fn setSearch(current: [:0]const u8, value: []const u8, allocator: Allocator)
|
|||||||
const pathname = getPathname(current);
|
const pathname = getPathname(current);
|
||||||
const hash = getHash(current);
|
const hash = getHash(current);
|
||||||
|
|
||||||
|
const encoded = try percentEncodeSegment(allocator, value, .query);
|
||||||
|
|
||||||
// Add ? prefix if not present and value is not empty
|
// Add ? prefix if not present and value is not empty
|
||||||
const search = if (value.len > 0 and value[0] != '?')
|
const search = if (encoded.len > 0 and value[0] != '?')
|
||||||
try std.fmt.allocPrint(allocator, "?{s}", .{value})
|
try std.fmt.allocPrint(allocator, "?{s}", .{encoded})
|
||||||
else
|
else
|
||||||
value;
|
encoded;
|
||||||
|
|
||||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||||
}
|
}
|
||||||
@@ -469,15 +631,75 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
|||||||
const pathname = getPathname(current);
|
const pathname = getPathname(current);
|
||||||
const search = getSearch(current);
|
const search = getSearch(current);
|
||||||
|
|
||||||
|
const encoded = try percentEncodeSegment(allocator, value, .fragment);
|
||||||
|
|
||||||
// Add # prefix if not present and value is not empty
|
// Add # prefix if not present and value is not empty
|
||||||
const hash = if (value.len > 0 and value[0] != '#')
|
const hash = if (encoded.len > 0 and encoded[0] != '#')
|
||||||
try std.fmt.allocPrint(allocator, "#{s}", .{value})
|
try std.fmt.allocPrint(allocator, "#{s}", .{encoded})
|
||||||
else
|
else
|
||||||
value;
|
encoded;
|
||||||
|
|
||||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||||
|
const protocol = getProtocol(current);
|
||||||
|
const host = getHost(current);
|
||||||
|
const pathname = getPathname(current);
|
||||||
|
const search = getSearch(current);
|
||||||
|
const hash = getHash(current);
|
||||||
|
const password = getPassword(current);
|
||||||
|
|
||||||
|
const encoded_username = try percentEncodeSegment(allocator, value, .userinfo);
|
||||||
|
return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||||
|
const protocol = getProtocol(current);
|
||||||
|
const host = getHost(current);
|
||||||
|
const pathname = getPathname(current);
|
||||||
|
const search = getSearch(current);
|
||||||
|
const hash = getHash(current);
|
||||||
|
const username = getUsername(current);
|
||||||
|
|
||||||
|
const encoded_password = try percentEncodeSegment(allocator, value, .userinfo);
|
||||||
|
return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buildUrlWithUserInfo(
|
||||||
|
allocator: Allocator,
|
||||||
|
protocol: []const u8,
|
||||||
|
username: []const u8,
|
||||||
|
password: []const u8,
|
||||||
|
host: []const u8,
|
||||||
|
pathname: []const u8,
|
||||||
|
search: []const u8,
|
||||||
|
hash: []const u8,
|
||||||
|
) ![:0]const u8 {
|
||||||
|
if (username.len == 0 and password.len == 0) {
|
||||||
|
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||||
|
} else if (password.len == 0) {
|
||||||
|
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{
|
||||||
|
protocol,
|
||||||
|
username,
|
||||||
|
host,
|
||||||
|
pathname,
|
||||||
|
search,
|
||||||
|
hash,
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{
|
||||||
|
protocol,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
pathname,
|
||||||
|
search,
|
||||||
|
hash,
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
|
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
|
||||||
if (query_string.len == 0) {
|
if (query_string.len == 0) {
|
||||||
return arena.dupeZ(u8, url);
|
return arena.dupeZ(u8, url);
|
||||||
@@ -512,6 +734,33 @@ pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
|
||||||
|
if (std.mem.indexOfScalar(u8, input, '%') == null) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = try std.ArrayList(u8).initCapacity(arena, input.len);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < input.len) {
|
||||||
|
if (input[i] == '%' and i + 2 < input.len) {
|
||||||
|
const hex = input[i + 1 .. i + 3];
|
||||||
|
const byte = std.fmt.parseInt(u8, hex, 16) catch {
|
||||||
|
result.appendAssumeCapacity(input[i]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
result.appendAssumeCapacity(byte);
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
result.appendAssumeCapacity(input[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
test "URL: isCompleteHTTPUrl" {
|
test "URL: isCompleteHTTPUrl" {
|
||||||
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
|
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
|
||||||
@@ -691,6 +940,297 @@ test "URL: resolve" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "URL: ensureEncoded" {
|
||||||
|
defer testing.reset();
|
||||||
|
|
||||||
|
const Case = struct {
|
||||||
|
url: [:0]const u8,
|
||||||
|
expected: [:0]const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [_]Case{
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/over 9000!",
|
||||||
|
.expected = "https://example.com/over%209000!",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "http://example.com/hello world.html",
|
||||||
|
.expected = "http://example.com/hello%20world.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/file[1].html",
|
||||||
|
.expected = "https://example.com/file%5B1%5D.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/file{name}.html",
|
||||||
|
.expected = "https://example.com/file%7Bname%7D.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page?query=hello world",
|
||||||
|
.expected = "https://example.com/page?query=hello%20world",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page?a=1&b=value with spaces",
|
||||||
|
.expected = "https://example.com/page?a=1&b=value%20with%20spaces",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page#section one",
|
||||||
|
.expected = "https://example.com/page#section%20one",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/my path?query=my value#my anchor",
|
||||||
|
.expected = "https://example.com/my%20path?query=my%20value#my%20anchor",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/already%20encoded",
|
||||||
|
.expected = "https://example.com/already%20encoded",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/file%5B1%5D.html",
|
||||||
|
.expected = "https://example.com/file%5B1%5D.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/caf%C3%A9",
|
||||||
|
.expected = "https://example.com/caf%C3%A9",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page?query=already%20encoded",
|
||||||
|
.expected = "https://example.com/page?query=already%20encoded",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page?a=1&b=value%20here",
|
||||||
|
.expected = "https://example.com/page?a=1&b=value%20here",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page#section%20one",
|
||||||
|
.expected = "https://example.com/page#section%20one",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/part%20encoded and not",
|
||||||
|
.expected = "https://example.com/part%20encoded%20and%20not",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/page?a=encoded%20value&b=not encoded",
|
||||||
|
.expected = "https://example.com/page?a=encoded%20value&b=not%20encoded",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/my%20path?query=not encoded#encoded%20anchor",
|
||||||
|
.expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
|
||||||
|
.expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/path-with_under~tilde",
|
||||||
|
.expected = "https://example.com/path-with_under~tilde",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/sub-delims!$&'()*+,;=",
|
||||||
|
.expected = "https://example.com/sub-delims!$&'()*+,;=",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com",
|
||||||
|
.expected = "https://example.com",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com?query=value",
|
||||||
|
.expected = "https://example.com?query=value",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/clean/path",
|
||||||
|
.expected = "https://example.com/clean/path",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/path?clean=query#clean-fragment",
|
||||||
|
.expected = "https://example.com/path?clean=query#clean-fragment",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/100% complete",
|
||||||
|
.expected = "https://example.com/100%25%20complete",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "https://example.com/path?value=100% done",
|
||||||
|
.expected = "https://example.com/path?value=100%25%20done",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.url = "about:blank",
|
||||||
|
.expected = "about:blank",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (cases) |case| {
|
||||||
|
const result = try ensureEncoded(testing.arena_allocator, case.url);
|
||||||
|
try testing.expectString(case.expected, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "URL: resolve with encoding" {
|
||||||
|
defer testing.reset();
|
||||||
|
|
||||||
|
const Case = struct {
|
||||||
|
base: [:0]const u8,
|
||||||
|
path: [:0]const u8,
|
||||||
|
expected: [:0]const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [_]Case{
|
||||||
|
// Spaces should be encoded as %20, but ! is allowed
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/dir/",
|
||||||
|
.path = "over 9000!",
|
||||||
|
.expected = "https://example.com/dir/over%209000!",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "hello world.html",
|
||||||
|
.expected = "https://example.com/hello%20world.html",
|
||||||
|
},
|
||||||
|
// Multiple spaces
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "path with multiple spaces",
|
||||||
|
.expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces",
|
||||||
|
},
|
||||||
|
// Special characters that need encoding
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file[1].html",
|
||||||
|
.expected = "https://example.com/file%5B1%5D.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file{name}.html",
|
||||||
|
.expected = "https://example.com/file%7Bname%7D.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file<test>.html",
|
||||||
|
.expected = "https://example.com/file%3Ctest%3E.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file\"quote\".html",
|
||||||
|
.expected = "https://example.com/file%22quote%22.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file|pipe.html",
|
||||||
|
.expected = "https://example.com/file%7Cpipe.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file\\backslash.html",
|
||||||
|
.expected = "https://example.com/file%5Cbackslash.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file^caret.html",
|
||||||
|
.expected = "https://example.com/file%5Ecaret.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file`backtick`.html",
|
||||||
|
.expected = "https://example.com/file%60backtick%60.html",
|
||||||
|
},
|
||||||
|
// Characters that should NOT be encoded
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "path-with_under~tilde.html",
|
||||||
|
.expected = "https://example.com/path-with_under~tilde.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "path/with/slashes",
|
||||||
|
.expected = "https://example.com/path/with/slashes",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "sub-delims!$&'()*+,;=.html",
|
||||||
|
.expected = "https://example.com/sub-delims!$&'()*+,;=.html",
|
||||||
|
},
|
||||||
|
// Already encoded characters should not be double-encoded
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "already%20encoded",
|
||||||
|
.expected = "https://example.com/already%20encoded",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file%5B1%5D.html",
|
||||||
|
.expected = "https://example.com/file%5B1%5D.html",
|
||||||
|
},
|
||||||
|
// Mix of encoded and unencoded
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "part%20encoded and not",
|
||||||
|
.expected = "https://example.com/part%20encoded%20and%20not",
|
||||||
|
},
|
||||||
|
// Query strings and fragments ARE encoded
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file name.html?query=value with spaces",
|
||||||
|
.expected = "https://example.com/file%20name.html?query=value%20with%20spaces",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file name.html#anchor with spaces",
|
||||||
|
.expected = "https://example.com/file%20name.html#anchor%20with%20spaces",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file.html?hello=world !",
|
||||||
|
.expected = "https://example.com/file.html?hello=world%20!",
|
||||||
|
},
|
||||||
|
// Query structural characters should NOT be encoded
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "file.html?a=1&b=2",
|
||||||
|
.expected = "https://example.com/file.html?a=1&b=2",
|
||||||
|
},
|
||||||
|
// Relative paths with encoding
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/dir/page.html",
|
||||||
|
.path = "../other dir/file.html",
|
||||||
|
.expected = "https://example.com/other%20dir/file.html",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/dir/",
|
||||||
|
.path = "./sub dir/file.html",
|
||||||
|
.expected = "https://example.com/dir/sub%20dir/file.html",
|
||||||
|
},
|
||||||
|
// Absolute paths with encoding
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/some/path",
|
||||||
|
.path = "/absolute path/file.html",
|
||||||
|
.expected = "https://example.com/absolute%20path/file.html",
|
||||||
|
},
|
||||||
|
// Unicode/high bytes (though ideally these should be UTF-8 encoded first)
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "café",
|
||||||
|
.expected = "https://example.com/caf%C3%A9",
|
||||||
|
},
|
||||||
|
// Empty path
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "",
|
||||||
|
.expected = "https://example.com/",
|
||||||
|
},
|
||||||
|
// Complete URL as path (should not be encoded)
|
||||||
|
.{
|
||||||
|
.base = "https://example.com/",
|
||||||
|
.path = "https://other.com/path with spaces",
|
||||||
|
.expected = "https://other.com/path%20with%20spaces",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (cases) |case| {
|
||||||
|
const result = try resolve(testing.arena_allocator, case.base, case.path, .{ .encode = true });
|
||||||
|
try testing.expectString(case.expected, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test "URL: eqlDocument" {
|
test "URL: eqlDocument" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
{
|
{
|
||||||
@@ -816,3 +1356,96 @@ test "URL: getRobotsUrl" {
|
|||||||
try testing.expectString("https://example.com/robots.txt", url);
|
try testing.expectString("https://example.com/robots.txt", url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "URL: unescape" {
|
||||||
|
defer testing.reset();
|
||||||
|
const arena = testing.arena_allocator;
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello world");
|
||||||
|
try testing.expectEqual("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello%20world");
|
||||||
|
try testing.expectEqual("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "%48%65%6c%6c%6f");
|
||||||
|
try testing.expectEqual("Hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "%48%65%6C%6C%6F");
|
||||||
|
try testing.expectEqual("Hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "a%3Db");
|
||||||
|
try testing.expectEqual("a=b", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "a%3DB");
|
||||||
|
try testing.expectEqual("a=B", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "ZDIgPSAndHdvJzs%3D");
|
||||||
|
try testing.expectEqual("ZDIgPSAndHdvJzs=", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "%5a%44%4d%67%50%53%41%6e%64%47%68%79%5a%57%55%6e%4f%77%3D%3D");
|
||||||
|
try testing.expectEqual("ZDMgPSAndGhyZWUnOw==", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello%2world");
|
||||||
|
try testing.expectEqual("hello%2world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello%ZZworld");
|
||||||
|
try testing.expectEqual("hello%ZZworld", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello%");
|
||||||
|
try testing.expectEqual("hello%", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = try unescape(arena, "hello%2");
|
||||||
|
try testing.expectEqual("hello%2", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "URL: getHost" {
|
||||||
|
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://example.com:8080/path"));
|
||||||
|
try testing.expectEqualSlices(u8, "example.com", getHost("https://example.com/path"));
|
||||||
|
try testing.expectEqualSlices(u8, "example.com:443", getHost("https://example.com:443/"));
|
||||||
|
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
|
||||||
|
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
|
||||||
|
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "URL: setPathname percent-encodes" {
|
||||||
|
// Use arena allocator to match production usage (setPathname makes intermediate allocations)
|
||||||
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const allocator = arena.allocator();
|
||||||
|
|
||||||
|
// Spaces must be encoded as %20
|
||||||
|
const result1 = try setPathname("http://a/", "c d", allocator);
|
||||||
|
try testing.expectEqualSlices(u8, "http://a/c%20d", result1);
|
||||||
|
|
||||||
|
// Already-encoded sequences must not be double-encoded
|
||||||
|
const result2 = try setPathname("https://example.com/path", "/already%20encoded", allocator);
|
||||||
|
try testing.expectEqualSlices(u8, "https://example.com/already%20encoded", result2);
|
||||||
|
|
||||||
|
// Query and hash must be preserved
|
||||||
|
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
|
||||||
|
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
|
||||||
|
}
|
||||||
|
|||||||
137
src/browser/actions.zig
Normal file
137
src/browser/actions.zig
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// 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 lp = @import("../lightpanda.zig");
|
||||||
|
const DOMNode = @import("webapi/Node.zig");
|
||||||
|
const Element = @import("webapi/Element.zig");
|
||||||
|
const Event = @import("webapi/Event.zig");
|
||||||
|
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||||
|
const Page = @import("Page.zig");
|
||||||
|
const Session = @import("Session.zig");
|
||||||
|
const Selector = @import("webapi/selector/Selector.zig");
|
||||||
|
|
||||||
|
pub fn click(node: *DOMNode, page: *Page) !void {
|
||||||
|
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||||
|
|
||||||
|
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
|
||||||
|
.bubbles = true,
|
||||||
|
.cancelable = true,
|
||||||
|
.composed = true,
|
||||||
|
.clientX = 0,
|
||||||
|
.clientY = 0,
|
||||||
|
}, page);
|
||||||
|
|
||||||
|
page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
|
||||||
|
lp.log.err(.app, "click failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
|
||||||
|
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||||
|
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
input.setValue(text, page) catch |err| {
|
||||||
|
lp.log.err(.app, "fill input failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
||||||
|
textarea.setValue(text, page) catch |err| {
|
||||||
|
lp.log.err(.app, "fill textarea failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
} else if (el.is(Element.Html.Select)) |select| {
|
||||||
|
select.setValue(text, page) catch |err| {
|
||||||
|
lp.log.err(.app, "fill select failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return error.InvalidNodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
|
||||||
|
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
|
||||||
|
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
|
||||||
|
};
|
||||||
|
|
||||||
|
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
|
||||||
|
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
|
||||||
|
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
|
||||||
|
if (node) |n| {
|
||||||
|
const el = n.is(Element) orelse return error.InvalidNodeType;
|
||||||
|
|
||||||
|
if (x) |val| {
|
||||||
|
el.setScrollLeft(val, page) catch |err| {
|
||||||
|
lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (y) |val| {
|
||||||
|
el.setScrollTop(val, page) catch |err| {
|
||||||
|
lp.log.err(.app, "setScrollTop failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page);
|
||||||
|
page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
|
||||||
|
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| {
|
||||||
|
lp.log.err(.app, "scroll failed", .{ .err = err });
|
||||||
|
return error.ActionFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode {
|
||||||
|
var timer = try std.time.Timer.start();
|
||||||
|
var runner = try session.runner(.{});
|
||||||
|
try runner.wait(.{ .ms = timeout_ms, .until = .load });
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = runner.page;
|
||||||
|
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
|
||||||
|
return error.InvalidSelector;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (element) |el| {
|
||||||
|
return el.asNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
|
||||||
|
if (elapsed >= timeout_ms) {
|
||||||
|
return error.Timeout;
|
||||||
|
}
|
||||||
|
switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) {
|
||||||
|
.done => return error.Timeout,
|
||||||
|
.ok => |recommended_sleep_ms| {
|
||||||
|
if (recommended_sleep_ms > 0) {
|
||||||
|
// guanrateed to be <= 20ms
|
||||||
|
std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,3 +293,191 @@ fn isBang(token: Tokenizer.Token) bool {
|
|||||||
else => false,
|
else => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const Rule = struct {
|
||||||
|
selector: []const u8,
|
||||||
|
block: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn parseStylesheet(input: []const u8) RulesIterator {
|
||||||
|
return RulesIterator.init(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const RulesIterator = struct {
|
||||||
|
input: []const u8,
|
||||||
|
stream: TokenStream,
|
||||||
|
has_skipped_at_rule: bool = false,
|
||||||
|
|
||||||
|
pub fn init(input: []const u8) RulesIterator {
|
||||||
|
return .{
|
||||||
|
.input = input,
|
||||||
|
.stream = TokenStream.init(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(self: *RulesIterator) ?Rule {
|
||||||
|
var selector_start: ?usize = null;
|
||||||
|
var selector_end: ?usize = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const peeked = self.stream.peek() orelse return null;
|
||||||
|
|
||||||
|
if (peeked.token == .curly_bracket_block) {
|
||||||
|
if (selector_start == null) {
|
||||||
|
self.skipBlock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open_brace = self.stream.next() orelse return null;
|
||||||
|
const block_start = open_brace.end;
|
||||||
|
var block_end = block_start;
|
||||||
|
|
||||||
|
var depth: usize = 1;
|
||||||
|
while (true) {
|
||||||
|
const span = self.stream.next() orelse {
|
||||||
|
block_end = self.input.len;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if (span.token == .curly_bracket_block) {
|
||||||
|
depth += 1;
|
||||||
|
} else if (span.token == .close_curly_bracket) {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth == 0) {
|
||||||
|
block_end = span.start;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selector = self.input[selector_start.?..selector_end.?];
|
||||||
|
selector = std.mem.trim(u8, selector, &std.ascii.whitespace);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.selector = selector,
|
||||||
|
.block = self.input[block_start..block_end],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeked.token == .at_keyword) {
|
||||||
|
self.has_skipped_at_rule = true;
|
||||||
|
self.skipAtRule();
|
||||||
|
selector_start = null;
|
||||||
|
selector_end = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selector_start == null and (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token))) {
|
||||||
|
_ = self.stream.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = self.stream.next() orelse return null;
|
||||||
|
if (!isWhitespaceOrComment(span.token)) {
|
||||||
|
if (selector_start == null) selector_start = span.start;
|
||||||
|
selector_end = span.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skipBlock(self: *RulesIterator) void {
|
||||||
|
const span = self.stream.next() orelse return;
|
||||||
|
if (span.token != .curly_bracket_block) return;
|
||||||
|
|
||||||
|
var depth: usize = 1;
|
||||||
|
while (true) {
|
||||||
|
const next_span = self.stream.next() orelse return;
|
||||||
|
if (next_span.token == .curly_bracket_block) {
|
||||||
|
depth += 1;
|
||||||
|
} else if (next_span.token == .close_curly_bracket) {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth == 0) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skipAtRule(self: *RulesIterator) void {
|
||||||
|
_ = self.stream.next(); // consume @keyword
|
||||||
|
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 (span.token == .curly_bracket_block) {
|
||||||
|
depth += 1;
|
||||||
|
saw_block = true;
|
||||||
|
} else if (span.token == .close_curly_bracket) {
|
||||||
|
if (depth > 0) depth -= 1;
|
||||||
|
if (saw_block and depth == 0) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "RulesIterator: single rule" {
|
||||||
|
var it = RulesIterator.init(".test { color: red; }");
|
||||||
|
const rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings(".test", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" color: red; ", rule.block);
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RulesIterator: multiple rules" {
|
||||||
|
var it = RulesIterator.init("h1 { margin: 0; } p { padding: 10px; }");
|
||||||
|
|
||||||
|
var rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings("h1", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" margin: 0; ", rule.block);
|
||||||
|
|
||||||
|
rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings("p", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" padding: 10px; ", rule.block);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RulesIterator: skips at-rules without block" {
|
||||||
|
var it = RulesIterator.init("@import url('style.css'); .test { color: red; }");
|
||||||
|
|
||||||
|
const rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings(".test", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" color: red; ", rule.block);
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RulesIterator: skips at-rules with block" {
|
||||||
|
var it = RulesIterator.init("@media screen { .test { color: blue; } } .test2 { color: green; }");
|
||||||
|
|
||||||
|
const rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings(".test2", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" color: green; ", rule.block);
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RulesIterator: comments and whitespace" {
|
||||||
|
var it = RulesIterator.init(" /* comment */ .test /* comment */ { /* comment */ color: red; } \n\t");
|
||||||
|
|
||||||
|
const rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings(".test", rule.selector);
|
||||||
|
try testing.expectEqualStrings(" /* comment */ color: red; ", rule.block);
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RulesIterator: top-level semicolons" {
|
||||||
|
var it = RulesIterator.init("*{}; ; p{}");
|
||||||
|
var rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings("*", rule.selector);
|
||||||
|
|
||||||
|
rule = it.next() orelse return error.MissingRule;
|
||||||
|
try testing.expectEqualStrings("p", rule.selector);
|
||||||
|
try testing.expectEqual(@as(?Rule, null), it.next());
|
||||||
|
}
|
||||||
|
|||||||
@@ -480,10 +480,11 @@ fn consumeName(self: *Tokenizer) []const u8 {
|
|||||||
self.consumeEscape();
|
self.consumeEscape();
|
||||||
},
|
},
|
||||||
0x0 => self.advance(1),
|
0x0 => self.advance(1),
|
||||||
'\x80'...'\xBF', '\xC0'...'\xEF', '\xF0'...'\xFF' => {
|
'\x80'...'\xFF' => {
|
||||||
// This byte *is* part of a multi-byte code point,
|
// Non-ASCII: advance over the complete UTF-8 code point in one step.
|
||||||
// we’ll end up copying the whole code point before this loop does something else.
|
// Using consumeChar() instead of advance(1) ensures we never land on
|
||||||
self.advance(1);
|
// a continuation byte, which advance() asserts against.
|
||||||
|
self.consumeChar();
|
||||||
},
|
},
|
||||||
else => {
|
else => {
|
||||||
if (self.hasNonAsciiAt(0)) {
|
if (self.hasNonAsciiAt(0)) {
|
||||||
@@ -583,7 +584,7 @@ fn consumeNumeric(self: *Tokenizer) Token {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.advance(2);
|
self.advance(2);
|
||||||
} else if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(2))) {
|
} else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
|
||||||
self.advance(1);
|
self.advance(1);
|
||||||
} else {
|
} else {
|
||||||
break :blk;
|
break :blk;
|
||||||
|
|||||||
@@ -20,16 +20,15 @@ const std = @import("std");
|
|||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
const Node = @import("webapi/Node.zig");
|
const Node = @import("webapi/Node.zig");
|
||||||
const Slot = @import("webapi/element/html/Slot.zig");
|
const Slot = @import("webapi/element/html/Slot.zig");
|
||||||
|
const IFrame = @import("webapi/element/html/IFrame.zig");
|
||||||
|
|
||||||
pub const RootOpts = struct {
|
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||||
with_base: bool = false,
|
|
||||||
strip: Opts.Strip = .{},
|
|
||||||
shadow: Opts.Shadow = .rendered,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const Opts = struct {
|
pub const Opts = struct {
|
||||||
strip: Strip = .{},
|
with_base: bool = false,
|
||||||
shadow: Shadow = .rendered,
|
with_frames: bool = false,
|
||||||
|
strip: Opts.Strip = .{},
|
||||||
|
shadow: Opts.Shadow = .rendered,
|
||||||
|
|
||||||
pub const Strip = struct {
|
pub const Strip = struct {
|
||||||
js: bool = false,
|
js: bool = false,
|
||||||
@@ -49,7 +48,7 @@ pub const Opts = struct {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void {
|
pub fn root(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||||
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
|
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
|
||||||
blk: {
|
blk: {
|
||||||
// Ideally we just render the doctype which is part of the document
|
// Ideally we just render the doctype which is part of the document
|
||||||
@@ -71,7 +70,7 @@ pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deep(doc.asNode(), .{ .strip = opts.strip, .shadow = opts.shadow }, writer, page);
|
return deep(doc.asNode(), opts, writer, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
||||||
@@ -83,19 +82,19 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
|
|||||||
.cdata => |cd| {
|
.cdata => |cd| {
|
||||||
if (node.is(Node.CData.Comment)) |_| {
|
if (node.is(Node.CData.Comment)) |_| {
|
||||||
try writer.writeAll("<!--");
|
try writer.writeAll("<!--");
|
||||||
try writer.writeAll(cd.getData());
|
try writer.writeAll(cd.getData().str());
|
||||||
try writer.writeAll("-->");
|
try writer.writeAll("-->");
|
||||||
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
|
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
|
||||||
try writer.writeAll("<?");
|
try writer.writeAll("<?");
|
||||||
try writer.writeAll(pi._target);
|
try writer.writeAll(pi._target);
|
||||||
try writer.writeAll(" ");
|
try writer.writeAll(" ");
|
||||||
try writer.writeAll(cd.getData());
|
try writer.writeAll(cd.getData().str());
|
||||||
try writer.writeAll("?>");
|
try writer.writeAll("?>");
|
||||||
} else {
|
} else {
|
||||||
if (shouldEscapeText(node._parent)) {
|
if (shouldEscapeText(node._parent)) {
|
||||||
try writeEscapedText(cd.getData(), writer);
|
try writeEscapedText(cd.getData().str(), writer);
|
||||||
} else {
|
} else {
|
||||||
try writer.writeAll(cd.getData());
|
try writer.writeAll(cd.getData().str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -140,7 +139,24 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
try children(node, opts, writer, page);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isVoidElement(el)) {
|
if (!isVoidElement(el)) {
|
||||||
try writer.writeAll("</");
|
try writer.writeAll("</");
|
||||||
try writer.writeAll(el.getTagNameDump());
|
try writer.writeAll(el.getTagNameDump());
|
||||||
@@ -172,7 +188,11 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
|
|||||||
try writer.writeAll(">\n");
|
try writer.writeAll(">\n");
|
||||||
},
|
},
|
||||||
.document_fragment => try children(node, opts, writer, page),
|
.document_fragment => try children(node, opts, writer, page),
|
||||||
.attribute => unreachable,
|
.attribute => {
|
||||||
|
// Not called normally, but can be called via XMLSerializer.serializeToString
|
||||||
|
// in which case it should return an empty string
|
||||||
|
try writer.writeAll("");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +314,12 @@ fn shouldEscapeText(node_: ?*Node) bool {
|
|||||||
if (node.is(Node.Element.Html.Script) != null) {
|
if (node.is(Node.Element.Html.Script) != null) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {
|
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {
|
||||||
|
|||||||
460
src/browser/forms.zig
Normal file
460
src/browser/forms.zig
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
// 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 Page = @import("Page.zig");
|
||||||
|
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||||
|
const Element = @import("webapi/Element.zig");
|
||||||
|
const Node = @import("webapi/Node.zig");
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
pub const SelectOption = struct {
|
||||||
|
value: []const u8,
|
||||||
|
text: []const u8,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
try jw.objectField("value");
|
||||||
|
try jw.write(self.value);
|
||||||
|
try jw.objectField("text");
|
||||||
|
try jw.write(self.text);
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const FormField = struct {
|
||||||
|
backendNodeId: ?u32 = null,
|
||||||
|
node: *Node,
|
||||||
|
tag_name: []const u8,
|
||||||
|
name: ?[]const u8,
|
||||||
|
input_type: ?[]const u8,
|
||||||
|
required: bool,
|
||||||
|
disabled: bool,
|
||||||
|
value: ?[]const u8,
|
||||||
|
placeholder: ?[]const u8,
|
||||||
|
options: []SelectOption,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: *const FormField, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
|
||||||
|
if (self.backendNodeId) |id| {
|
||||||
|
try jw.objectField("backendNodeId");
|
||||||
|
try jw.write(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField("tagName");
|
||||||
|
try jw.write(self.tag_name);
|
||||||
|
|
||||||
|
if (self.name) |v| {
|
||||||
|
try jw.objectField("name");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.input_type) |v| {
|
||||||
|
try jw.objectField("inputType");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField("required");
|
||||||
|
try jw.write(self.required);
|
||||||
|
|
||||||
|
try jw.objectField("disabled");
|
||||||
|
try jw.write(self.disabled);
|
||||||
|
|
||||||
|
if (self.value) |v| {
|
||||||
|
try jw.objectField("value");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.placeholder) |v| {
|
||||||
|
try jw.objectField("placeholder");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.options.len > 0) {
|
||||||
|
try jw.objectField("options");
|
||||||
|
try jw.beginArray();
|
||||||
|
for (self.options) |opt| {
|
||||||
|
try opt.jsonStringify(jw);
|
||||||
|
}
|
||||||
|
try jw.endArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const FormInfo = struct {
|
||||||
|
backendNodeId: ?u32 = null,
|
||||||
|
node: *Node,
|
||||||
|
action: ?[]const u8,
|
||||||
|
method: ?[]const u8,
|
||||||
|
fields: []FormField,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
|
||||||
|
if (self.backendNodeId) |id| {
|
||||||
|
try jw.objectField("backendNodeId");
|
||||||
|
try jw.write(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.action) |v| {
|
||||||
|
try jw.objectField("action");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.method) |v| {
|
||||||
|
try jw.objectField("method");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField("fields");
|
||||||
|
try jw.beginArray();
|
||||||
|
for (self.fields) |field| {
|
||||||
|
try field.jsonStringify(jw);
|
||||||
|
}
|
||||||
|
try jw.endArray();
|
||||||
|
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Populate backendNodeId on each form and its fields by registering
|
||||||
|
/// their nodes in the given registry. Works with both CDP and MCP registries.
|
||||||
|
pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void {
|
||||||
|
for (forms_data) |*form| {
|
||||||
|
const form_registered = try registry.register(form.node);
|
||||||
|
form.backendNodeId = form_registered.id;
|
||||||
|
for (form.fields) |*field| {
|
||||||
|
const field_registered = try registry.register(field.node);
|
||||||
|
field.backendNodeId = field_registered.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all forms and their fields under `root`.
|
||||||
|
/// Uses Form.getElements() to include fields outside the <form> that
|
||||||
|
/// reference it via the form="id" attribute, matching browser behavior.
|
||||||
|
/// `arena` must be an arena allocator — returned slices borrow its memory.
|
||||||
|
pub fn collectForms(
|
||||||
|
arena: Allocator,
|
||||||
|
root: *Node,
|
||||||
|
page: *Page,
|
||||||
|
) ![]FormInfo {
|
||||||
|
var forms: std.ArrayList(FormInfo) = .empty;
|
||||||
|
|
||||||
|
var tw = TreeWalker.Full.init(root, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
const form = node.is(Element.Html.Form) orelse continue;
|
||||||
|
const el = form.asElement();
|
||||||
|
|
||||||
|
const fields = try collectFormFields(arena, form, page);
|
||||||
|
if (fields.len == 0) continue;
|
||||||
|
|
||||||
|
const action_attr = el.getAttributeSafe(comptime .wrap("action"));
|
||||||
|
const method_str = form.getMethod();
|
||||||
|
|
||||||
|
try forms.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.action = if (action_attr) |a| if (a.len > 0) a else null else null,
|
||||||
|
.method = method_str,
|
||||||
|
.fields = fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return forms.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectFormFields(
|
||||||
|
arena: Allocator,
|
||||||
|
form: *Element.Html.Form,
|
||||||
|
page: *Page,
|
||||||
|
) ![]FormField {
|
||||||
|
var fields: std.ArrayList(FormField) = .empty;
|
||||||
|
|
||||||
|
var elements = try form.getElements(page);
|
||||||
|
var it = try elements.iterator();
|
||||||
|
while (it.next()) |el| {
|
||||||
|
const node = el.asNode();
|
||||||
|
|
||||||
|
const is_disabled = el.isDisabled();
|
||||||
|
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
if (input._input_type == .hidden) continue;
|
||||||
|
if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue;
|
||||||
|
|
||||||
|
try fields.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.tag_name = "input",
|
||||||
|
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||||
|
.input_type = input._input_type.toString(),
|
||||||
|
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||||
|
.disabled = is_disabled,
|
||||||
|
.value = input.getValue(),
|
||||||
|
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||||
|
.options = &.{},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.is(Element.Html.TextArea)) |textarea| {
|
||||||
|
try fields.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.tag_name = "textarea",
|
||||||
|
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||||
|
.input_type = null,
|
||||||
|
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||||
|
.disabled = is_disabled,
|
||||||
|
.value = textarea.getValue(),
|
||||||
|
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||||
|
.options = &.{},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.is(Element.Html.Select)) |select| {
|
||||||
|
const options = try collectSelectOptions(arena, node, page);
|
||||||
|
|
||||||
|
try fields.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.tag_name = "select",
|
||||||
|
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||||
|
.input_type = null,
|
||||||
|
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||||
|
.disabled = is_disabled,
|
||||||
|
.value = select.getValue(page),
|
||||||
|
.placeholder = null,
|
||||||
|
.options = options,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button elements from getElements() - skip (not fillable)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectSelectOptions(
|
||||||
|
arena: Allocator,
|
||||||
|
select_node: *Node,
|
||||||
|
page: *Page,
|
||||||
|
) ![]SelectOption {
|
||||||
|
var options: std.ArrayList(SelectOption) = .empty;
|
||||||
|
const Option = Element.Html.Option;
|
||||||
|
|
||||||
|
var tw = TreeWalker.Full.init(select_node, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
const el = node.is(Element) orelse continue;
|
||||||
|
const option = el.is(Option) orelse continue;
|
||||||
|
|
||||||
|
try options.append(arena, .{
|
||||||
|
.value = option.getValue(page),
|
||||||
|
.text = option.getText(page),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
|
||||||
|
fn testForms(html: []const u8) ![]FormInfo {
|
||||||
|
const page = try testing.test_session.createPage();
|
||||||
|
|
||||||
|
const doc = page.window._document;
|
||||||
|
const div = try doc.createElement("div", null, page);
|
||||||
|
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||||
|
|
||||||
|
return collectForms(page.call_arena, div.asNode(), page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: login form" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form action="/login" method="POST">
|
||||||
|
\\ <input type="email" name="email" required placeholder="Email">
|
||||||
|
\\ <input type="password" name="password" required>
|
||||||
|
\\ <input type="submit" value="Log In">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual("/login", forms[0].action.?);
|
||||||
|
try testing.expectEqual("post", forms[0].method.?);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("email", forms[0].fields[0].name.?);
|
||||||
|
try testing.expectEqual("email", forms[0].fields[0].input_type.?);
|
||||||
|
try testing.expect(forms[0].fields[0].required);
|
||||||
|
try testing.expect(!forms[0].fields[0].disabled);
|
||||||
|
try testing.expectEqual("password", forms[0].fields[1].name.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: form with select" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <select name="color">
|
||||||
|
\\ <option value="red">Red</option>
|
||||||
|
\\ <option value="blue">Blue</option>
|
||||||
|
\\ </select>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("select", forms[0].fields[0].tag_name);
|
||||||
|
try testing.expectEqual(2, forms[0].fields[0].options.len);
|
||||||
|
try testing.expectEqual("red", forms[0].fields[0].options[0].value);
|
||||||
|
try testing.expectEqual("Red", forms[0].fields[0].options[0].text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: form with textarea" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form method="POST">
|
||||||
|
\\ <textarea name="message" placeholder="Your message"></textarea>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("textarea", forms[0].fields[0].tag_name);
|
||||||
|
try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: empty form skipped" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form action="/empty">
|
||||||
|
\\ <p>No fields here</p>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(0, forms.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: hidden inputs excluded" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="hidden" name="csrf" value="token123">
|
||||||
|
\\ <input type="text" name="username">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("username", forms[0].fields[0].name.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: multiple forms" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form action="/search" method="GET">
|
||||||
|
\\ <input type="text" name="q" placeholder="Search">
|
||||||
|
\\</form>
|
||||||
|
\\<form action="/login" method="POST">
|
||||||
|
\\ <input type="email" name="email">
|
||||||
|
\\ <input type="password" name="pass">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, forms.len);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
try testing.expectEqual(2, forms[1].fields.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: disabled fields flagged" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="text" name="enabled_field">
|
||||||
|
\\ <input type="text" name="disabled_field" disabled>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
try testing.expect(!forms[0].fields[0].disabled);
|
||||||
|
try testing.expect(forms[0].fields[1].disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: disabled fieldset" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <fieldset disabled>
|
||||||
|
\\ <input type="text" name="in_disabled_fieldset">
|
||||||
|
\\ </fieldset>
|
||||||
|
\\ <input type="text" name="outside_fieldset">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
try testing.expect(forms[0].fields[0].disabled);
|
||||||
|
try testing.expect(!forms[0].fields[1].disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: external field via form attribute" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<input type="text" name="external" form="myform">
|
||||||
|
\\<form id="myform" action="/submit">
|
||||||
|
\\ <input type="text" name="internal">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: checkbox and radio return value attribute" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="checkbox" name="agree" value="yes" checked>
|
||||||
|
\\ <input type="radio" name="color" value="red">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?);
|
||||||
|
try testing.expectEqual("yes", forms[0].fields[0].value.?);
|
||||||
|
try testing.expectEqual("radio", forms[0].fields[1].input_type.?);
|
||||||
|
try testing.expectEqual("red", forms[0].fields[1].value.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: form without action or method" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="text" name="q">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(null, forms[0].action);
|
||||||
|
try testing.expectEqual("get", forms[0].method.?);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
}
|
||||||
562
src/browser/interactive.zig
Normal file
562
src/browser/interactive.zig
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
// 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 Page = @import("Page.zig");
|
||||||
|
const URL = @import("URL.zig");
|
||||||
|
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||||
|
const Element = @import("webapi/Element.zig");
|
||||||
|
const Node = @import("webapi/Node.zig");
|
||||||
|
const EventTarget = @import("webapi/EventTarget.zig");
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
pub const InteractivityType = enum {
|
||||||
|
native,
|
||||||
|
aria,
|
||||||
|
contenteditable,
|
||||||
|
listener,
|
||||||
|
focusable,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const InteractiveElement = struct {
|
||||||
|
node: *Node,
|
||||||
|
tag_name: []const u8,
|
||||||
|
role: ?[]const u8,
|
||||||
|
name: ?[]const u8,
|
||||||
|
interactivity_type: InteractivityType,
|
||||||
|
listener_types: []const []const u8,
|
||||||
|
disabled: bool,
|
||||||
|
tab_index: i32,
|
||||||
|
id: ?[]const u8,
|
||||||
|
class: ?[]const u8,
|
||||||
|
href: ?[]const u8,
|
||||||
|
input_type: ?[]const u8,
|
||||||
|
value: ?[]const u8,
|
||||||
|
element_name: ?[]const u8,
|
||||||
|
placeholder: ?[]const u8,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
|
||||||
|
try jw.objectField("tagName");
|
||||||
|
try jw.write(self.tag_name);
|
||||||
|
|
||||||
|
try jw.objectField("role");
|
||||||
|
try jw.write(self.role);
|
||||||
|
|
||||||
|
try jw.objectField("name");
|
||||||
|
try jw.write(self.name);
|
||||||
|
|
||||||
|
try jw.objectField("type");
|
||||||
|
try jw.write(@tagName(self.interactivity_type));
|
||||||
|
|
||||||
|
if (self.listener_types.len > 0) {
|
||||||
|
try jw.objectField("listeners");
|
||||||
|
try jw.beginArray();
|
||||||
|
for (self.listener_types) |lt| {
|
||||||
|
try jw.write(lt);
|
||||||
|
}
|
||||||
|
try jw.endArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.disabled) {
|
||||||
|
try jw.objectField("disabled");
|
||||||
|
try jw.write(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField("tabIndex");
|
||||||
|
try jw.write(self.tab_index);
|
||||||
|
|
||||||
|
if (self.id) |v| {
|
||||||
|
try jw.objectField("id");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.class) |v| {
|
||||||
|
try jw.objectField("class");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.href) |v| {
|
||||||
|
try jw.objectField("href");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.input_type) |v| {
|
||||||
|
try jw.objectField("inputType");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.value) |v| {
|
||||||
|
try jw.objectField("value");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.element_name) |v| {
|
||||||
|
try jw.objectField("elementName");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.placeholder) |v| {
|
||||||
|
try jw.objectField("placeholder");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Collect all interactive elements under `root`.
|
||||||
|
pub fn collectInteractiveElements(
|
||||||
|
root: *Node,
|
||||||
|
arena: Allocator,
|
||||||
|
page: *Page,
|
||||||
|
) ![]InteractiveElement {
|
||||||
|
// Pre-build a map of event_target pointer → event type names,
|
||||||
|
// so classify and getListenerTypes are both O(1) per element.
|
||||||
|
const listener_targets = try buildListenerTargetMap(page, arena);
|
||||||
|
|
||||||
|
var css_cache: Element.PointerEventsCache = .empty;
|
||||||
|
|
||||||
|
var results: std.ArrayList(InteractiveElement) = .empty;
|
||||||
|
|
||||||
|
var tw = TreeWalker.Full.init(root, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
const el = node.is(Element) orelse continue;
|
||||||
|
const html_el = el.is(Element.Html) orelse continue;
|
||||||
|
|
||||||
|
// Skip non-visual elements that are never user-interactive.
|
||||||
|
switch (el.getTag()) {
|
||||||
|
.script, .style, .link, .meta, .head, .noscript, .template => continue,
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itype = classifyInteractivity(page, el, html_el, listener_targets, &css_cache) orelse continue;
|
||||||
|
|
||||||
|
const listener_types = getListenerTypes(
|
||||||
|
el.asEventTarget(),
|
||||||
|
listener_targets,
|
||||||
|
);
|
||||||
|
|
||||||
|
try results.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.tag_name = el.getTagNameLower(),
|
||||||
|
.role = getRole(el),
|
||||||
|
.name = try getAccessibleName(el, arena),
|
||||||
|
.interactivity_type = itype,
|
||||||
|
.listener_types = listener_types,
|
||||||
|
.disabled = el.isDisabled(),
|
||||||
|
.tab_index = html_el.getTabIndex(),
|
||||||
|
.id = el.getAttributeSafe(comptime .wrap("id")),
|
||||||
|
.class = el.getAttributeSafe(comptime .wrap("class")),
|
||||||
|
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
|
||||||
|
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
|
||||||
|
else
|
||||||
|
null,
|
||||||
|
.input_type = getInputType(el),
|
||||||
|
.value = getInputValue(el),
|
||||||
|
.element_name = el.getAttributeSafe(comptime .wrap("name")),
|
||||||
|
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
|
||||||
|
|
||||||
|
/// Pre-build a map from event_target pointer → list of event type names.
|
||||||
|
/// This lets both classifyInteractivity (O(1) "has any?") and
|
||||||
|
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
|
||||||
|
pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
|
||||||
|
var map = ListenerTargetMap{};
|
||||||
|
|
||||||
|
// addEventListener registrations
|
||||||
|
var it = page._event_manager.lookup.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const list = entry.value_ptr.*;
|
||||||
|
if (list.first != null) {
|
||||||
|
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .empty;
|
||||||
|
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline handlers (onclick, onmousedown, etc.)
|
||||||
|
var attr_it = page._event_target_attr_listeners.iterator();
|
||||||
|
while (attr_it.next()) |entry| {
|
||||||
|
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
|
||||||
|
if (!gop.found_existing) gop.value_ptr.* = .empty;
|
||||||
|
// Strip "on" prefix to get the event type name.
|
||||||
|
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classifyInteractivity(
|
||||||
|
page: *Page,
|
||||||
|
el: *Element,
|
||||||
|
html_el: *Element.Html,
|
||||||
|
listener_targets: ListenerTargetMap,
|
||||||
|
cache: ?*Element.PointerEventsCache,
|
||||||
|
) ?InteractivityType {
|
||||||
|
if (el.hasPointerEventsNone(cache, page)) return null;
|
||||||
|
|
||||||
|
// 1. Native interactive by tag
|
||||||
|
switch (el.getTag()) {
|
||||||
|
.button, .summary, .details, .select, .textarea => return .native,
|
||||||
|
.anchor, .area => {
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
|
||||||
|
},
|
||||||
|
.input => {
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
if (input._input_type != .hidden) return .native;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ARIA interactive role
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
|
||||||
|
if (isInteractiveRole(role)) return .aria;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
|
||||||
|
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
|
||||||
|
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Event listeners (addEventListener or inline handlers)
|
||||||
|
const et_ptr = @intFromPtr(html_el.asEventTarget());
|
||||||
|
if (listener_targets.get(et_ptr) != null) return .listener;
|
||||||
|
|
||||||
|
// 5. Explicitly focusable via tabindex.
|
||||||
|
// Only count elements with an EXPLICIT tabindex attribute,
|
||||||
|
// since getTabIndex() returns 0 for all interactive tags by default
|
||||||
|
// (including anchors without href and hidden inputs).
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
|
||||||
|
if (html_el.getTabIndex() >= 0) return .focusable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isInteractiveRole(role: []const u8) bool {
|
||||||
|
const MAX_LEN = "menuitemcheckbox".len;
|
||||||
|
if (role.len > MAX_LEN) return false;
|
||||||
|
var buf: [MAX_LEN]u8 = undefined;
|
||||||
|
const lowered = std.ascii.lowerString(&buf, role);
|
||||||
|
const interactive_roles = std.StaticStringMap(void).initComptime(.{
|
||||||
|
.{ "button", {} },
|
||||||
|
.{ "checkbox", {} },
|
||||||
|
.{ "combobox", {} },
|
||||||
|
.{ "iframe", {} },
|
||||||
|
.{ "link", {} },
|
||||||
|
.{ "listbox", {} },
|
||||||
|
.{ "menuitem", {} },
|
||||||
|
.{ "menuitemcheckbox", {} },
|
||||||
|
.{ "menuitemradio", {} },
|
||||||
|
.{ "option", {} },
|
||||||
|
.{ "radio", {} },
|
||||||
|
.{ "searchbox", {} },
|
||||||
|
.{ "slider", {} },
|
||||||
|
.{ "spinbutton", {} },
|
||||||
|
.{ "switch", {} },
|
||||||
|
.{ "tab", {} },
|
||||||
|
.{ "textbox", {} },
|
||||||
|
.{ "treeitem", {} },
|
||||||
|
});
|
||||||
|
return interactive_roles.has(lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isContentRole(role: []const u8) bool {
|
||||||
|
const MAX_LEN = "columnheader".len;
|
||||||
|
if (role.len > MAX_LEN) return false;
|
||||||
|
var buf: [MAX_LEN]u8 = undefined;
|
||||||
|
const lowered = std.ascii.lowerString(&buf, role);
|
||||||
|
const content_roles = std.StaticStringMap(void).initComptime(.{
|
||||||
|
.{ "article", {} },
|
||||||
|
.{ "cell", {} },
|
||||||
|
.{ "columnheader", {} },
|
||||||
|
.{ "gridcell", {} },
|
||||||
|
.{ "heading", {} },
|
||||||
|
.{ "listitem", {} },
|
||||||
|
.{ "main", {} },
|
||||||
|
.{ "navigation", {} },
|
||||||
|
.{ "region", {} },
|
||||||
|
.{ "rowheader", {} },
|
||||||
|
});
|
||||||
|
return content_roles.has(lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getRole(el: *Element) ?[]const u8 {
|
||||||
|
// Explicit role attribute takes precedence
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
|
||||||
|
|
||||||
|
// Implicit role from tag
|
||||||
|
return switch (el.getTag()) {
|
||||||
|
.button, .summary => "button",
|
||||||
|
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
|
||||||
|
.input => blk: {
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
break :blk switch (input._input_type) {
|
||||||
|
.text, .tel, .url, .email => "textbox",
|
||||||
|
.checkbox => "checkbox",
|
||||||
|
.radio => "radio",
|
||||||
|
.button, .submit, .reset, .image => "button",
|
||||||
|
.range => "slider",
|
||||||
|
.number => "spinbutton",
|
||||||
|
.search => "searchbox",
|
||||||
|
else => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break :blk null;
|
||||||
|
},
|
||||||
|
.select => "combobox",
|
||||||
|
.textarea => "textbox",
|
||||||
|
.details => "group",
|
||||||
|
else => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getAccessibleName(el: *Element, arena: Allocator) !?[]const u8 {
|
||||||
|
// aria-label
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
|
||||||
|
if (v.len > 0) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// alt (for img, input[type=image])
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
|
||||||
|
if (v.len > 0) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// title
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
|
||||||
|
if (v.len > 0) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholder
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
|
||||||
|
if (v.len > 0) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// value (for buttons)
|
||||||
|
if (el.getTag() == .input) {
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
|
||||||
|
if (v.len > 0) return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content (first non-empty text node, trimmed)
|
||||||
|
return try getTextContent(el.asNode(), arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
|
||||||
|
var tw: TreeWalker.FullExcludeSelf = .init(node, .{});
|
||||||
|
|
||||||
|
var arr: std.ArrayList(u8) = .empty;
|
||||||
|
var single_chunk: ?[]const u8 = null;
|
||||||
|
|
||||||
|
while (tw.next()) |child| {
|
||||||
|
// Skip text inside script/style elements.
|
||||||
|
if (child.is(Element)) |el| {
|
||||||
|
switch (el.getTag()) {
|
||||||
|
.script, .style => {
|
||||||
|
tw.skipChildren();
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (child.is(Node.CData)) |cdata| {
|
||||||
|
if (cdata.is(Node.CData.Text)) |text| {
|
||||||
|
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
|
||||||
|
if (content.len > 0) {
|
||||||
|
if (single_chunk == null and arr.items.len == 0) {
|
||||||
|
single_chunk = content;
|
||||||
|
} else {
|
||||||
|
if (single_chunk) |sc| {
|
||||||
|
try arr.appendSlice(arena, sc);
|
||||||
|
try arr.append(arena, ' ');
|
||||||
|
single_chunk = null;
|
||||||
|
}
|
||||||
|
try arr.appendSlice(arena, content);
|
||||||
|
try arr.append(arena, ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (single_chunk) |sc| return sc;
|
||||||
|
if (arr.items.len == 0) return null;
|
||||||
|
|
||||||
|
// strip out trailing space
|
||||||
|
return arr.items[0 .. arr.items.len - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getInputType(el: *Element) ?[]const u8 {
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
return input._input_type.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getInputValue(el: *Element) ?[]const u8 {
|
||||||
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
return input.getValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all event listener types registered on this target.
|
||||||
|
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
|
||||||
|
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
|
||||||
|
return &.{};
|
||||||
|
}
|
||||||
|
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
|
||||||
|
fn testInteractive(html: []const u8) ![]InteractiveElement {
|
||||||
|
const page = try testing.test_session.createPage();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
|
||||||
|
const doc = page.window._document;
|
||||||
|
const div = try doc.createElement("div", null, page);
|
||||||
|
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||||
|
|
||||||
|
return collectInteractiveElements(div.asNode(), page.call_arena, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: button" {
|
||||||
|
const elements = try testInteractive("<button>Click me</button>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual("button", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("button", elements[0].role.?);
|
||||||
|
try testing.expectEqual("Click me", elements[0].name.?);
|
||||||
|
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: anchor with href" {
|
||||||
|
const elements = try testInteractive("<a href=\"/page\">Link</a>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual("a", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("link", elements[0].role.?);
|
||||||
|
try testing.expectEqual("Link", elements[0].name.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: anchor without href" {
|
||||||
|
const elements = try testInteractive("<a>Not a link</a>");
|
||||||
|
try testing.expectEqual(0, elements.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: input types" {
|
||||||
|
const elements = try testInteractive(
|
||||||
|
\\<input type="text" placeholder="Search">
|
||||||
|
\\<input type="hidden" name="csrf">
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual("input", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("text", elements[0].input_type.?);
|
||||||
|
try testing.expectEqual("Search", elements[0].placeholder.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: select and textarea" {
|
||||||
|
const elements = try testInteractive(
|
||||||
|
\\<select name="color"><option>Red</option></select>
|
||||||
|
\\<textarea name="msg"></textarea>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, elements.len);
|
||||||
|
try testing.expectEqual("select", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("textarea", elements[1].tag_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: aria role" {
|
||||||
|
const elements = try testInteractive("<div role=\"button\">Custom</div>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual("div", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("button", elements[0].role.?);
|
||||||
|
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: contenteditable" {
|
||||||
|
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: tabindex" {
|
||||||
|
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
|
||||||
|
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: disabled" {
|
||||||
|
const elements = try testInteractive("<button disabled>Off</button>");
|
||||||
|
try testing.expectEqual(1, elements.len);
|
||||||
|
try testing.expect(elements[0].disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: disabled by fieldset" {
|
||||||
|
const elements = try testInteractive(
|
||||||
|
\\<fieldset disabled>
|
||||||
|
\\ <button>Disabled</button>
|
||||||
|
\\ <legend><button>In legend</button></legend>
|
||||||
|
\\</fieldset>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, elements.len);
|
||||||
|
// Button outside legend is disabled by fieldset
|
||||||
|
try testing.expect(elements[0].disabled);
|
||||||
|
// Button inside first legend is NOT disabled
|
||||||
|
try testing.expect(!elements[1].disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: pointer-events none" {
|
||||||
|
const elements = try testInteractive("<button style=\"pointer-events: none;\">Click me</button>");
|
||||||
|
try testing.expectEqual(0, elements.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: non-interactive div" {
|
||||||
|
const elements = try testInteractive("<div>Just text</div>");
|
||||||
|
try testing.expectEqual(0, elements.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: details and summary" {
|
||||||
|
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
|
||||||
|
try testing.expectEqual(2, elements.len);
|
||||||
|
try testing.expectEqual("details", elements[0].tag_name);
|
||||||
|
try testing.expectEqual("summary", elements[1].tag_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.interactive: mixed elements" {
|
||||||
|
const elements = try testInteractive(
|
||||||
|
\\<div>
|
||||||
|
\\ <a href="/home">Home</a>
|
||||||
|
\\ <p>Some text</p>
|
||||||
|
\\ <button id="btn1">Submit</button>
|
||||||
|
\\ <input type="email" placeholder="Email">
|
||||||
|
\\ <div>Not interactive</div>
|
||||||
|
\\ <div role="tab">Tab</div>
|
||||||
|
\\</div>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(4, elements.len);
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ prev_context: *Context,
|
|||||||
|
|
||||||
// Takes the raw v8 isolate and extracts the context from it.
|
// Takes the raw v8 isolate and extracts the context from it.
|
||||||
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
|
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
|
||||||
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
|
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
|
||||||
initWithContext(self, Context.fromC(v8_context), v8_context);
|
initWithContext(self, ctx, v8_context);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
|
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
|
||||||
@@ -60,6 +60,11 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context)
|
|||||||
ctx.local = &self.local;
|
ctx.local = &self.local;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void {
|
||||||
|
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
|
||||||
|
self.init(isolate);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Caller) void {
|
pub fn deinit(self: *Caller) void {
|
||||||
const ctx = self.local.ctx;
|
const ctx = self.local.ctx;
|
||||||
const call_depth = ctx.call_depth - 1;
|
const call_depth = ctx.call_depth - 1;
|
||||||
@@ -251,7 +256,33 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
|
|||||||
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
|
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
|
// need to unwrap this error immediately for when opts.null_as_undefined == true
|
||||||
// and we need to compare it to null;
|
// and we need to compare it to null;
|
||||||
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
|
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
|
||||||
@@ -274,7 +305,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool
|
|||||||
else => ret,
|
else => ret,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (comptime getter) {
|
if (comptime with_value) {
|
||||||
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
|
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
|
||||||
}
|
}
|
||||||
// intercepted
|
// intercepted
|
||||||
@@ -302,15 +333,20 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T
|
|||||||
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
|
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
|
||||||
const isolate = local.isolate;
|
const isolate = local.isolate;
|
||||||
|
|
||||||
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) {
|
if (comptime IS_DEBUG and @TypeOf(info) == FunctionCallbackInfo) {
|
||||||
if (log.enabled(.js, .warn)) {
|
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);
|
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const js_err: *const v8.Value = switch (err) {
|
const js_err: *const v8.Value = switch (err) {
|
||||||
error.TryCatchRethrow => return,
|
error.TryCatchRethrow => return,
|
||||||
error.InvalidArgument => isolate.createTypeError("invalid argument"),
|
error.InvalidArgument => isolate.createTypeError("invalid argument"),
|
||||||
|
error.TypeError => isolate.createTypeError(""),
|
||||||
error.OutOfMemory => isolate.createError("out of memory"),
|
error.OutOfMemory => isolate.createError("out of memory"),
|
||||||
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
|
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
|
||||||
else => blk: {
|
else => blk: {
|
||||||
@@ -333,7 +369,7 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
|
|||||||
// this can add as much as 10 seconds of compilation time.
|
// 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 {
|
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";
|
const args_dump = serializeFunctionArgs(local, info) catch "failed to serialize args";
|
||||||
log.info(.js, "function call error", .{
|
log.debug(.js, "function call error", .{
|
||||||
.type = type_name,
|
.type = type_name,
|
||||||
.func = func,
|
.func = func,
|
||||||
.err = err,
|
.err = err,
|
||||||
@@ -410,6 +446,11 @@ pub const FunctionCallbackInfo = struct {
|
|||||||
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
|
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn getData(self: FunctionCallbackInfo) ?*anyopaque {
|
||||||
|
const data = v8.v8__FunctionCallbackInfo__Data(self.handle) orelse return null;
|
||||||
|
return v8.v8__External__Value(@ptrCast(data));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
|
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
|
||||||
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
|
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
|
||||||
}
|
}
|
||||||
@@ -462,11 +503,13 @@ const ReturnValue = struct {
|
|||||||
|
|
||||||
pub const Function = struct {
|
pub const Function = struct {
|
||||||
pub const Opts = struct {
|
pub const Opts = struct {
|
||||||
|
noop: bool = false,
|
||||||
static: bool = false,
|
static: bool = false,
|
||||||
dom_exception: bool = false,
|
dom_exception: bool = false,
|
||||||
as_typed_array: bool = false,
|
as_typed_array: bool = false,
|
||||||
null_as_undefined: bool = false,
|
null_as_undefined: bool = false,
|
||||||
cache: ?Caching = null,
|
cache: ?Caching = null,
|
||||||
|
embedded_receiver: bool = false,
|
||||||
|
|
||||||
// We support two ways to cache a value directly into a v8::Object. The
|
// 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
|
// difference between the two is like the difference between a Map
|
||||||
@@ -494,9 +537,7 @@ pub const Function = struct {
|
|||||||
|
|
||||||
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
|
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_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
|
||||||
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
|
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
|
||||||
|
|
||||||
const ctx = Context.fromC(v8_context);
|
|
||||||
const info = FunctionCallbackInfo{ .handle = info_handle };
|
const info = FunctionCallbackInfo{ .handle = info_handle };
|
||||||
|
|
||||||
var hs: js.HandleScope = undefined;
|
var hs: js.HandleScope = undefined;
|
||||||
@@ -537,6 +578,9 @@ pub const Function = struct {
|
|||||||
var args: ParameterTypes(F) = undefined;
|
var args: ParameterTypes(F) = undefined;
|
||||||
if (comptime opts.static) {
|
if (comptime opts.static) {
|
||||||
args = try getArgs(F, 0, local, info);
|
args = try getArgs(F, 0, local, info);
|
||||||
|
} else if (comptime opts.embedded_receiver) {
|
||||||
|
args = try getArgs(F, 1, local, info);
|
||||||
|
@field(args, "0") = @ptrCast(@alignCast(info.getData() orelse unreachable));
|
||||||
} else {
|
} else {
|
||||||
args = try getArgs(F, 1, local, info);
|
args = try getArgs(F, 1, local, info);
|
||||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||||
@@ -688,7 +732,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
|
|||||||
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
|
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
|
||||||
const slice_type = last_parameter_type_info.pointer.child;
|
const slice_type = last_parameter_type_info.pointer.child;
|
||||||
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
|
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) {
|
if (slice_type == js.Value or (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8)) {
|
||||||
is_variadic = true;
|
is_variadic = true;
|
||||||
if (js_parameter_count == 0) {
|
if (js_parameter_count == 0) {
|
||||||
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
|
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ const log = @import("../../log.zig");
|
|||||||
const js = @import("js.zig");
|
const js = @import("js.zig");
|
||||||
const Env = @import("Env.zig");
|
const Env = @import("Env.zig");
|
||||||
const bridge = @import("bridge.zig");
|
const bridge = @import("bridge.zig");
|
||||||
|
const Origin = @import("Origin.zig");
|
||||||
const Scheduler = @import("Scheduler.zig");
|
const Scheduler = @import("Scheduler.zig");
|
||||||
|
|
||||||
const Page = @import("../Page.zig");
|
const Page = @import("../Page.zig");
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
const ScriptManager = @import("../ScriptManager.zig");
|
const ScriptManager = @import("../ScriptManager.zig");
|
||||||
|
|
||||||
const v8 = js.v8;
|
const v8 = js.v8;
|
||||||
@@ -41,15 +43,16 @@ const Context = @This();
|
|||||||
id: usize,
|
id: usize,
|
||||||
env: *Env,
|
env: *Env,
|
||||||
page: *Page,
|
page: *Page,
|
||||||
|
session: *Session,
|
||||||
isolate: js.Isolate,
|
isolate: js.Isolate,
|
||||||
|
|
||||||
|
// Per-context microtask queue for isolation between contexts
|
||||||
|
microtask_queue: *v8.MicrotaskQueue,
|
||||||
|
|
||||||
// The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>>
|
// The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>>
|
||||||
// from this, and we can free it when the context is done.
|
// from this, and we can free it when the context is done.
|
||||||
handle: v8.Global,
|
handle: v8.Global,
|
||||||
|
|
||||||
// True if the context is auto-entered,
|
|
||||||
entered: bool,
|
|
||||||
|
|
||||||
cpu_profiler: ?*v8.CpuProfiler = null,
|
cpu_profiler: ?*v8.CpuProfiler = null,
|
||||||
|
|
||||||
heap_profiler: ?*v8.HeapProfiler = null,
|
heap_profiler: ?*v8.HeapProfiler = null,
|
||||||
@@ -60,7 +63,9 @@ templates: []*const v8.FunctionTemplate,
|
|||||||
// Arena for the lifetime of the context
|
// Arena for the lifetime of the context
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
|
|
||||||
// The page.call_arena
|
// The call_arena for this context. For main world contexts this is
|
||||||
|
// page.call_arena. For isolated world contexts this is a separate arena
|
||||||
|
// owned by the IsolatedWorld.
|
||||||
call_arena: Allocator,
|
call_arena: Allocator,
|
||||||
|
|
||||||
// Because calls can be nested (i.e.a function calling a callback),
|
// Because calls can be nested (i.e.a function calling a callback),
|
||||||
@@ -74,39 +79,21 @@ call_depth: usize = 0,
|
|||||||
// context.localScope
|
// context.localScope
|
||||||
local: ?*const js.Local = null,
|
local: ?*const js.Local = null,
|
||||||
|
|
||||||
// Serves two purposes. Like `global_objects`, this is used to free
|
origin: *Origin,
|
||||||
// every Global(Object) we've created during the lifetime of the context.
|
|
||||||
// More importantly, it serves as an identity map - for a given Zig
|
|
||||||
// instance, we map it to the same Global(Object).
|
|
||||||
// The key is the @intFromPtr of the Zig value
|
|
||||||
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
|
||||||
|
|
||||||
// Any type that is stored in the identity_map which has a finalizer declared
|
// Identity tracking for this context. For main world contexts, this points to
|
||||||
// will have its finalizer stored here. This is only used when shutting down
|
// Session's Identity. For isolated world contexts (CDP inspector), this points
|
||||||
// if v8 hasn't called the finalizer directly itself.
|
// to IsolatedWorld's Identity. This ensures same-origin frames share object
|
||||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
|
// identity while isolated worlds have separate identity tracking.
|
||||||
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
|
identity: *js.Identity,
|
||||||
|
|
||||||
// Some web APIs have to manage opaque values. Ideally, they use an
|
// Allocator to use for identity map operations. For main world contexts this is
|
||||||
// js.Object, but the js.Object has no lifetime guarantee beyond the
|
// session.page_arena, for isolated worlds it's the isolated world's arena.
|
||||||
// current call. They can call .persist() on their js.Object to get
|
identity_arena: Allocator,
|
||||||
// a `Global(Object)`. We need to track these to free them.
|
|
||||||
// This used to be a map and acted like identity_map; the key was
|
// Unlike other v8 types, like functions or objects, modules are not shared
|
||||||
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
|
// across origins.
|
||||||
// a reliable way to know if an object has already been persisted,
|
|
||||||
// we now simply persist every time persist() is called.
|
|
||||||
global_values: std.ArrayList(v8.Global) = .empty,
|
|
||||||
global_objects: std.ArrayList(v8.Global) = .empty,
|
|
||||||
global_modules: std.ArrayList(v8.Global) = .empty,
|
global_modules: std.ArrayList(v8.Global) = .empty,
|
||||||
global_promises: std.ArrayList(v8.Global) = .empty,
|
|
||||||
global_functions: std.ArrayList(v8.Global) = .empty,
|
|
||||||
global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
|
|
||||||
|
|
||||||
// Temp variants stored in HashMaps for O(1) early cleanup.
|
|
||||||
// Key is global.data_ptr.
|
|
||||||
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
|
||||||
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
|
||||||
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
|
||||||
|
|
||||||
// Our module cache: normalized module specifier => module.
|
// Our module cache: normalized module specifier => module.
|
||||||
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
|
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
|
||||||
@@ -124,10 +111,6 @@ script_manager: ?*ScriptManager,
|
|||||||
// Our macrotasks
|
// Our macrotasks
|
||||||
scheduler: Scheduler,
|
scheduler: Scheduler,
|
||||||
|
|
||||||
// Prevents us from enqueuing a microtask for this context while we're shutting
|
|
||||||
// down.
|
|
||||||
shutting_down: bool = false,
|
|
||||||
|
|
||||||
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
|
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
|
||||||
|
|
||||||
const ModuleEntry = struct {
|
const ModuleEntry = struct {
|
||||||
@@ -148,21 +131,26 @@ const ModuleEntry = struct {
|
|||||||
resolver_promise: ?js.Promise.Global = null,
|
resolver_promise: ?js.Promise.Global = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn fromC(c_context: *const v8.Context) *Context {
|
pub fn fromC(c_context: *const v8.Context) ?*Context {
|
||||||
const data = v8.v8__Context__GetEmbedderData(c_context, 1).?;
|
return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
|
||||||
const big_int = js.BigInt{ .handle = @ptrCast(data) };
|
|
||||||
return @ptrFromInt(big_int.getUint64());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fromIsolate(isolate: js.Isolate) *Context {
|
/// Returns the Context and v8::Context for the given isolate.
|
||||||
|
/// If the current context is from a destroyed Context (e.g., navigated-away iframe),
|
||||||
|
/// falls back to the incumbent context (the calling context).
|
||||||
|
pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } {
|
||||||
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?;
|
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?;
|
||||||
const data = v8.v8__Context__GetEmbedderData(v8_context, 1).?;
|
if (fromC(v8_context)) |ctx| {
|
||||||
const big_int = js.BigInt{ .handle = @ptrCast(data) };
|
return .{ ctx, v8_context };
|
||||||
return @ptrFromInt(big_int.getUint64());
|
}
|
||||||
|
// The current context's Context struct has been freed (e.g., iframe navigated away).
|
||||||
|
// Fall back to the incumbent context (the calling context).
|
||||||
|
const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?;
|
||||||
|
return .{ fromC(v8_incumbent).?, v8_incumbent };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Context) void {
|
pub fn deinit(self: *Context) void {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG and @import("builtin").is_test == false) {
|
||||||
var it = self.unknown_properties.iterator();
|
var it = self.unknown_properties.iterator();
|
||||||
while (it.next()) |kv| {
|
while (it.next()) |kv| {
|
||||||
log.debug(.unknown_prop, "unknown property", .{
|
log.debug(.unknown_prop, "unknown property", .{
|
||||||
@@ -172,102 +160,82 @@ pub fn deinit(self: *Context) void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer self.env.app.arena_pool.release(self.arena);
|
|
||||||
|
const env = self.env;
|
||||||
|
defer env.app.arena_pool.release(self.arena);
|
||||||
|
|
||||||
var hs: js.HandleScope = undefined;
|
var hs: js.HandleScope = undefined;
|
||||||
const entered = self.enter(&hs);
|
const entered = self.enter(&hs);
|
||||||
defer entered.exit();
|
defer entered.exit();
|
||||||
|
|
||||||
// We might have microtasks in the isolate that refence this context. The
|
// this can release objects
|
||||||
// only option we have is to run them. But a microtask could queue another
|
|
||||||
// microtask, so we set the shutting_down flag, so that any such microtask
|
|
||||||
// will be a noop (this isn't automatic, when v8 calls our microtask callback
|
|
||||||
// the first thing we'll check is if self.shutting_down == true).
|
|
||||||
self.shutting_down = true;
|
|
||||||
self.env.runMicrotasks();
|
|
||||||
|
|
||||||
// can release objects
|
|
||||||
self.scheduler.deinit();
|
self.scheduler.deinit();
|
||||||
|
|
||||||
{
|
|
||||||
var it = self.identity_map.valueIterator();
|
|
||||||
while (it.next()) |global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
var it = self.finalizer_callbacks.valueIterator();
|
|
||||||
while (it.next()) |finalizer| {
|
|
||||||
finalizer.*.deinit();
|
|
||||||
}
|
|
||||||
self.finalizer_callback_pool.deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (self.global_values.items) |*global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (self.global_objects.items) |*global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (self.global_modules.items) |*global| {
|
for (self.global_modules.items) |*global| {
|
||||||
v8.v8__Global__Reset(global);
|
v8.v8__Global__Reset(global);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (self.global_functions.items) |*global| {
|
self.session.releaseOrigin(self.origin);
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (self.global_promises.items) |*global| {
|
// Clear the embedder data so that if V8 keeps this context alive
|
||||||
v8.v8__Global__Reset(global);
|
// (because objects created in it are still referenced), we don't
|
||||||
}
|
// have a dangling pointer to our freed Context struct.
|
||||||
|
v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null);
|
||||||
for (self.global_promise_resolvers.items) |*global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
var it = self.global_values_temp.valueIterator();
|
|
||||||
while (it.next()) |global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
var it = self.global_promises_temp.valueIterator();
|
|
||||||
while (it.next()) |global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
var it = self.global_functions_temp.valueIterator();
|
|
||||||
while (it.next()) |global| {
|
|
||||||
v8.v8__Global__Reset(global);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self.entered) {
|
|
||||||
v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)));
|
|
||||||
}
|
|
||||||
|
|
||||||
v8.v8__Global__Reset(&self.handle);
|
v8.v8__Global__Reset(&self.handle);
|
||||||
|
env.isolate.notifyContextDisposed();
|
||||||
|
// There can be other tasks associated with this context that we need to
|
||||||
|
// purge while the context is still alive.
|
||||||
|
_ = env.pumpMessageLoop();
|
||||||
|
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||||
|
const env = self.env;
|
||||||
|
const isolate = env.isolate;
|
||||||
|
|
||||||
|
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
|
||||||
|
|
||||||
|
const origin = try self.session.getOrCreateOrigin(key);
|
||||||
|
|
||||||
|
self.session.releaseOrigin(self.origin);
|
||||||
|
self.origin = origin;
|
||||||
|
|
||||||
|
{
|
||||||
|
var ls: js.Local.Scope = undefined;
|
||||||
|
self.localScope(&ls);
|
||||||
|
defer ls.deinit();
|
||||||
|
|
||||||
|
// Set the V8::Context SecurityToken, which is a big part of what allows
|
||||||
|
// one context to access another.
|
||||||
|
const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle);
|
||||||
|
v8.v8__Context__SetSecurityToken(ls.local.handle, token_local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
|
||||||
|
return self.identity.globals.append(self.identity_arena, global);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trackTemp(self: *Context, global: v8.Global) !void {
|
||||||
|
return self.identity.temps.put(self.identity_arena, global.data_ptr, global);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn weakRef(self: *Context, obj: anytype) void {
|
pub fn weakRef(self: *Context, obj: anytype) void {
|
||||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
const resolved = js.Local.resolveValue(obj);
|
||||||
|
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
// should not be possible
|
// should not be possible
|
||||||
std.debug.assert(false);
|
std.debug.assert(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
|
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
||||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
const resolved = js.Local.resolveValue(obj);
|
||||||
|
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
// should not be possible
|
// should not be possible
|
||||||
std.debug.assert(false);
|
std.debug.assert(false);
|
||||||
@@ -275,11 +243,12 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
v8.v8__Global__ClearWeak(&fc.global);
|
v8.v8__Global__ClearWeak(&fc.global);
|
||||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
|
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn strongRef(self: *Context, obj: anytype) void {
|
pub fn strongRef(self: *Context, obj: anytype) void {
|
||||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
const resolved = js.Local.resolveValue(obj);
|
||||||
|
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
// should not be possible
|
// should not be possible
|
||||||
std.debug.assert(false);
|
std.debug.assert(false);
|
||||||
@@ -289,43 +258,46 @@ pub fn strongRef(self: *Context, obj: anytype) void {
|
|||||||
v8.v8__Global__ClearWeak(&fc.global);
|
v8.v8__Global__ClearWeak(&fc.global);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn release(self: *Context, item: anytype) void {
|
pub const IdentityResult = struct {
|
||||||
if (@TypeOf(item) == *anyopaque) {
|
value_ptr: *v8.Global,
|
||||||
// Existing *anyopaque path for identity_map. Called internally from
|
found_existing: bool,
|
||||||
// finalizers
|
|
||||||
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
|
|
||||||
if (comptime IS_DEBUG) {
|
|
||||||
// should not be possible
|
|
||||||
std.debug.assert(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
v8.v8__Global__Reset(&global.value);
|
|
||||||
|
|
||||||
// The item has been fianalized, remove it for the finalizer callback so that
|
|
||||||
// we don't try to call it again on shutdown.
|
|
||||||
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
|
|
||||||
if (comptime IS_DEBUG) {
|
|
||||||
// should not be possible
|
|
||||||
std.debug.assert(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.finalizer_callback_pool.destroy(fc.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var map = switch (@TypeOf(item)) {
|
|
||||||
js.Value.Temp => &self.global_values_temp,
|
|
||||||
js.Promise.Temp => &self.global_promises_temp,
|
|
||||||
js.Function.Temp => &self.global_functions_temp,
|
|
||||||
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (map.fetchRemove(item.handle.data_ptr)) |kv| {
|
pub fn addIdentity(self: *Context, ptr: usize) !IdentityResult {
|
||||||
var global = kv.value;
|
const gop = try self.identity.identity_map.getOrPut(self.identity_arena, ptr);
|
||||||
v8.v8__Global__Reset(&global);
|
return .{
|
||||||
|
.value_ptr = gop.value_ptr,
|
||||||
|
.found_existing = gop.found_existing,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn releaseTemp(self: *Context, global: v8.Global) void {
|
||||||
|
if (self.identity.temps.fetchRemove(global.data_ptr)) |kv| {
|
||||||
|
var g = kv.value;
|
||||||
|
v8.v8__Global__Reset(&g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn createFinalizerCallback(
|
||||||
|
self: *Context,
|
||||||
|
global: v8.Global,
|
||||||
|
ptr: *anyopaque,
|
||||||
|
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
|
||||||
|
) !*Session.FinalizerCallback {
|
||||||
|
const session = self.session;
|
||||||
|
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
|
||||||
|
errdefer session.releaseArena(arena);
|
||||||
|
const fc = try arena.create(Session.FinalizerCallback);
|
||||||
|
fc.* = .{
|
||||||
|
.arena = arena,
|
||||||
|
.session = session,
|
||||||
|
.ptr = ptr,
|
||||||
|
.global = global,
|
||||||
|
.zig_finalizer = zig_finalizer,
|
||||||
|
// Store identity pointer for cleanup when V8 GCs the object
|
||||||
|
.identity = self.identity,
|
||||||
|
};
|
||||||
|
return fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any operation on the context have to be made from a local.
|
// Any operation on the context have to be made from a local.
|
||||||
@@ -333,11 +305,14 @@ pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
|
|||||||
const isolate = self.isolate;
|
const isolate = self.isolate;
|
||||||
js.HandleScope.init(&ls.handle_scope, isolate);
|
js.HandleScope.init(&ls.handle_scope, isolate);
|
||||||
|
|
||||||
|
const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
|
||||||
|
v8.v8__Context__Enter(local_v8_context);
|
||||||
|
|
||||||
// TODO: add and init ls.hs for the handlescope
|
// TODO: add and init ls.hs for the handlescope
|
||||||
ls.local = .{
|
ls.local = .{
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)),
|
|
||||||
.isolate = isolate,
|
.isolate = isolate,
|
||||||
|
.handle = local_v8_context,
|
||||||
.call_arena = self.call_arena,
|
.call_arena = self.call_arena,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -347,29 +322,22 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
|
|||||||
return l.toLocal(global);
|
return l.toLocal(global);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This isn't expected to be called often. It's for converting attributes into
|
pub fn getIncumbent(self: *Context) *Page {
|
||||||
// function calls, e.g. <body onload="doSomething"> will turn that "doSomething"
|
return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
|
||||||
// string into a js.Function which looks like: function(e) { doSomething(e) }
|
}
|
||||||
// There might be more efficient ways to do this, but doing it this way means
|
|
||||||
// our code only has to worry about js.Funtion, not some union of a js.Function
|
pub fn stringToPersistedFunction(
|
||||||
// or a string.
|
self: *Context,
|
||||||
pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global {
|
function_body: []const u8,
|
||||||
|
comptime parameter_names: []const []const u8,
|
||||||
|
extensions: []const v8.Object,
|
||||||
|
) !js.Function.Global {
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
self.localScope(&ls);
|
self.localScope(&ls);
|
||||||
defer ls.deinit();
|
defer ls.deinit();
|
||||||
|
|
||||||
var extra: []const u8 = "";
|
const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions);
|
||||||
const normalized = std.mem.trim(u8, str, &std.ascii.whitespace);
|
return js_function.persist();
|
||||||
if (normalized.len > 0 and normalized[normalized.len - 1] != ')') {
|
|
||||||
extra = "(e)";
|
|
||||||
}
|
|
||||||
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
|
|
||||||
|
|
||||||
const js_val = try ls.local.compileAndRun(full, null);
|
|
||||||
if (!js_val.isFunction()) {
|
|
||||||
return error.StringFunctionError;
|
|
||||||
}
|
|
||||||
return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
|
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
|
||||||
@@ -409,15 +377,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
|
|||||||
}
|
}
|
||||||
|
|
||||||
const owned_url = try arena.dupeZ(u8, url);
|
const owned_url = try arena.dupeZ(u8, url);
|
||||||
|
if (cacheable and !gop.found_existing) {
|
||||||
|
gop.key_ptr.* = owned_url;
|
||||||
|
}
|
||||||
const m = try compileModule(local, src, owned_url);
|
const m = try compileModule(local, src, owned_url);
|
||||||
|
|
||||||
if (cacheable) {
|
if (cacheable) {
|
||||||
// compileModule is synchronous - nothing can modify the cache during compilation
|
// compileModule is synchronous - nothing can modify the cache during compilation
|
||||||
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
|
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
|
||||||
gop.value_ptr.module = try m.persist();
|
gop.value_ptr.module = try m.persist();
|
||||||
if (!gop.found_existing) {
|
|
||||||
gop.key_ptr.* = owned_url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break :blk .{ m, owned_url };
|
break :blk .{ m, owned_url };
|
||||||
@@ -547,6 +515,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
|
|||||||
nested_gop.key_ptr.* = owned_specifier;
|
nested_gop.key_ptr.* = owned_specifier;
|
||||||
nested_gop.value_ptr.* = .{};
|
nested_gop.value_ptr.* = .{};
|
||||||
try script_manager.preloadImport(owned_specifier, url);
|
try script_manager.preloadImport(owned_specifier, url);
|
||||||
|
} else if (nested_gop.value_ptr.module == null) {
|
||||||
|
// Entry exists but module failed to compile previously.
|
||||||
|
// The imported_modules entry may have been consumed, so
|
||||||
|
// re-preload to ensure waitForImport can find it.
|
||||||
|
// Key was stored via dupeZ so it has a sentinel in memory.
|
||||||
|
const key = nested_gop.key_ptr.*;
|
||||||
|
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
|
||||||
|
try script_manager.preloadImport(key_z, url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,7 +547,7 @@ fn resolveModuleCallback(
|
|||||||
) callconv(.c) ?*const v8.Module {
|
) callconv(.c) ?*const v8.Module {
|
||||||
_ = import_attributes;
|
_ = import_attributes;
|
||||||
|
|
||||||
const self = fromC(c_context.?);
|
const self = fromC(c_context.?).?;
|
||||||
const local = js.Local{
|
const local = js.Local{
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
.handle = c_context.?,
|
.handle = c_context.?,
|
||||||
@@ -604,7 +580,7 @@ pub fn dynamicModuleCallback(
|
|||||||
_ = host_defined_options;
|
_ = host_defined_options;
|
||||||
_ = import_attrs;
|
_ = import_attrs;
|
||||||
|
|
||||||
const self = fromC(c_context.?);
|
const self = fromC(c_context.?).?;
|
||||||
const local = js.Local{
|
const local = js.Local{
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
.handle = c_context.?,
|
.handle = c_context.?,
|
||||||
@@ -612,14 +588,23 @@ pub fn dynamicModuleCallback(
|
|||||||
.isolate = self.isolate,
|
.isolate = self.isolate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
|
const resource = blk: {
|
||||||
|
const resource_value = js.Value{ .handle = resource_name.?, .local = &local };
|
||||||
|
if (resource_value.isNullOrUndefined()) {
|
||||||
|
// will only be null / undefined in extreme cases (e.g. WPT tests)
|
||||||
|
// where you're
|
||||||
|
break :blk self.page.base();
|
||||||
|
}
|
||||||
|
|
||||||
|
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
|
||||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
|
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
|
||||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
|
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
|
||||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
|
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
|
||||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalized_specifier = self.script_manager.?.resolveSpecifier(
|
const normalized_specifier = self.script_manager.?.resolveSpecifier(
|
||||||
@@ -628,21 +613,21 @@ pub fn dynamicModuleCallback(
|
|||||||
specifier,
|
specifier,
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
|
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
|
||||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
|
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
|
||||||
log.err(.js, "dynamic module callback", .{
|
log.err(.js, "dynamic module callback", .{
|
||||||
.err = err,
|
.err = err,
|
||||||
});
|
});
|
||||||
break :blk local.rejectPromise("Failed to load module") catch return null;
|
break :blk local.rejectPromise(.{ .generic_error = "Out of memory" });
|
||||||
};
|
};
|
||||||
return @constCast(promise.handle);
|
return @constCast(promise.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void {
|
pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void {
|
||||||
// @HandleScope implement this without a fat context/local..
|
// @HandleScope implement this without a fat context/local..
|
||||||
const self = fromC(c_context.?);
|
const self = fromC(c_context.?).?;
|
||||||
var local = js.Local{
|
var local = js.Local{
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
.handle = c_context.?,
|
.handle = c_context.?,
|
||||||
@@ -686,7 +671,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
|
|||||||
return local.toLocal(m).handle;
|
return local.toLocal(m).handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
var source = try self.script_manager.?.waitForImport(normalized_specifier);
|
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
|
||||||
|
error.UnknownModule => blk: {
|
||||||
|
// Module is in cache but was consumed from imported_modules
|
||||||
|
// (e.g., by a previous failed resolution). Re-preload and retry.
|
||||||
|
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
|
||||||
|
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
|
||||||
|
},
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
defer source.deinit();
|
defer source.deinit();
|
||||||
|
|
||||||
var try_catch: js.TryCatch = undefined;
|
var try_catch: js.TryCatch = undefined;
|
||||||
@@ -789,9 +782,16 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
|
|||||||
entry.module_promise = try module_resolver.promise().persist();
|
entry.module_promise = try module_resolver.promise().persist();
|
||||||
} else {
|
} else {
|
||||||
// the module was loaded, but not evaluated, we _have_ to evaluate it now
|
// the module was loaded, but not evaluated, we _have_ to evaluate it now
|
||||||
|
if (status == .kUninstantiated) {
|
||||||
|
if (try mod.instantiate(resolveModuleCallback) == false) {
|
||||||
|
_ = resolver.reject("module instantiation", local.newString("Module instantiation failed"));
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const evaluated = mod.evaluate() catch {
|
const evaluated = mod.evaluate() catch {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
std.debug.assert(status == .kErrored);
|
std.debug.assert(mod.getStatus() == .kErrored);
|
||||||
}
|
}
|
||||||
_ = resolver.reject("module evaluation", local.newString("Module evaluation failed"));
|
_ = resolver.reject("module evaluation", local.newString("Module evaluation failed"));
|
||||||
return promise;
|
return promise;
|
||||||
@@ -871,13 +871,12 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
|
|||||||
|
|
||||||
const then_callback = newFunctionWithData(local, struct {
|
const then_callback = newFunctionWithData(local, struct {
|
||||||
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
||||||
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
|
|
||||||
var c: Caller = undefined;
|
var c: Caller = undefined;
|
||||||
c.init(isolate);
|
c.initFromHandle(callback_handle);
|
||||||
defer c.deinit();
|
defer c.deinit();
|
||||||
|
|
||||||
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
|
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
|
||||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
|
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
|
||||||
|
|
||||||
if (s.context_id != c.local.ctx.id) {
|
if (s.context_id != c.local.ctx.id) {
|
||||||
// The microtask is tied to the isolate, not the context
|
// The microtask is tied to the isolate, not the context
|
||||||
@@ -896,17 +895,15 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
|
|||||||
|
|
||||||
const catch_callback = newFunctionWithData(local, struct {
|
const catch_callback = newFunctionWithData(local, struct {
|
||||||
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
||||||
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
|
|
||||||
var c: Caller = undefined;
|
var c: Caller = undefined;
|
||||||
c.init(isolate);
|
c.initFromHandle(callback_handle);
|
||||||
defer c.deinit();
|
defer c.deinit();
|
||||||
|
|
||||||
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
|
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
|
||||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
|
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
|
||||||
|
|
||||||
const l = &c.local;
|
const l = &c.local;
|
||||||
const ctx = l.ctx;
|
if (s.context_id != l.ctx.id) {
|
||||||
if (s.context_id != ctx.id) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,13 +995,10 @@ pub fn queueSlotchangeDelivery(self: *Context) !void {
|
|||||||
// But for these Context microtasks, we want to (a) make sure the context isn't
|
// But for these Context microtasks, we want to (a) make sure the context isn't
|
||||||
// being shut down and (b) that it's entered.
|
// being shut down and (b) that it's entered.
|
||||||
fn enqueueMicrotask(self: *Context, callback: anytype) void {
|
fn enqueueMicrotask(self: *Context, callback: anytype) void {
|
||||||
self.isolate.enqueueMicrotask(struct {
|
// Use context-specific microtask queue instead of isolate queue
|
||||||
|
v8.v8__MicrotaskQueue__EnqueueMicrotask(self.microtask_queue, self.isolate.handle, struct {
|
||||||
fn run(data: ?*anyopaque) callconv(.c) void {
|
fn run(data: ?*anyopaque) callconv(.c) void {
|
||||||
const ctx: *Context = @ptrCast(@alignCast(data.?));
|
const ctx: *Context = @ptrCast(@alignCast(data.?));
|
||||||
if (ctx.shutting_down) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hs: js.HandleScope = undefined;
|
var hs: js.HandleScope = undefined;
|
||||||
const entered = ctx.enter(&hs);
|
const entered = ctx.enter(&hs);
|
||||||
defer entered.exit();
|
defer entered.exit();
|
||||||
@@ -1013,38 +1007,18 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
|
|||||||
}.run, self);
|
}.run, self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// There's an assumption here: the js.Function will be alive when microtasks are
|
||||||
|
// run. If we're Env.runMicrotasks in all the places that we're supposed to, then
|
||||||
|
// this should be safe (I think). In whatever HandleScope a microtask is enqueued,
|
||||||
|
// PerformCheckpoint should be run. So the v8::Local<v8::Function> should remain
|
||||||
|
// valid. If we have problems with this, a simple solution is to provide a Zig
|
||||||
|
// wrapper for these callbacks which references a js.Function.Temp, on callback
|
||||||
|
// it executes the function and then releases the global.
|
||||||
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
|
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
|
||||||
self.isolate.enqueueMicrotaskFunc(cb);
|
// Use context-specific microtask queue instead of isolate queue
|
||||||
|
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback {
|
|
||||||
const fc = try self.finalizer_callback_pool.create();
|
|
||||||
fc.* = .{
|
|
||||||
.ctx = self,
|
|
||||||
.ptr = ptr,
|
|
||||||
.global = global,
|
|
||||||
.finalizerFn = finalizerFn,
|
|
||||||
};
|
|
||||||
return fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// == Misc ==
|
|
||||||
// A type that has a finalizer can have its finalizer called one of two ways.
|
|
||||||
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
|
|
||||||
// guaranteed to fire, so we track this in ctx._finalizers and call them on
|
|
||||||
// context shutdown.
|
|
||||||
pub const FinalizerCallback = struct {
|
|
||||||
ctx: *Context,
|
|
||||||
ptr: *anyopaque,
|
|
||||||
global: v8.Global,
|
|
||||||
finalizerFn: *const fn (ptr: *anyopaque) void,
|
|
||||||
|
|
||||||
pub fn deinit(self: *FinalizerCallback) void {
|
|
||||||
self.finalizerFn(self.ptr);
|
|
||||||
self.ctx.finalizer_callback_pool.destroy(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// == Profiler ==
|
// == Profiler ==
|
||||||
pub fn startCpuProfiler(self: *Context) void {
|
pub fn startCpuProfiler(self: *Context) void {
|
||||||
if (comptime !IS_DEBUG) {
|
if (comptime !IS_DEBUG) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const App = @import("../../App.zig");
|
|||||||
const log = @import("../../log.zig");
|
const log = @import("../../log.zig");
|
||||||
|
|
||||||
const bridge = @import("bridge.zig");
|
const bridge = @import("bridge.zig");
|
||||||
|
const Origin = @import("Origin.zig");
|
||||||
const Context = @import("Context.zig");
|
const Context = @import("Context.zig");
|
||||||
const Isolate = @import("Isolate.zig");
|
const Isolate = @import("Isolate.zig");
|
||||||
const Platform = @import("Platform.zig");
|
const Platform = @import("Platform.zig");
|
||||||
@@ -33,6 +34,7 @@ const Snapshot = @import("Snapshot.zig");
|
|||||||
const Inspector = @import("Inspector.zig");
|
const Inspector = @import("Inspector.zig");
|
||||||
|
|
||||||
const Page = @import("../Page.zig");
|
const Page = @import("../Page.zig");
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
const Window = @import("../webapi/Window.zig");
|
const Window = @import("../webapi/Window.zig");
|
||||||
|
|
||||||
const JsApis = bridge.JsApis;
|
const JsApis = bridge.JsApis;
|
||||||
@@ -57,18 +59,26 @@ const Env = @This();
|
|||||||
|
|
||||||
app: *App,
|
app: *App,
|
||||||
|
|
||||||
|
allocator: Allocator,
|
||||||
|
|
||||||
platform: *const Platform,
|
platform: *const Platform,
|
||||||
|
|
||||||
// the global isolate
|
// the global isolate
|
||||||
isolate: js.Isolate,
|
isolate: js.Isolate,
|
||||||
|
|
||||||
contexts: std.ArrayList(*js.Context),
|
contexts: [64]*Context,
|
||||||
|
context_count: usize,
|
||||||
|
|
||||||
// just kept around because we need to free it on deinit
|
// just kept around because we need to free it on deinit
|
||||||
isolate_params: *v8.CreateParams,
|
isolate_params: *v8.CreateParams,
|
||||||
|
|
||||||
context_id: usize,
|
context_id: usize,
|
||||||
|
|
||||||
|
// Maps origin -> shared Origin contains, for v8 values shared across
|
||||||
|
// same-origin Contexts. There's a mismatch here between our JS model and our
|
||||||
|
// Browser model. Origins only live as long as the root page of a session exists.
|
||||||
|
// It would be wrong/dangerous to re-use an Origin across root page navigations.
|
||||||
|
|
||||||
// Global handles that need to be freed on deinit
|
// Global handles that need to be freed on deinit
|
||||||
eternal_function_templates: []v8.Eternal,
|
eternal_function_templates: []v8.Eternal,
|
||||||
|
|
||||||
@@ -85,6 +95,8 @@ inspector: ?*Inspector,
|
|||||||
// which an be created once per isolaet.
|
// which an be created once per isolaet.
|
||||||
private_symbols: PrivateSymbols,
|
private_symbols: PrivateSymbols,
|
||||||
|
|
||||||
|
microtask_queues_are_running: bool,
|
||||||
|
|
||||||
pub const InitOpts = struct {
|
pub const InitOpts = struct {
|
||||||
with_inspector: bool = false,
|
with_inspector: bool = false,
|
||||||
};
|
};
|
||||||
@@ -175,8 +187,23 @@ pub fn init(app: *App, opts: InitOpts) !Env {
|
|||||||
.data = null,
|
.data = null,
|
||||||
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
|
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
|
||||||
});
|
});
|
||||||
|
// I don't 100% understand this. We actually set this up in the snapshot,
|
||||||
|
// but for the global instance, it doesn't work. SetIndexedHandler and
|
||||||
|
// SetNamedHandler are set on the Instance template, and that's the key
|
||||||
|
// difference. The context has its own global instance, so we need to set
|
||||||
|
// these back up directly on it. There might be a better way to do this.
|
||||||
|
v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{
|
||||||
|
.getter = Window.JsApi.index.getter,
|
||||||
|
.setter = null,
|
||||||
|
.query = null,
|
||||||
|
.deleter = null,
|
||||||
|
.enumerator = null,
|
||||||
|
.definer = null,
|
||||||
|
.descriptor = null,
|
||||||
|
.data = null,
|
||||||
|
.flags = 0,
|
||||||
|
});
|
||||||
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
|
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
|
||||||
|
|
||||||
private_symbols = PrivateSymbols.init(isolate_handle);
|
private_symbols = PrivateSymbols.init(isolate_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +215,9 @@ pub fn init(app: *App, opts: InitOpts) !Env {
|
|||||||
return .{
|
return .{
|
||||||
.app = app,
|
.app = app,
|
||||||
.context_id = 0,
|
.context_id = 0,
|
||||||
.contexts = .empty,
|
.allocator = allocator,
|
||||||
|
.contexts = undefined,
|
||||||
|
.context_count = 0,
|
||||||
.isolate = isolate,
|
.isolate = isolate,
|
||||||
.platform = &app.platform,
|
.platform = &app.platform,
|
||||||
.templates = templates,
|
.templates = templates,
|
||||||
@@ -196,25 +225,26 @@ pub fn init(app: *App, opts: InitOpts) !Env {
|
|||||||
.inspector = inspector,
|
.inspector = inspector,
|
||||||
.global_template = global_eternal,
|
.global_template = global_eternal,
|
||||||
.private_symbols = private_symbols,
|
.private_symbols = private_symbols,
|
||||||
|
.microtask_queues_are_running = false,
|
||||||
.eternal_function_templates = eternal_function_templates,
|
.eternal_function_templates = eternal_function_templates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Env) void {
|
pub fn deinit(self: *Env) void {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
std.debug.assert(self.contexts.items.len == 0);
|
std.debug.assert(self.context_count == 0);
|
||||||
}
|
}
|
||||||
for (self.contexts.items) |ctx| {
|
for (self.contexts[0..self.context_count]) |ctx| {
|
||||||
ctx.deinit();
|
ctx.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allocator = self.app.allocator;
|
const app = self.app;
|
||||||
|
const allocator = app.allocator;
|
||||||
|
|
||||||
if (self.inspector) |i| {
|
if (self.inspector) |i| {
|
||||||
i.deinit(allocator);
|
i.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.contexts.deinit(allocator);
|
|
||||||
|
|
||||||
allocator.free(self.templates);
|
allocator.free(self.templates);
|
||||||
allocator.free(self.eternal_function_templates);
|
allocator.free(self.eternal_function_templates);
|
||||||
self.private_symbols.deinit();
|
self.private_symbols.deinit();
|
||||||
@@ -225,8 +255,15 @@ pub fn deinit(self: *Env) void {
|
|||||||
allocator.destroy(self.isolate_params);
|
allocator.destroy(self.isolate_params);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
|
pub const ContextParams = struct {
|
||||||
const context_arena = try self.app.arena_pool.acquire();
|
identity: *js.Identity,
|
||||||
|
identity_arena: Allocator,
|
||||||
|
call_arena: Allocator,
|
||||||
|
debug_name: []const u8 = "Context",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
|
||||||
|
const context_arena = try self.app.arena_pool.acquire(.{ .debug = params.debug_name });
|
||||||
errdefer self.app.arena_pool.release(context_arena);
|
errdefer self.app.arena_pool.release(context_arena);
|
||||||
|
|
||||||
const isolate = self.isolate;
|
const isolate = self.isolate;
|
||||||
@@ -234,10 +271,19 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
|
|||||||
hs.init(isolate);
|
hs.init(isolate);
|
||||||
defer hs.deinit();
|
defer hs.deinit();
|
||||||
|
|
||||||
|
// Create a per-context microtask queue for isolation
|
||||||
|
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
|
||||||
|
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
|
||||||
|
|
||||||
// Get the global template that was created once per isolate
|
// Get the global template that was created once per isolate
|
||||||
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
|
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
|
||||||
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
|
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
|
||||||
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?;
|
|
||||||
|
const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{
|
||||||
|
.global_template = global_template,
|
||||||
|
.global_object = null,
|
||||||
|
.microtask_queue = microtask_queue,
|
||||||
|
}).?;
|
||||||
|
|
||||||
// Create the v8::Context and wrap it in a v8::Global
|
// Create the v8::Context and wrap it in a v8::Global
|
||||||
var context_global: v8.Global = undefined;
|
var context_global: v8.Global = undefined;
|
||||||
@@ -245,6 +291,7 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
|
|||||||
|
|
||||||
// get the global object for the context, this maps to our Window
|
// get the global object for the context, this maps to our Window
|
||||||
const global_obj = v8.v8__Context__Global(v8_context).?;
|
const global_obj = v8.v8__Context__Global(v8_context).?;
|
||||||
|
|
||||||
{
|
{
|
||||||
// Store our TAO inside the internal field of the global object. This
|
// Store our TAO inside the internal field of the global object. This
|
||||||
// maps the v8::Object -> Zig instance. Almost all objects have this, and
|
// maps the v8::Object -> Zig instance. Almost all objects have this, and
|
||||||
@@ -260,50 +307,65 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
|
|||||||
};
|
};
|
||||||
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
|
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
|
||||||
}
|
}
|
||||||
// our window wrapped in a v8::Global
|
|
||||||
var global_global: v8.Global = undefined;
|
|
||||||
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
|
|
||||||
|
|
||||||
if (enter) {
|
|
||||||
v8.v8__Context__Enter(v8_context);
|
|
||||||
}
|
|
||||||
errdefer if (enter) {
|
|
||||||
v8.v8__Context__Exit(v8_context);
|
|
||||||
};
|
|
||||||
|
|
||||||
const context_id = self.context_id;
|
const context_id = self.context_id;
|
||||||
self.context_id = context_id + 1;
|
self.context_id = context_id + 1;
|
||||||
|
|
||||||
|
const session = page._session;
|
||||||
|
const origin = try session.getOrCreateOrigin(null);
|
||||||
|
errdefer session.releaseOrigin(origin);
|
||||||
|
|
||||||
const context = try context_arena.create(Context);
|
const context = try context_arena.create(Context);
|
||||||
context.* = .{
|
context.* = .{
|
||||||
.env = self,
|
.env = self,
|
||||||
.page = page,
|
.page = page,
|
||||||
|
.origin = origin,
|
||||||
.id = context_id,
|
.id = context_id,
|
||||||
.entered = enter,
|
.session = session,
|
||||||
.isolate = isolate,
|
.isolate = isolate,
|
||||||
.arena = context_arena,
|
.arena = context_arena,
|
||||||
.handle = context_global,
|
.handle = context_global,
|
||||||
.templates = self.templates,
|
.templates = self.templates,
|
||||||
.call_arena = page.call_arena,
|
.call_arena = params.call_arena,
|
||||||
|
.microtask_queue = microtask_queue,
|
||||||
.script_manager = &page._script_manager,
|
.script_manager = &page._script_manager,
|
||||||
.scheduler = .init(context_arena),
|
.scheduler = .init(context_arena),
|
||||||
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
|
.identity = params.identity,
|
||||||
|
.identity_arena = params.identity_arena,
|
||||||
};
|
};
|
||||||
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
|
|
||||||
|
{
|
||||||
|
// Multiple contexts can be created for the same Window (via CDP). We only
|
||||||
|
// need to register the first one.
|
||||||
|
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window));
|
||||||
|
if (gop.found_existing == false) {
|
||||||
|
// our window wrapped in a v8::Global
|
||||||
|
var global_global: v8.Global = undefined;
|
||||||
|
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
|
||||||
|
gop.value_ptr.* = global_global;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store a pointer to our context inside the v8 context so that, given
|
// Store a pointer to our context inside the v8 context so that, given
|
||||||
// a v8 context, we can get our context out
|
// a v8 context, we can get our context out
|
||||||
const data = isolate.initBigInt(@intFromPtr(context));
|
v8.v8__Context__SetAlignedPointerInEmbedderData(v8_context, 1, @ptrCast(context));
|
||||||
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
|
|
||||||
|
const count = self.context_count;
|
||||||
|
if (count >= self.contexts.len) {
|
||||||
|
return error.TooManyContexts;
|
||||||
|
}
|
||||||
|
self.contexts[count] = context;
|
||||||
|
self.context_count = count + 1;
|
||||||
|
|
||||||
try self.contexts.append(self.app.allocator, context);
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn destroyContext(self: *Env, context: *Context) void {
|
pub fn destroyContext(self: *Env, context: *Context) void {
|
||||||
for (self.contexts.items, 0..) |ctx, i| {
|
for (self.contexts[0..self.context_count], 0..) |ctx, i| {
|
||||||
if (ctx == context) {
|
if (ctx == context) {
|
||||||
_ = self.contexts.swapRemove(i);
|
// Swap with last element and decrement count
|
||||||
|
self.context_count -= 1;
|
||||||
|
self.contexts[i] = self.contexts[self.context_count];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -321,16 +383,25 @@ pub fn destroyContext(self: *Env, context: *Context) void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.deinit();
|
context.deinit();
|
||||||
isolate.notifyContextDisposed();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runMicrotasks(self: *const Env) void {
|
pub fn runMicrotasks(self: *Env) void {
|
||||||
self.isolate.performMicrotasksCheckpoint();
|
if (self.microtask_queues_are_running == false) {
|
||||||
|
const v8_isolate = self.isolate.handle;
|
||||||
|
|
||||||
|
self.microtask_queues_are_running = true;
|
||||||
|
defer self.microtask_queues_are_running = false;
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < self.context_count) : (i += 1) {
|
||||||
|
const ctx = self.contexts[i];
|
||||||
|
v8.v8__MicrotaskQueue__PerformCheckpoint(ctx.microtask_queue, v8_isolate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runMacrotasks(self: *Env) !?u64 {
|
pub fn runMacrotasks(self: *Env) !void {
|
||||||
var ms_to_next_task: ?u64 = null;
|
for (self.contexts[0..self.context_count]) |ctx| {
|
||||||
for (self.contexts.items) |ctx| {
|
|
||||||
if (comptime builtin.is_test == false) {
|
if (comptime builtin.is_test == false) {
|
||||||
// I hate this comptime check as much as you do. But we have tests
|
// I hate this comptime check as much as you do. But we have tests
|
||||||
// which rely on short execution before shutdown. In real world, it's
|
// which rely on short execution before shutdown. In real world, it's
|
||||||
@@ -344,21 +415,44 @@ pub fn runMacrotasks(self: *Env) !?u64 {
|
|||||||
var hs: js.HandleScope = undefined;
|
var hs: js.HandleScope = undefined;
|
||||||
const entered = ctx.enter(&hs);
|
const entered = ctx.enter(&hs);
|
||||||
defer entered.exit();
|
defer entered.exit();
|
||||||
|
try ctx.scheduler.run();
|
||||||
const ms = (try ctx.scheduler.run()) orelse continue;
|
|
||||||
if (ms_to_next_task == null or ms < ms_to_next_task.?) {
|
|
||||||
ms_to_next_task = ms;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ms_to_next_task;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pumpMessageLoop(self: *const Env) bool {
|
pub fn msToNextMacrotask(self: *Env) ?u64 {
|
||||||
|
var next_task: u64 = std.math.maxInt(u64);
|
||||||
|
for (self.contexts[0..self.context_count]) |ctx| {
|
||||||
|
const candidate = ctx.scheduler.msToNextHigh() orelse continue;
|
||||||
|
next_task = @min(candidate, next_task);
|
||||||
|
}
|
||||||
|
return if (next_task == std.math.maxInt(u64)) null else next_task;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pumpMessageLoop(self: *const Env) void {
|
||||||
var hs: v8.HandleScope = undefined;
|
var hs: v8.HandleScope = undefined;
|
||||||
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
|
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
|
||||||
defer v8.v8__HandleScope__DESTRUCT(&hs);
|
defer v8.v8__HandleScope__DESTRUCT(&hs);
|
||||||
|
|
||||||
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false);
|
const isolate = self.isolate.handle;
|
||||||
|
const platform = self.platform.handle;
|
||||||
|
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hasBackgroundTasks(self: *const Env) bool {
|
||||||
|
return v8.v8__Isolate__HasPendingBackgroundTasks(self.isolate.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn waitForBackgroundTasks(self: *Env) void {
|
||||||
|
var hs: v8.HandleScope = undefined;
|
||||||
|
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
|
||||||
|
defer v8.v8__HandleScope__DESTRUCT(&hs);
|
||||||
|
|
||||||
|
const isolate = self.isolate.handle;
|
||||||
|
const platform = self.platform.handle;
|
||||||
|
while (v8.v8__Isolate__HasPendingBackgroundTasks(isolate)) {
|
||||||
|
_ = v8.v8__Platform__PumpMessageLoop(platform, isolate, true);
|
||||||
|
self.runMicrotasks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runIdleTasks(self: *const Env) void {
|
pub fn runIdleTasks(self: *const Env) void {
|
||||||
@@ -414,21 +508,30 @@ pub fn dumpMemoryStats(self: *Env) void {
|
|||||||
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
|
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn terminate(self: *const Env) void {
|
||||||
|
v8.v8__Isolate__TerminateExecution(self.isolate.handle);
|
||||||
|
}
|
||||||
|
|
||||||
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
|
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
|
||||||
|
const promise_event = v8.v8__PromiseRejectMessage__GetEvent(&message_handle);
|
||||||
|
if (promise_event != v8.kPromiseRejectWithNoHandler and promise_event != v8.kPromiseHandlerAddedAfterReject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
|
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
|
||||||
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
|
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
|
||||||
const js_isolate = js.Isolate{ .handle = v8_isolate };
|
const isolate = js.Isolate{ .handle = v8_isolate };
|
||||||
const ctx = Context.fromIsolate(js_isolate);
|
const ctx, const v8_context = Context.fromIsolate(isolate);
|
||||||
|
|
||||||
const local = js.Local{
|
const local = js.Local{
|
||||||
.ctx = ctx,
|
.ctx = ctx,
|
||||||
.isolate = js_isolate,
|
.isolate = isolate,
|
||||||
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
|
.handle = v8_context,
|
||||||
.call_arena = ctx.call_arena,
|
.call_arena = ctx.call_arena,
|
||||||
};
|
};
|
||||||
|
|
||||||
const page = ctx.page;
|
const page = ctx.page;
|
||||||
page.window.unhandledPromiseRejection(.{
|
page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
|
||||||
.local = &local,
|
.local = &local,
|
||||||
.handle = &message_handle,
|
.handle = &message_handle,
|
||||||
}, page) catch |err| {
|
}, page) catch |err| {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const js = @import("js.zig");
|
|||||||
const v8 = js.v8;
|
const v8 = js.v8;
|
||||||
|
|
||||||
const log = @import("../../log.zig");
|
const log = @import("../../log.zig");
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
|
|
||||||
const Function = @This();
|
const Function = @This();
|
||||||
|
|
||||||
@@ -160,8 +161,8 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
|
|||||||
try_catch.rethrow();
|
try_catch.rethrow();
|
||||||
return error.TryCatchRethrow;
|
return error.TryCatchRethrow;
|
||||||
}
|
}
|
||||||
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
|
caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
|
||||||
return error.JSExecCallback;
|
return error.JsException;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (@typeInfo(T) == .void) {
|
if (@typeInfo(T) == .void) {
|
||||||
@@ -209,11 +210,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
|
|||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
if (comptime is_global) {
|
if (comptime is_global) {
|
||||||
try ctx.global_functions.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
} else {
|
return .{ .handle = global, .temps = {} };
|
||||||
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
|
|
||||||
}
|
}
|
||||||
return .{ .handle = global };
|
try ctx.trackTemp(global);
|
||||||
|
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
|
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
|
||||||
@@ -226,15 +227,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
|
|||||||
return with_this.persist();
|
return with_this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const Temp = G(0);
|
pub const Temp = G(.temp);
|
||||||
pub const Global = G(1);
|
pub const Global = G(.global);
|
||||||
|
|
||||||
fn G(comptime discriminator: u8) type {
|
const GlobalType = enum(u8) {
|
||||||
|
temp,
|
||||||
|
global,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn G(comptime global_type: GlobalType) type {
|
||||||
return struct {
|
return struct {
|
||||||
handle: v8.Global,
|
handle: v8.Global,
|
||||||
|
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||||
// makes the types different (G(0) != G(1)), without taking up space
|
|
||||||
comptime _: u8 = discriminator,
|
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
@@ -252,5 +256,12 @@ fn G(comptime discriminator: u8) type {
|
|||||||
pub fn isEqual(self: *const Self, other: Function) bool {
|
pub fn isEqual(self: *const Self, other: Function) bool {
|
||||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn release(self: *const Self) void {
|
||||||
|
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||||
|
var g = kv.value;
|
||||||
|
v8.v8__Global__Reset(&g);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/browser/js/Identity.zig
Normal file
76
src/browser/js/Identity.zig
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
// Identity manages the mapping between Zig instances and their v8::Object wrappers.
|
||||||
|
// This provides object identity semantics - the same Zig instance always maps to
|
||||||
|
// the same JS object within a given Identity scope.
|
||||||
|
//
|
||||||
|
// Main world contexts share a single Identity (on Session), ensuring that
|
||||||
|
// `window.top.document === top's document` works across same-origin frames.
|
||||||
|
//
|
||||||
|
// Isolated worlds (CDP inspector) have their own Identity, ensuring their
|
||||||
|
// v8::Global wrappers don't leak into the main world.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const js = @import("js.zig");
|
||||||
|
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
|
|
||||||
|
const v8 = js.v8;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const Identity = @This();
|
||||||
|
|
||||||
|
// Maps Zig instance pointers to their v8::Global(Object) wrappers.
|
||||||
|
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||||
|
|
||||||
|
// Tracked global v8 objects that need to be released on cleanup.
|
||||||
|
globals: std.ArrayList(v8.Global) = .empty,
|
||||||
|
|
||||||
|
// Temporary v8 globals that can be released early. Key is global.data_ptr.
|
||||||
|
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||||
|
|
||||||
|
// Finalizer callbacks for weak references. Key is @intFromPtr of the Zig instance.
|
||||||
|
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty,
|
||||||
|
|
||||||
|
pub fn deinit(self: *Identity) void {
|
||||||
|
{
|
||||||
|
var it = self.finalizer_callbacks.valueIterator();
|
||||||
|
while (it.next()) |finalizer| {
|
||||||
|
finalizer.*.deinit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var it = self.identity_map.valueIterator();
|
||||||
|
while (it.next()) |global| {
|
||||||
|
v8.v8__Global__Reset(global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (self.globals.items) |*global| {
|
||||||
|
v8.v8__Global__Reset(global);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var it = self.temps.valueIterator();
|
||||||
|
while (it.next()) |global| {
|
||||||
|
v8.v8__Global__Reset(global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,12 @@ pub fn contextCreated(
|
|||||||
|
|
||||||
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
|
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
|
||||||
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
|
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
|
||||||
|
|
||||||
|
if (self.default_context) |*dc| {
|
||||||
|
if (v8.v8__Global__IsEqual(dc, context)) {
|
||||||
|
self.default_context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resetContextGroup(self: *const Inspector) void {
|
pub fn resetContextGroup(self: *const Inspector) void {
|
||||||
@@ -241,8 +247,6 @@ pub const Session = struct {
|
|||||||
msg.ptr,
|
msg.ptr,
|
||||||
msg.len,
|
msg.len,
|
||||||
);
|
);
|
||||||
|
|
||||||
v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets a value by object ID regardless of which context it is in.
|
// Gets a value by object ID regardless of which context it is in.
|
||||||
|
|||||||
@@ -41,18 +41,6 @@ pub fn exit(self: Isolate) void {
|
|||||||
v8.v8__Isolate__Exit(self.handle);
|
v8.v8__Isolate__Exit(self.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn performMicrotasksCheckpoint(self: Isolate) void {
|
|
||||||
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void {
|
|
||||||
v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void {
|
|
||||||
v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn lowMemoryNotification(self: Isolate) void {
|
pub fn lowMemoryNotification(self: Isolate) void {
|
||||||
v8.v8__Isolate__LowMemoryNotification(self.handle);
|
v8.v8__Isolate__LowMemoryNotification(self.handle);
|
||||||
}
|
}
|
||||||
@@ -90,6 +78,21 @@ pub fn createError(self: Isolate, msg: []const u8) *const v8.Value {
|
|||||||
return v8.v8__Exception__Error(message).?;
|
return v8.v8__Exception__Error(message).?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn createRangeError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||||
|
const message = self.initStringHandle(msg);
|
||||||
|
return v8.v8__Exception__RangeError(message).?;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn createReferenceError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||||
|
const message = self.initStringHandle(msg);
|
||||||
|
return v8.v8__Exception__ReferenceError(message).?;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn createSyntaxError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||||
|
const message = self.initStringHandle(msg);
|
||||||
|
return v8.v8__Exception__SyntaxError(message).?;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn createTypeError(self: Isolate, msg: []const u8) *const v8.Value {
|
pub fn createTypeError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||||
const message = self.initStringHandle(msg);
|
const message = self.initStringHandle(msg);
|
||||||
return v8.v8__Exception__TypeError(message).?;
|
return v8.v8__Exception__TypeError(message).?;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const Page = @import("../Page.zig");
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
const log = @import("../../log.zig");
|
const log = @import("../../log.zig");
|
||||||
const string = @import("../../string.zig");
|
const string = @import("../../string.zig");
|
||||||
|
|
||||||
@@ -81,8 +83,28 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s
|
|||||||
return .init(self, size);
|
return .init(self, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn newCallback(
|
||||||
|
self: *const Local,
|
||||||
|
callback: anytype,
|
||||||
|
data: anytype,
|
||||||
|
) js.Function {
|
||||||
|
const external = self.isolate.createExternal(data);
|
||||||
|
const handle = v8.v8__Function__New__DEFAULT2(self.handle, struct {
|
||||||
|
fn wrap(info_handle: ?*const js.v8.FunctionCallbackInfo) callconv(.c) void {
|
||||||
|
Caller.Function.call(@TypeOf(data), info_handle.?, callback, .{ .embedded_receiver = true });
|
||||||
|
}
|
||||||
|
}.wrap, @ptrCast(external)).?;
|
||||||
|
return .{ .local = self, .handle = handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runMacrotasks(self: *const Local) void {
|
||||||
|
const env = self.ctx.env;
|
||||||
|
env.pumpMessageLoop();
|
||||||
|
env.runMicrotasks(); // macrotasks can cause microtasks to queue
|
||||||
|
}
|
||||||
|
|
||||||
pub fn runMicrotasks(self: *const Local) void {
|
pub fn runMicrotasks(self: *const Local) void {
|
||||||
self.isolate.performMicrotasksCheckpoint();
|
self.ctx.env.runMicrotasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// == Executors ==
|
// == Executors ==
|
||||||
@@ -94,6 +116,49 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
|
|||||||
return self.compileAndRun(src, name);
|
return self.compileAndRun(src, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compiles a function body as function.
|
||||||
|
///
|
||||||
|
/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a
|
||||||
|
pub fn compileFunction(
|
||||||
|
self: *const Local,
|
||||||
|
function_body: []const u8,
|
||||||
|
/// We tend to know how many params we'll pass; can remove the comptime if necessary.
|
||||||
|
comptime parameter_names: []const []const u8,
|
||||||
|
extensions: []const v8.Object,
|
||||||
|
) !js.Function {
|
||||||
|
// TODO: Make configurable.
|
||||||
|
const script_name = self.isolate.initStringHandle("anonymous");
|
||||||
|
const script_source = self.isolate.initStringHandle(function_body);
|
||||||
|
|
||||||
|
var parameter_list: [parameter_names.len]*const v8.String = undefined;
|
||||||
|
inline for (0..parameter_names.len) |i| {
|
||||||
|
parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create `ScriptOrigin`.
|
||||||
|
var origin: v8.ScriptOrigin = undefined;
|
||||||
|
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
|
||||||
|
|
||||||
|
// Create `ScriptCompilerSource`.
|
||||||
|
var script_compiler_source: v8.ScriptCompilerSource = undefined;
|
||||||
|
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source);
|
||||||
|
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source);
|
||||||
|
|
||||||
|
// Compile the function.
|
||||||
|
const result = v8.v8__ScriptCompiler__CompileFunction(
|
||||||
|
self.handle,
|
||||||
|
&script_compiler_source,
|
||||||
|
parameter_list.len,
|
||||||
|
¶meter_list,
|
||||||
|
extensions.len,
|
||||||
|
@ptrCast(&extensions),
|
||||||
|
v8.kNoCompileOptions,
|
||||||
|
v8.kNoCacheNoReason,
|
||||||
|
) orelse return error.CompilationError;
|
||||||
|
|
||||||
|
return .{ .local = self, .handle = result };
|
||||||
|
}
|
||||||
|
|
||||||
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
|
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
|
||||||
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
|
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
|
||||||
const script_source = self.isolate.initStringHandle(src);
|
const script_source = self.isolate.initStringHandle(src);
|
||||||
@@ -116,7 +181,7 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
|
|||||||
) orelse return error.CompilationError;
|
) orelse return error.CompilationError;
|
||||||
|
|
||||||
// Run the script
|
// Run the script
|
||||||
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.ExecutionError;
|
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.JsException;
|
||||||
return .{ .local = self, .handle = result };
|
return .{ .local = self, .handle = result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,20 +202,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
|
|||||||
// we can just grab it from the identity_map)
|
// we can just grab it from the identity_map)
|
||||||
pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object {
|
pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object {
|
||||||
const ctx = self.ctx;
|
const ctx = self.ctx;
|
||||||
const arena = ctx.arena;
|
const context_arena = ctx.arena;
|
||||||
|
|
||||||
const T = @TypeOf(value);
|
const T = @TypeOf(value);
|
||||||
switch (@typeInfo(T)) {
|
switch (@typeInfo(T)) {
|
||||||
.@"struct" => {
|
.@"struct" => {
|
||||||
// Struct, has to be placed on the heap
|
// Struct, has to be placed on the heap
|
||||||
const heap = try arena.create(T);
|
const heap = try context_arena.create(T);
|
||||||
heap.* = value;
|
heap.* = value;
|
||||||
return self.mapZigInstanceToJs(js_obj_handle, heap);
|
return self.mapZigInstanceToJs(js_obj_handle, heap);
|
||||||
},
|
},
|
||||||
.pointer => |ptr| {
|
.pointer => |ptr| {
|
||||||
const resolved = resolveValue(value);
|
const resolved = resolveValue(value);
|
||||||
|
|
||||||
const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
|
const gop = try ctx.addIdentity(@intFromPtr(resolved.ptr));
|
||||||
if (gop.found_existing) {
|
if (gop.found_existing) {
|
||||||
// we've seen this instance before, return the same object
|
// we've seen this instance before, return the same object
|
||||||
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
|
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
|
||||||
@@ -179,7 +244,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
|||||||
// The TAO contains the pointer to our Zig instance as
|
// The TAO contains the pointer to our Zig instance as
|
||||||
// well as any meta data we'll need to use it later.
|
// well as any meta data we'll need to use it later.
|
||||||
// See the TaggedOpaque struct for more details.
|
// See the TaggedOpaque struct for more details.
|
||||||
const tao = try arena.create(TaggedOpaque);
|
const tao = try context_arena.create(TaggedOpaque);
|
||||||
tao.* = .{
|
tao.* = .{
|
||||||
.value = resolved.ptr,
|
.value = resolved.ptr,
|
||||||
.prototype_chain = resolved.prototype_chain.ptr,
|
.prototype_chain = resolved.prototype_chain.ptr,
|
||||||
@@ -204,6 +269,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
|||||||
// can't figure out how to make that work, since it depends on
|
// can't figure out how to make that work, since it depends on
|
||||||
// the [runtime] `value`.
|
// the [runtime] `value`.
|
||||||
// We need the resolved finalizer, which we have in resolved.
|
// We need the resolved finalizer, which we have in resolved.
|
||||||
|
//
|
||||||
// The above if statement would be more clear as:
|
// The above if statement would be more clear as:
|
||||||
// if (resolved.finalizer_from_v8) |finalizer| {
|
// if (resolved.finalizer_from_v8) |finalizer| {
|
||||||
// But that's a runtime check.
|
// But that's a runtime check.
|
||||||
@@ -213,10 +279,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
|||||||
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
||||||
{
|
{
|
||||||
errdefer fc.deinit();
|
errdefer fc.deinit();
|
||||||
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
|
try ctx.identity.finalizer_callbacks.put(ctx.identity_arena, @intFromPtr(resolved.ptr), fc);
|
||||||
}
|
}
|
||||||
|
|
||||||
conditionallyFlagHandoff(value);
|
conditionallyReference(value);
|
||||||
if (@hasDecl(JsApi.Meta, "weak")) {
|
if (@hasDecl(JsApi.Meta, "weak")) {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
std.debug.assert(JsApi.Meta.weak == true);
|
std.debug.assert(JsApi.Meta.weak == true);
|
||||||
@@ -322,6 +388,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
|
|||||||
},
|
},
|
||||||
|
|
||||||
inline
|
inline
|
||||||
|
js.Array,
|
||||||
js.Function,
|
js.Function,
|
||||||
js.Object,
|
js.Object,
|
||||||
js.Promise,
|
js.Promise,
|
||||||
@@ -453,6 +520,13 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
|
|||||||
return js_val;
|
return js_val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (comptime o.child == js.NullableString) {
|
||||||
|
if (js_val.isUndefined()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return .{ .value = try js_val.toStringSlice() };
|
||||||
|
}
|
||||||
|
|
||||||
if (comptime o.child == js.Object) {
|
if (comptime o.child == js.Object) {
|
||||||
return js.Object{
|
return js.Object{
|
||||||
.local = self,
|
.local = self,
|
||||||
@@ -1054,7 +1128,7 @@ const Resolved = struct {
|
|||||||
class_id: u16,
|
class_id: u16,
|
||||||
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
|
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
|
||||||
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
|
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
|
||||||
finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null,
|
finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null,
|
||||||
};
|
};
|
||||||
pub fn resolveValue(value: anytype) Resolved {
|
pub fn resolveValue(value: anytype) Resolved {
|
||||||
const T = bridge.Struct(@TypeOf(value));
|
const T = bridge.Struct(@TypeOf(value));
|
||||||
@@ -1093,14 +1167,14 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn conditionallyFlagHandoff(value: anytype) void {
|
fn conditionallyReference(value: anytype) void {
|
||||||
const T = bridge.Struct(@TypeOf(value));
|
const T = bridge.Struct(@TypeOf(value));
|
||||||
if (@hasField(T, "_v8_handoff")) {
|
if (@hasDecl(T, "acquireRef")) {
|
||||||
value._v8_handoff = true;
|
value.acquireRef();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (@hasField(T, "_proto")) {
|
if (@hasField(T, "_proto")) {
|
||||||
conditionallyFlagHandoff(value._proto);
|
conditionallyReference(value._proto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,9 +1206,15 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// == Promise Helpers ==
|
// == Promise Helpers ==
|
||||||
pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
|
pub fn rejectPromise(self: *const Local, err: js.PromiseResolver.RejectError) js.Promise {
|
||||||
var resolver = js.PromiseResolver.init(self);
|
var resolver = js.PromiseResolver.init(self);
|
||||||
resolver.reject("Local.rejectPromise", value);
|
resolver.rejectError("Local.rejectPromise", err);
|
||||||
|
return resolver.promise();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
|
||||||
|
var resolver = js.PromiseResolver.init(self);
|
||||||
|
resolver.rejectError("Local.rejectPromise", value);
|
||||||
return resolver.promise();
|
return resolver.promise();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1201,13 +1281,20 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
|
|||||||
gop.value_ptr.* = {};
|
gop.value_ptr.* = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const names_arr = js_obj.getOwnPropertyNames();
|
|
||||||
const len = names_arr.len();
|
|
||||||
|
|
||||||
if (depth > 20) {
|
if (depth > 20) {
|
||||||
return writer.writeAll("...deeply nested object...");
|
return writer.writeAll("...deeply nested object...");
|
||||||
}
|
}
|
||||||
const own_len = js_obj.getOwnPropertyNames().len();
|
|
||||||
|
const names_arr = js_obj.getOwnPropertyNames() catch {
|
||||||
|
return writer.writeAll("...invalid object...");
|
||||||
|
};
|
||||||
|
const len = names_arr.len();
|
||||||
|
|
||||||
|
const own_len = blk: {
|
||||||
|
const own_names = js_obj.getOwnPropertyNames() catch break :blk 0;
|
||||||
|
break :blk own_names.len();
|
||||||
|
};
|
||||||
|
|
||||||
if (own_len == 0) {
|
if (own_len == 0) {
|
||||||
const js_val_str = try js_val.toStringSlice();
|
const js_val_str = try js_val.toStringSlice();
|
||||||
if (js_val_str.len > 2000) {
|
if (js_val_str.len > 2000) {
|
||||||
@@ -1326,6 +1413,7 @@ pub const Scope = struct {
|
|||||||
handle_scope: js.HandleScope,
|
handle_scope: js.HandleScope,
|
||||||
|
|
||||||
pub fn deinit(self: *Scope) void {
|
pub fn deinit(self: *Scope) void {
|
||||||
|
v8.v8__Context__Exit(self.local.handle);
|
||||||
self.handle_scope.deinit();
|
self.handle_scope.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global {
|
|||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
|
|
||||||
try ctx.global_objects.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
|
|
||||||
return .{ .handle = global };
|
return .{ .handle = global };
|
||||||
}
|
}
|
||||||
@@ -129,8 +129,14 @@ pub fn isNullOrUndefined(self: Object) bool {
|
|||||||
return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle));
|
return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getOwnPropertyNames(self: Object) js.Array {
|
pub fn getOwnPropertyNames(self: Object) !js.Array {
|
||||||
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?;
|
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle) orelse {
|
||||||
|
// This is almost always a fatal error case. Either we're in some exception
|
||||||
|
// and things are messy, or we're shutting down, or someone has messed up
|
||||||
|
// the object (like some WPT tests do).
|
||||||
|
return error.TypeError;
|
||||||
|
};
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.local = self.local,
|
.local = self.local,
|
||||||
.handle = handle,
|
.handle = handle,
|
||||||
@@ -145,8 +151,11 @@ pub fn getPropertyNames(self: Object) js.Array {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nameIterator(self: Object) NameIterator {
|
pub fn nameIterator(self: Object) !NameIterator {
|
||||||
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
|
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle) orelse {
|
||||||
|
// see getOwnPropertyNames above
|
||||||
|
return error.TypeError;
|
||||||
|
};
|
||||||
const count = v8.v8__Array__Length(handle);
|
const count = v8.v8__Array__Length(handle);
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
|
|||||||
73
src/browser/js/Origin.zig
Normal file
73
src/browser/js/Origin.zig
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
// Origin represents the security token for contexts within the same origin.
|
||||||
|
// Multiple contexts (frames) from the same origin share a single Origin,
|
||||||
|
// which provides the V8 SecurityToken that allows cross-context access.
|
||||||
|
//
|
||||||
|
// Note: Identity tracking (mapping Zig instances to v8::Objects) is managed
|
||||||
|
// separately via js.Identity - Session has the main world Identity, and
|
||||||
|
// IsolatedWorlds have their own Identity instances.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const js = @import("js.zig");
|
||||||
|
|
||||||
|
const App = @import("../../App.zig");
|
||||||
|
|
||||||
|
const v8 = js.v8;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const Origin = @This();
|
||||||
|
|
||||||
|
rc: usize = 1,
|
||||||
|
arena: Allocator,
|
||||||
|
|
||||||
|
// The key, e.g. lightpanda.io:443
|
||||||
|
key: []const u8,
|
||||||
|
|
||||||
|
// Security token - all contexts in this origin must use the same v8::Value instance
|
||||||
|
// as their security token for V8 to allow cross-context access
|
||||||
|
security_token: v8.Global,
|
||||||
|
|
||||||
|
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
|
||||||
|
const arena = try app.arena_pool.acquire(.{ .debug = "Origin" });
|
||||||
|
errdefer app.arena_pool.release(arena);
|
||||||
|
|
||||||
|
var hs: js.HandleScope = undefined;
|
||||||
|
hs.init(isolate);
|
||||||
|
defer hs.deinit();
|
||||||
|
|
||||||
|
const owned_key = try arena.dupe(u8, key);
|
||||||
|
const token_local = isolate.initStringHandle(owned_key);
|
||||||
|
var token_global: v8.Global = undefined;
|
||||||
|
v8.v8__Global__New(isolate.handle, token_local, &token_global);
|
||||||
|
|
||||||
|
const self = try arena.create(Origin);
|
||||||
|
self.* = .{
|
||||||
|
.rc = 1,
|
||||||
|
.arena = arena,
|
||||||
|
.key = owned_key,
|
||||||
|
.security_token = token_global,
|
||||||
|
};
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Origin, app: *App) void {
|
||||||
|
v8.v8__Global__Reset(&self.security_token);
|
||||||
|
app.arena_pool.release(self.arena);
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
const js = @import("js.zig");
|
const js = @import("js.zig");
|
||||||
const v8 = js.v8;
|
const v8 = js.v8;
|
||||||
|
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
|
|
||||||
const Promise = @This();
|
const Promise = @This();
|
||||||
|
|
||||||
local: *const js.Local,
|
local: *const js.Local,
|
||||||
@@ -62,22 +65,25 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
|
|||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
if (comptime is_global) {
|
if (comptime is_global) {
|
||||||
try ctx.global_promises.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
} else {
|
return .{ .handle = global, .temps = {} };
|
||||||
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
|
|
||||||
}
|
}
|
||||||
return .{ .handle = global };
|
try ctx.trackTemp(global);
|
||||||
|
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const Temp = G(0);
|
pub const Temp = G(.temp);
|
||||||
pub const Global = G(1);
|
pub const Global = G(.global);
|
||||||
|
|
||||||
fn G(comptime discriminator: u8) type {
|
const GlobalType = enum(u8) {
|
||||||
|
temp,
|
||||||
|
global,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn G(comptime global_type: GlobalType) type {
|
||||||
return struct {
|
return struct {
|
||||||
handle: v8.Global,
|
handle: v8.Global,
|
||||||
|
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||||
// makes the types different (G(0) != G(1)), without taking up space
|
|
||||||
comptime _: u8 = discriminator,
|
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
@@ -91,5 +97,12 @@ fn G(comptime discriminator: u8) type {
|
|||||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn release(self: *const Self) void {
|
||||||
|
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||||
|
var g = kv.value;
|
||||||
|
v8.v8__Global__Reset(&g);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
|
|
||||||
const js = @import("js.zig");
|
const js = @import("js.zig");
|
||||||
const v8 = js.v8;
|
const v8 = js.v8;
|
||||||
|
|
||||||
const log = @import("../../log.zig");
|
const log = @import("../../log.zig");
|
||||||
|
|
||||||
|
const DOMException = @import("../webapi/DOMException.zig");
|
||||||
|
|
||||||
const PromiseResolver = @This();
|
const PromiseResolver = @This();
|
||||||
|
|
||||||
local: *const js.Local,
|
local: *const js.Local,
|
||||||
@@ -63,6 +66,43 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const RejectError = union(enum) {
|
||||||
|
/// Not to be confused with `DOMException`; this is bare `Error`.
|
||||||
|
generic_error: []const u8,
|
||||||
|
range_error: []const u8,
|
||||||
|
reference_error: []const u8,
|
||||||
|
syntax_error: []const u8,
|
||||||
|
type_error: []const u8,
|
||||||
|
/// DOM exceptions are unknown to V8, belongs to web standards.
|
||||||
|
dom_exception: struct { err: anyerror },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Rejects the promise w/ an error object.
|
||||||
|
pub fn rejectError(
|
||||||
|
self: PromiseResolver,
|
||||||
|
comptime source: []const u8,
|
||||||
|
err: RejectError,
|
||||||
|
) void {
|
||||||
|
const handle = switch (err) {
|
||||||
|
.generic_error => |msg| self.local.isolate.createError(msg),
|
||||||
|
.range_error => |msg| self.local.isolate.createRangeError(msg),
|
||||||
|
.reference_error => |msg| self.local.isolate.createReferenceError(msg),
|
||||||
|
.syntax_error => |msg| self.local.isolate.createSyntaxError(msg),
|
||||||
|
.type_error => |msg| self.local.isolate.createTypeError(msg),
|
||||||
|
// "Exceptional".
|
||||||
|
.dom_exception => |exception| {
|
||||||
|
self._reject(DOMException.fromError(exception.err) orelse unreachable) catch |reject_err| {
|
||||||
|
log.err(.bug, "rejectDomException", .{ .source = source, .err = reject_err, .persistent = false });
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
|
||||||
|
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn _reject(self: PromiseResolver, value: anytype) !void {
|
fn _reject(self: PromiseResolver, value: anytype) !void {
|
||||||
const local = self.local;
|
const local = self.local;
|
||||||
const js_val = try local.zigValueToJs(value, .{});
|
const js_val = try local.zigValueToJs(value, .{});
|
||||||
@@ -79,7 +119,7 @@ pub fn persist(self: PromiseResolver) !Global {
|
|||||||
var ctx = self.local.ctx;
|
var ctx = self.local.ctx;
|
||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
try ctx.global_promise_resolvers.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
return .{ .handle = global };
|
return .{ .handle = global };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(self: *Scheduler) !?u64 {
|
pub fn run(self: *Scheduler) !void {
|
||||||
_ = try self.runQueue(&self.low_priority);
|
const now = milliTimestamp(.monotonic);
|
||||||
return self.runQueue(&self.high_priority);
|
try self.runQueue(&self.low_priority, now);
|
||||||
|
try self.runQueue(&self.high_priority, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hasReadyTasks(self: *Scheduler) bool {
|
pub fn hasReadyTasks(self: *Scheduler) bool {
|
||||||
@@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool {
|
|||||||
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
|
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
|
pub fn msToNextHigh(self: *Scheduler) ?u64 {
|
||||||
if (queue.count() == 0) {
|
const task = self.high_priority.peek() orelse return null;
|
||||||
return null;
|
const now = milliTimestamp(.monotonic);
|
||||||
|
if (task.run_at <= now) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return @intCast(task.run_at - now);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = milliTimestamp(.monotonic);
|
fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void {
|
||||||
|
if (queue.count() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
while (queue.peek()) |*task_| {
|
while (queue.peek()) |*task_| {
|
||||||
if (task_.run_at > now) {
|
if (task_.run_at > now) {
|
||||||
return @intCast(task_.run_at - now);
|
return;
|
||||||
}
|
}
|
||||||
var task = queue.remove();
|
var task = queue.remove();
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
|
|||||||
try self.low_priority.add(task);
|
try self.low_priority.add(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
|
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
|
||||||
|
|||||||
@@ -282,9 +282,13 @@ fn hasNamedIndexedGetter(comptime JsApi: type) bool {
|
|||||||
fn countExternalReferences() comptime_int {
|
fn countExternalReferences() comptime_int {
|
||||||
@setEvalBranchQuota(100_000);
|
@setEvalBranchQuota(100_000);
|
||||||
|
|
||||||
// +1 for the illegal constructor callback
|
var count: comptime_int = 0;
|
||||||
var count: comptime_int = 1;
|
|
||||||
var has_non_template_property: bool = false;
|
// +1 for the illegal constructor callback shared by various types
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
// +1 for the noop function shared by various types
|
||||||
|
count += 1;
|
||||||
|
|
||||||
inline for (JsApis) |JsApi| {
|
inline for (JsApis) |JsApi| {
|
||||||
// Constructor (only if explicit)
|
// Constructor (only if explicit)
|
||||||
@@ -304,17 +308,18 @@ fn countExternalReferences() comptime_int {
|
|||||||
const T = @TypeOf(value);
|
const T = @TypeOf(value);
|
||||||
if (T == bridge.Accessor) {
|
if (T == bridge.Accessor) {
|
||||||
count += 1; // getter
|
count += 1; // getter
|
||||||
if (value.setter != null) count += 1; // setter
|
if (value.setter != null) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
} else if (T == bridge.Function) {
|
} else if (T == bridge.Function) {
|
||||||
count += 1;
|
count += 1;
|
||||||
} else if (T == bridge.Property) {
|
|
||||||
if (value.template == false) {
|
|
||||||
has_non_template_property = true;
|
|
||||||
}
|
|
||||||
} else if (T == bridge.Iterator) {
|
} else if (T == bridge.Iterator) {
|
||||||
count += 1;
|
count += 1;
|
||||||
} else if (T == bridge.Indexed) {
|
} else if (T == bridge.Indexed) {
|
||||||
count += 1;
|
count += 1;
|
||||||
|
if (value.enumerator != null) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
} else if (T == bridge.NamedIndexed) {
|
} else if (T == bridge.NamedIndexed) {
|
||||||
count += 1; // getter
|
count += 1; // getter
|
||||||
if (value.setter != null) count += 1;
|
if (value.setter != null) count += 1;
|
||||||
@@ -323,10 +328,6 @@ fn countExternalReferences() comptime_int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has_non_template_property) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In debug mode, add unknown property callbacks for types without NamedIndexed
|
// In debug mode, add unknown property callbacks for types without NamedIndexed
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
inline for (JsApis) |JsApi| {
|
inline for (JsApis) |JsApi| {
|
||||||
@@ -346,7 +347,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
|||||||
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
|
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
|
|
||||||
var has_non_template_property = false;
|
references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
|
||||||
|
idx += 1;
|
||||||
|
|
||||||
inline for (JsApis) |JsApi| {
|
inline for (JsApis) |JsApi| {
|
||||||
if (@hasDecl(JsApi, "constructor")) {
|
if (@hasDecl(JsApi, "constructor")) {
|
||||||
@@ -373,16 +375,16 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
|||||||
} else if (T == bridge.Function) {
|
} else if (T == bridge.Function) {
|
||||||
references[idx] = @bitCast(@intFromPtr(value.func));
|
references[idx] = @bitCast(@intFromPtr(value.func));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
} else if (T == bridge.Property) {
|
|
||||||
if (value.template == false) {
|
|
||||||
has_non_template_property = true;
|
|
||||||
}
|
|
||||||
} else if (T == bridge.Iterator) {
|
} else if (T == bridge.Iterator) {
|
||||||
references[idx] = @bitCast(@intFromPtr(value.func));
|
references[idx] = @bitCast(@intFromPtr(value.func));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
} else if (T == bridge.Indexed) {
|
} else if (T == bridge.Indexed) {
|
||||||
references[idx] = @bitCast(@intFromPtr(value.getter));
|
references[idx] = @bitCast(@intFromPtr(value.getter));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
|
if (value.enumerator) |enumerator| {
|
||||||
|
references[idx] = @bitCast(@intFromPtr(enumerator));
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
} else if (T == bridge.NamedIndexed) {
|
} else if (T == bridge.NamedIndexed) {
|
||||||
references[idx] = @bitCast(@intFromPtr(value.getter));
|
references[idx] = @bitCast(@intFromPtr(value.getter));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
@@ -398,11 +400,6 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has_non_template_property) {
|
|
||||||
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In debug mode, collect unknown property callbacks for types without NamedIndexed
|
// In debug mode, collect unknown property callbacks for types without NamedIndexed
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
inline for (JsApis) |JsApi| {
|
inline for (JsApis) |JsApi| {
|
||||||
@@ -486,8 +483,8 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
|
|||||||
|
|
||||||
// Attaches JsApi members to the prototype template (normal case)
|
// Attaches JsApi members to the prototype template (normal case)
|
||||||
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
|
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
|
||||||
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);
|
|
||||||
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
|
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
|
||||||
|
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
|
||||||
|
|
||||||
const declarations = @typeInfo(JsApi).@"struct".decls;
|
const declarations = @typeInfo(JsApi).@"struct".decls;
|
||||||
var has_named_index_getter = false;
|
var has_named_index_getter = false;
|
||||||
@@ -505,14 +502,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
|||||||
if (value.static) {
|
if (value.static) {
|
||||||
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
|
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
|
||||||
} else {
|
} else {
|
||||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
|
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
std.debug.assert(value.static == false);
|
std.debug.assert(value.static == false);
|
||||||
}
|
}
|
||||||
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
|
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
|
||||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback);
|
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bridge.Function => {
|
bridge.Function => {
|
||||||
@@ -521,16 +518,16 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
|||||||
if (value.static) {
|
if (value.static) {
|
||||||
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
|
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
|
||||||
} else {
|
} else {
|
||||||
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
|
v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bridge.Indexed => {
|
bridge.Indexed => {
|
||||||
var configuration: v8.IndexedPropertyHandlerConfiguration = .{
|
var configuration: v8.IndexedPropertyHandlerConfiguration = .{
|
||||||
.getter = value.getter,
|
.getter = value.getter,
|
||||||
|
.enumerator = value.enumerator,
|
||||||
.setter = null,
|
.setter = null,
|
||||||
.query = null,
|
.query = null,
|
||||||
.deleter = null,
|
.deleter = null,
|
||||||
.enumerator = null,
|
|
||||||
.definer = null,
|
.definer = null,
|
||||||
.descriptor = null,
|
.descriptor = null,
|
||||||
.data = null,
|
.data = null,
|
||||||
@@ -559,7 +556,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
|||||||
v8.v8__Symbol__GetAsyncIterator(isolate)
|
v8.v8__Symbol__GetAsyncIterator(isolate)
|
||||||
else
|
else
|
||||||
v8.v8__Symbol__GetIterator(isolate);
|
v8.v8__Symbol__GetIterator(isolate);
|
||||||
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
|
v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
|
||||||
},
|
},
|
||||||
bridge.Property => {
|
bridge.Property => {
|
||||||
const js_value = switch (value.value) {
|
const js_value = switch (value.value) {
|
||||||
@@ -568,22 +565,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
|||||||
};
|
};
|
||||||
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||||
|
|
||||||
if (value.template == false) {
|
{
|
||||||
// not defined on the template, only on the instance. This
|
const flags = if (value.readonly) v8.ReadOnly + v8.DontDelete else 0;
|
||||||
// is like an Accessor, but because the value is known at
|
v8.v8__Template__Set(@ptrCast(prototype), js_name, js_value, flags);
|
||||||
// compile time, we skip _a lot_ of code and quickly return
|
}
|
||||||
// the hard-coded value
|
|
||||||
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
if (value.template) {
|
||||||
.callback = bridge.Property.getter,
|
// apply it both to the type itself (e.g. Node.Elem)
|
||||||
.data = js_value,
|
|
||||||
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
|
|
||||||
}));
|
|
||||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
|
|
||||||
} else {
|
|
||||||
// apply it both to the type itself
|
|
||||||
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
|
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
|
||||||
// and to instances of the type
|
|
||||||
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bridge.Constructor => {}, // already handled in generateConstructor
|
bridge.Constructor => {}, // already handled in generateConstructor
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
|
|||||||
|
|
||||||
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
|
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
|
||||||
if (comptime global) {
|
if (comptime global) {
|
||||||
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.arena) };
|
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.page_arena) };
|
||||||
}
|
}
|
||||||
return self.toSSOWithAlloc(self.local.call_arena);
|
return self.toSSOWithAlloc(self.local.call_arena);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,4 +134,17 @@ pub const Caught = struct {
|
|||||||
try writer.write(prefix ++ ".line", self.line);
|
try writer.write(prefix ++ ".line", self.line);
|
||||||
try writer.write(prefix ++ ".caught", self.caught);
|
try writer.write(prefix ++ ".caught", self.caught);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: Caught, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
try jw.objectField("exception");
|
||||||
|
try jw.write(self.exception);
|
||||||
|
try jw.objectField("stack");
|
||||||
|
try jw.write(self.stack);
|
||||||
|
try jw.objectField("line");
|
||||||
|
try jw.write(self.line);
|
||||||
|
try jw.objectField("caught");
|
||||||
|
try jw.write(self.caught);
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const v8 = js.v8;
|
|||||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
|
|
||||||
const Value = @This();
|
const Value = @This();
|
||||||
|
|
||||||
@@ -245,6 +246,46 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 {
|
|||||||
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
|
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Currently does not support host objects (Blob, File, etc.) or transferables
|
||||||
|
// which require delegate callbacks to be implemented.
|
||||||
|
pub fn structuredClone(self: Value) !Value {
|
||||||
|
const local = self.local;
|
||||||
|
const v8_context = local.handle;
|
||||||
|
const v8_isolate = local.isolate.handle;
|
||||||
|
|
||||||
|
const size, const data = blk: {
|
||||||
|
const serializer = v8.v8__ValueSerializer__New(v8_isolate, null) orelse return error.JsException;
|
||||||
|
defer v8.v8__ValueSerializer__DELETE(serializer);
|
||||||
|
|
||||||
|
var write_result: v8.MaybeBool = undefined;
|
||||||
|
v8.v8__ValueSerializer__WriteHeader(serializer);
|
||||||
|
v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result);
|
||||||
|
if (!write_result.has_value or !write_result.value) {
|
||||||
|
return error.JsException;
|
||||||
|
}
|
||||||
|
|
||||||
|
var size: usize = undefined;
|
||||||
|
const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException;
|
||||||
|
break :blk .{ size, data };
|
||||||
|
};
|
||||||
|
|
||||||
|
defer v8.v8__ValueSerializer__FreeBuffer(data);
|
||||||
|
|
||||||
|
const cloned_handle = blk: {
|
||||||
|
const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException;
|
||||||
|
defer v8.v8__ValueDeserializer__DELETE(deserializer);
|
||||||
|
|
||||||
|
var read_header_result: v8.MaybeBool = undefined;
|
||||||
|
v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result);
|
||||||
|
if (!read_header_result.has_value or !read_header_result.value) {
|
||||||
|
return error.JsException;
|
||||||
|
}
|
||||||
|
break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException;
|
||||||
|
};
|
||||||
|
|
||||||
|
return .{ .local = local, .handle = cloned_handle };
|
||||||
|
}
|
||||||
|
|
||||||
pub fn persist(self: Value) !Global {
|
pub fn persist(self: Value) !Global {
|
||||||
return self._persist(true);
|
return self._persist(true);
|
||||||
}
|
}
|
||||||
@@ -259,11 +300,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
|
|||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
if (comptime is_global) {
|
if (comptime is_global) {
|
||||||
try ctx.global_values.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
} else {
|
return .{ .handle = global, .temps = {} };
|
||||||
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
|
|
||||||
}
|
}
|
||||||
return .{ .handle = global };
|
try ctx.trackTemp(global);
|
||||||
|
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toZig(self: Value, comptime T: type) !T {
|
pub fn toZig(self: Value, comptime T: type) !T {
|
||||||
@@ -310,15 +351,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
|
|||||||
return js_str.format(writer);
|
return js_str.format(writer);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const Temp = G(0);
|
pub const Temp = G(.temp);
|
||||||
pub const Global = G(1);
|
pub const Global = G(.global);
|
||||||
|
|
||||||
fn G(comptime discriminator: u8) type {
|
const GlobalType = enum(u8) {
|
||||||
|
temp,
|
||||||
|
global,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn G(comptime global_type: GlobalType) type {
|
||||||
return struct {
|
return struct {
|
||||||
handle: v8.Global,
|
handle: v8.Global,
|
||||||
|
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||||
// makes the types different (G(0) != G(1)), without taking up space
|
|
||||||
comptime _: u8 = discriminator,
|
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
@@ -336,5 +380,12 @@ fn G(comptime discriminator: u8) type {
|
|||||||
pub fn isEqual(self: *const Self, other: Value) bool {
|
pub fn isEqual(self: *const Self, other: Value) bool {
|
||||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn release(self: *const Self) void {
|
||||||
|
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||||
|
var g = kv.value;
|
||||||
|
v8.v8__Global__Reset(&g);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const js = @import("js.zig");
|
|||||||
const lp = @import("lightpanda");
|
const lp = @import("lightpanda");
|
||||||
const log = @import("../../log.zig");
|
const log = @import("../../log.zig");
|
||||||
const Page = @import("../Page.zig");
|
const Page = @import("../Page.zig");
|
||||||
|
const Session = @import("../Session.zig");
|
||||||
|
|
||||||
const v8 = js.v8;
|
const v8 = js.v8;
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ pub fn Builder(comptime T: type) type {
|
|||||||
return Function.init(T, func, opts);
|
return Function.init(T, func, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn indexed(comptime getter_func: anytype, comptime opts: Indexed.Opts) Indexed {
|
pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
|
||||||
return Indexed.init(T, getter_func, opts);
|
return Indexed.init(T, getter_func, enumerator_func, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
|
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
|
||||||
@@ -104,24 +105,23 @@ pub fn Builder(comptime T: type) type {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer {
|
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer {
|
||||||
return .{
|
return .{
|
||||||
.from_zig = struct {
|
.from_zig = struct {
|
||||||
fn wrap(ptr: *anyopaque) void {
|
fn wrap(ptr: *anyopaque, session: *Session) void {
|
||||||
func(@ptrCast(@alignCast(ptr)), true);
|
func(@ptrCast(@alignCast(ptr)), true, session);
|
||||||
}
|
}
|
||||||
}.wrap,
|
}.wrap,
|
||||||
|
|
||||||
.from_v8 = struct {
|
.from_v8 = struct {
|
||||||
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
|
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
|
||||||
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
|
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
|
||||||
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
const fc: *Session.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
||||||
|
|
||||||
const ctx = fc.ctx;
|
|
||||||
const value_ptr = fc.ptr;
|
const value_ptr = fc.ptr;
|
||||||
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
if (fc.identity.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
||||||
func(@ptrCast(@alignCast(value_ptr)), false);
|
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
|
||||||
ctx.release(value_ptr);
|
fc.releaseIdentity();
|
||||||
} else {
|
} else {
|
||||||
// A bit weird, but v8 _requires_ that we release it
|
// A bit weird, but v8 _requires_ that we release it
|
||||||
// If we don't. We'll 100% crash.
|
// If we don't. We'll 100% crash.
|
||||||
@@ -160,6 +160,7 @@ pub const Constructor = struct {
|
|||||||
pub const Function = struct {
|
pub const Function = struct {
|
||||||
static: bool,
|
static: bool,
|
||||||
arity: usize,
|
arity: usize,
|
||||||
|
noop: bool = false,
|
||||||
cache: ?Caller.Function.Opts.Caching = null,
|
cache: ?Caller.Function.Opts.Caching = null,
|
||||||
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
|
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ pub const Function = struct {
|
|||||||
.cache = opts.cache,
|
.cache = opts.cache,
|
||||||
.static = opts.static,
|
.static = opts.static,
|
||||||
.arity = getArity(@TypeOf(func)),
|
.arity = getArity(@TypeOf(func)),
|
||||||
.func = struct {
|
.func = if (opts.noop) noopFunction else struct {
|
||||||
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
||||||
Caller.Function.call(T, handle.?, func, opts);
|
Caller.Function.call(T, handle.?, func, opts);
|
||||||
}
|
}
|
||||||
@@ -176,6 +177,8 @@ pub const Function = struct {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn noopFunction(_: ?*const v8.FunctionCallbackInfo) callconv(.c) void {}
|
||||||
|
|
||||||
fn getArity(comptime T: type) usize {
|
fn getArity(comptime T: type) usize {
|
||||||
var count: usize = 0;
|
var count: usize = 0;
|
||||||
var params = @typeInfo(T).@"fn".params;
|
var params = @typeInfo(T).@"fn".params;
|
||||||
@@ -227,14 +230,17 @@ pub const Accessor = struct {
|
|||||||
|
|
||||||
pub const Indexed = struct {
|
pub const Indexed = struct {
|
||||||
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
|
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
|
||||||
|
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
|
||||||
|
|
||||||
const Opts = struct {
|
const Opts = struct {
|
||||||
as_typed_array: bool = false,
|
as_typed_array: bool = false,
|
||||||
null_as_undefined: bool = false,
|
null_as_undefined: bool = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed {
|
fn init(comptime T: type, comptime getter: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
|
||||||
return .{ .getter = struct {
|
var indexed = Indexed{
|
||||||
|
.enumerator = null,
|
||||||
|
.getter = struct {
|
||||||
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
|
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
|
||||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||||
var caller: Caller = undefined;
|
var caller: Caller = undefined;
|
||||||
@@ -246,7 +252,22 @@ pub const Indexed = struct {
|
|||||||
.null_as_undefined = opts.null_as_undefined,
|
.null_as_undefined = opts.null_as_undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}.wrap };
|
}.wrap,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (@typeInfo(@TypeOf(enumerator)) != .null) {
|
||||||
|
indexed.enumerator = struct {
|
||||||
|
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
|
||||||
|
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||||
|
var caller: Caller = undefined;
|
||||||
|
caller.init(v8_isolate);
|
||||||
|
defer caller.deinit();
|
||||||
|
return caller.getEnumerator(T, enumerator, handle.?, .{});
|
||||||
|
}
|
||||||
|
}.wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexed;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -367,6 +388,7 @@ pub const Callable = struct {
|
|||||||
pub const Property = struct {
|
pub const Property = struct {
|
||||||
value: Value,
|
value: Value,
|
||||||
template: bool,
|
template: bool,
|
||||||
|
readonly: bool,
|
||||||
|
|
||||||
const Value = union(enum) {
|
const Value = union(enum) {
|
||||||
null,
|
null,
|
||||||
@@ -378,30 +400,25 @@ pub const Property = struct {
|
|||||||
|
|
||||||
const Opts = struct {
|
const Opts = struct {
|
||||||
template: bool,
|
template: bool,
|
||||||
|
readonly: bool = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn init(value: Value, opts: Opts) Property {
|
fn init(value: Value, opts: Opts) Property {
|
||||||
return .{
|
return .{
|
||||||
.value = value,
|
.value = value,
|
||||||
.template = opts.template,
|
.template = opts.template,
|
||||||
|
.readonly = opts.readonly,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getter(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
|
||||||
const value = v8.v8__FunctionCallbackInfo__Data(handle.?);
|
|
||||||
var rv: v8.ReturnValue = undefined;
|
|
||||||
v8.v8__FunctionCallbackInfo__GetReturnValue(handle.?, &rv);
|
|
||||||
v8.v8__ReturnValue__Set(rv, value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Finalizer = struct {
|
const Finalizer = struct {
|
||||||
// The finalizer wrapper when called fro Zig. This is only called on
|
// The finalizer wrapper when called from Zig. This is only called on
|
||||||
// Context.deinit
|
// Origin.deinit
|
||||||
from_zig: *const fn (ctx: *anyopaque) void,
|
from_zig: *const fn (ctx: *anyopaque, session: *Session) void,
|
||||||
|
|
||||||
// The finalizer wrapper when called from V8. This may never be called
|
// The finalizer wrapper when called from V8. This may never be called
|
||||||
// (hence why we fallback to calling in Context.denit). If it is called,
|
// (hence why we fallback to calling in Origin.deinit). If it is called,
|
||||||
// it is only ever called after we SetWeak on the Global.
|
// it is only ever called after we SetWeak on the Global.
|
||||||
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
|
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
|
||||||
};
|
};
|
||||||
@@ -706,6 +723,8 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/collections.zig"),
|
@import("../webapi/collections.zig"),
|
||||||
@import("../webapi/Console.zig"),
|
@import("../webapi/Console.zig"),
|
||||||
@import("../webapi/Crypto.zig"),
|
@import("../webapi/Crypto.zig"),
|
||||||
|
@import("../webapi/Permissions.zig"),
|
||||||
|
@import("../webapi/StorageManager.zig"),
|
||||||
@import("../webapi/CSS.zig"),
|
@import("../webapi/CSS.zig"),
|
||||||
@import("../webapi/css/CSSRule.zig"),
|
@import("../webapi/css/CSSRule.zig"),
|
||||||
@import("../webapi/css/CSSRuleList.zig"),
|
@import("../webapi/css/CSSRuleList.zig"),
|
||||||
@@ -713,6 +732,8 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/css/CSSStyleRule.zig"),
|
@import("../webapi/css/CSSStyleRule.zig"),
|
||||||
@import("../webapi/css/CSSStyleSheet.zig"),
|
@import("../webapi/css/CSSStyleSheet.zig"),
|
||||||
@import("../webapi/css/CSSStyleProperties.zig"),
|
@import("../webapi/css/CSSStyleProperties.zig"),
|
||||||
|
@import("../webapi/css/FontFace.zig"),
|
||||||
|
@import("../webapi/css/FontFaceSet.zig"),
|
||||||
@import("../webapi/css/MediaQueryList.zig"),
|
@import("../webapi/css/MediaQueryList.zig"),
|
||||||
@import("../webapi/css/StyleSheetList.zig"),
|
@import("../webapi/css/StyleSheetList.zig"),
|
||||||
@import("../webapi/Document.zig"),
|
@import("../webapi/Document.zig"),
|
||||||
@@ -749,6 +770,7 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/element/html/Custom.zig"),
|
@import("../webapi/element/html/Custom.zig"),
|
||||||
@import("../webapi/element/html/Data.zig"),
|
@import("../webapi/element/html/Data.zig"),
|
||||||
@import("../webapi/element/html/DataList.zig"),
|
@import("../webapi/element/html/DataList.zig"),
|
||||||
|
@import("../webapi/element/html/Details.zig"),
|
||||||
@import("../webapi/element/html/Dialog.zig"),
|
@import("../webapi/element/html/Dialog.zig"),
|
||||||
@import("../webapi/element/html/Directory.zig"),
|
@import("../webapi/element/html/Directory.zig"),
|
||||||
@import("../webapi/element/html/DList.zig"),
|
@import("../webapi/element/html/DList.zig"),
|
||||||
@@ -808,6 +830,8 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/element/svg/Generic.zig"),
|
@import("../webapi/element/svg/Generic.zig"),
|
||||||
@import("../webapi/encoding/TextDecoder.zig"),
|
@import("../webapi/encoding/TextDecoder.zig"),
|
||||||
@import("../webapi/encoding/TextEncoder.zig"),
|
@import("../webapi/encoding/TextEncoder.zig"),
|
||||||
|
@import("../webapi/encoding/TextEncoderStream.zig"),
|
||||||
|
@import("../webapi/encoding/TextDecoderStream.zig"),
|
||||||
@import("../webapi/Event.zig"),
|
@import("../webapi/Event.zig"),
|
||||||
@import("../webapi/event/CompositionEvent.zig"),
|
@import("../webapi/event/CompositionEvent.zig"),
|
||||||
@import("../webapi/event/CustomEvent.zig"),
|
@import("../webapi/event/CustomEvent.zig"),
|
||||||
@@ -824,7 +848,9 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/event/FocusEvent.zig"),
|
@import("../webapi/event/FocusEvent.zig"),
|
||||||
@import("../webapi/event/WheelEvent.zig"),
|
@import("../webapi/event/WheelEvent.zig"),
|
||||||
@import("../webapi/event/TextEvent.zig"),
|
@import("../webapi/event/TextEvent.zig"),
|
||||||
|
@import("../webapi/event/InputEvent.zig"),
|
||||||
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
||||||
|
@import("../webapi/event/SubmitEvent.zig"),
|
||||||
@import("../webapi/MessageChannel.zig"),
|
@import("../webapi/MessageChannel.zig"),
|
||||||
@import("../webapi/MessagePort.zig"),
|
@import("../webapi/MessagePort.zig"),
|
||||||
@import("../webapi/media/MediaError.zig"),
|
@import("../webapi/media/MediaError.zig"),
|
||||||
@@ -844,6 +870,10 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/streams/ReadableStream.zig"),
|
@import("../webapi/streams/ReadableStream.zig"),
|
||||||
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
|
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
|
||||||
@import("../webapi/streams/ReadableStreamDefaultController.zig"),
|
@import("../webapi/streams/ReadableStreamDefaultController.zig"),
|
||||||
|
@import("../webapi/streams/WritableStream.zig"),
|
||||||
|
@import("../webapi/streams/WritableStreamDefaultWriter.zig"),
|
||||||
|
@import("../webapi/streams/WritableStreamDefaultController.zig"),
|
||||||
|
@import("../webapi/streams/TransformStream.zig"),
|
||||||
@import("../webapi/Node.zig"),
|
@import("../webapi/Node.zig"),
|
||||||
@import("../webapi/storage/storage.zig"),
|
@import("../webapi/storage/storage.zig"),
|
||||||
@import("../webapi/URL.zig"),
|
@import("../webapi/URL.zig"),
|
||||||
@@ -857,6 +887,8 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/IdleDeadline.zig"),
|
@import("../webapi/IdleDeadline.zig"),
|
||||||
@import("../webapi/Blob.zig"),
|
@import("../webapi/Blob.zig"),
|
||||||
@import("../webapi/File.zig"),
|
@import("../webapi/File.zig"),
|
||||||
|
@import("../webapi/FileList.zig"),
|
||||||
|
@import("../webapi/FileReader.zig"),
|
||||||
@import("../webapi/Screen.zig"),
|
@import("../webapi/Screen.zig"),
|
||||||
@import("../webapi/VisualViewport.zig"),
|
@import("../webapi/VisualViewport.zig"),
|
||||||
@import("../webapi/PerformanceObserver.zig"),
|
@import("../webapi/PerformanceObserver.zig"),
|
||||||
@@ -865,7 +897,10 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/navigation/NavigationActivation.zig"),
|
@import("../webapi/navigation/NavigationActivation.zig"),
|
||||||
@import("../webapi/canvas/CanvasRenderingContext2D.zig"),
|
@import("../webapi/canvas/CanvasRenderingContext2D.zig"),
|
||||||
@import("../webapi/canvas/WebGLRenderingContext.zig"),
|
@import("../webapi/canvas/WebGLRenderingContext.zig"),
|
||||||
|
@import("../webapi/canvas/OffscreenCanvas.zig"),
|
||||||
|
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
|
||||||
@import("../webapi/SubtleCrypto.zig"),
|
@import("../webapi/SubtleCrypto.zig"),
|
||||||
|
@import("../webapi/CryptoKey.zig"),
|
||||||
@import("../webapi/Selection.zig"),
|
@import("../webapi/Selection.zig"),
|
||||||
@import("../webapi/ImageData.zig"),
|
@import("../webapi/ImageData.zig"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const string = @import("../../string.zig");
|
|||||||
pub const Env = @import("Env.zig");
|
pub const Env = @import("Env.zig");
|
||||||
pub const bridge = @import("bridge.zig");
|
pub const bridge = @import("bridge.zig");
|
||||||
pub const Caller = @import("Caller.zig");
|
pub const Caller = @import("Caller.zig");
|
||||||
|
pub const Origin = @import("Origin.zig");
|
||||||
|
pub const Identity = @import("Identity.zig");
|
||||||
pub const Context = @import("Context.zig");
|
pub const Context = @import("Context.zig");
|
||||||
pub const Local = @import("Local.zig");
|
pub const Local = @import("Local.zig");
|
||||||
pub const Inspector = @import("Inspector.zig");
|
pub const Inspector = @import("Inspector.zig");
|
||||||
@@ -161,13 +163,23 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
|
|||||||
var ctx = self.local.ctx;
|
var ctx = self.local.ctx;
|
||||||
var global: v8.Global = undefined;
|
var global: v8.Global = undefined;
|
||||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||||
try ctx.global_values.append(ctx.arena, global);
|
try ctx.trackGlobal(global);
|
||||||
|
|
||||||
return .{ .handle = global };
|
return .{ .handle = global };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a WebAPI takes a []const u8, then we'll coerce any JS value to that string
|
||||||
|
// so null -> "null". But if a WebAPI takes an optional string, ?[]const u8,
|
||||||
|
// how should we handle null? If the parameter _isn't_ passed, then it's obvious
|
||||||
|
// that it should be null, but what if `null` is passed? It's ambiguous, should
|
||||||
|
// that be null, or "null"? It could depend on the api. So, `null` passed to
|
||||||
|
// ?[]const u8 will be `null`. If you want it to be "null", use a `.js.NullableString`.
|
||||||
|
pub const NullableString = struct {
|
||||||
|
value: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
pub const Exception = struct {
|
pub const Exception = struct {
|
||||||
local: *const Local,
|
local: *const Local,
|
||||||
handle: *const v8.Value,
|
handle: *const v8.Value,
|
||||||
|
|||||||
@@ -19,8 +19,12 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
|
const URL = @import("URL.zig");
|
||||||
|
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||||
|
const CData = @import("webapi/CData.zig");
|
||||||
const Element = @import("webapi/Element.zig");
|
const Element = @import("webapi/Element.zig");
|
||||||
const Node = @import("webapi/Node.zig");
|
const Node = @import("webapi/Node.zig");
|
||||||
|
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
|
||||||
|
|
||||||
pub const Opts = struct {
|
pub const Opts = struct {
|
||||||
// Options for future customization (e.g., dialect)
|
// Options for future customization (e.g., dialect)
|
||||||
@@ -35,7 +39,6 @@ const State = struct {
|
|||||||
|
|
||||||
list_depth: usize = 0,
|
list_depth: usize = 0,
|
||||||
list_stack: [32]ListState = undefined,
|
list_stack: [32]ListState = undefined,
|
||||||
in_pre: bool = false,
|
|
||||||
pre_node: ?*Node = null,
|
pre_node: ?*Node = null,
|
||||||
in_code: bool = false,
|
in_code: bool = false,
|
||||||
in_table: bool = false,
|
in_table: bool = false,
|
||||||
@@ -44,13 +47,6 @@ const State = struct {
|
|||||||
last_char_was_newline: bool = true,
|
last_char_was_newline: bool = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn isBlock(tag: Element.Tag) bool {
|
|
||||||
return switch (tag) {
|
|
||||||
.p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true,
|
|
||||||
else => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shouldAddSpacing(tag: Element.Tag) bool {
|
fn shouldAddSpacing(tag: Element.Tag) bool {
|
||||||
return switch (tag) {
|
return switch (tag) {
|
||||||
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
|
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
|
||||||
@@ -58,206 +54,297 @@ fn shouldAddSpacing(tag: Element.Tag) bool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
|
fn isLayoutBlock(tag: Element.Tag) bool {
|
||||||
if (!state.last_char_was_newline) {
|
return switch (tag) {
|
||||||
try writer.writeByte('\n');
|
.main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
|
||||||
state.last_char_was_newline = true;
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isStandaloneAnchor(el: *Element) bool {
|
||||||
|
const node = el.asNode();
|
||||||
|
const parent = node.parentNode() orelse return false;
|
||||||
|
const parent_el = parent.is(Element) orelse return false;
|
||||||
|
|
||||||
|
if (!isLayoutBlock(parent_el.getTag())) return false;
|
||||||
|
|
||||||
|
var prev = node.previousSibling();
|
||||||
|
while (prev) |p| : (prev = p.previousSibling()) {
|
||||||
|
if (isSignificantText(p)) return false;
|
||||||
|
if (p.is(Element)) |pe| {
|
||||||
|
if (isVisibleElement(pe)) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
var next = node.nextSibling();
|
||||||
_ = opts;
|
while (next) |n| : (next = n.nextSibling()) {
|
||||||
var state = State{};
|
if (isSignificantText(n)) return false;
|
||||||
try render(node, &state, writer, page);
|
if (n.is(Element)) |ne| {
|
||||||
if (!state.last_char_was_newline) {
|
if (isVisibleElement(ne)) break;
|
||||||
try writer.writeByte('\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isSignificantText(node: *Node) bool {
|
||||||
|
const text = node.is(Node.CData.Text) orelse return false;
|
||||||
|
return !isAllWhitespace(text.getWholeText());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isVisibleElement(el: *Element) bool {
|
||||||
|
const tag = el.getTag();
|
||||||
|
return !tag.isMetadata() and tag != .svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getAnchorLabel(el: *Element) ?[]const u8 {
|
||||||
|
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hasBlockDescendant(root: *Node) bool {
|
||||||
|
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
|
||||||
|
while (tw.next()) |el| {
|
||||||
|
if (el.getTag().isBlock()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hasVisibleContent(root: *Node) bool {
|
||||||
|
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
if (isSignificantText(node)) return true;
|
||||||
|
if (node.is(Element)) |el| {
|
||||||
|
if (!isVisibleElement(el)) {
|
||||||
|
tw.skipChildren();
|
||||||
|
} else if (el.getTag() == .img) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = struct {
|
||||||
|
state: State,
|
||||||
|
writer: *std.Io.Writer,
|
||||||
|
page: *Page,
|
||||||
|
|
||||||
|
fn ensureNewline(self: *Context) !void {
|
||||||
|
if (!self.state.last_char_was_newline) {
|
||||||
|
try self.writer.writeByte('\n');
|
||||||
|
self.state.last_char_was_newline = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
|
||||||
switch (node._type) {
|
switch (node._type) {
|
||||||
.document, .document_fragment => {
|
.document, .document_fragment => {
|
||||||
try renderChildren(node, state, writer, page);
|
try self.renderChildren(node);
|
||||||
},
|
},
|
||||||
.element => |el| {
|
.element => |el| {
|
||||||
try renderElement(el, state, writer, page);
|
try self.renderElement(el);
|
||||||
},
|
},
|
||||||
.cdata => |cd| {
|
.cdata => |cd| {
|
||||||
if (node.is(Node.CData.Text)) |_| {
|
if (node.is(Node.CData.Text)) |_| {
|
||||||
var text = cd.getData();
|
var text = cd.getData().str();
|
||||||
if (state.in_pre) {
|
if (self.state.pre_node) |pre| {
|
||||||
if (state.pre_node) |pre| {
|
|
||||||
if (node.parentNode() == pre and node.nextSibling() == null) {
|
if (node.parentNode() == pre and node.nextSibling() == null) {
|
||||||
text = std.mem.trimRight(u8, text, " \t\r\n");
|
text = std.mem.trimRight(u8, text, " \t\r\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
try self.renderText(text);
|
||||||
try renderText(text, state, writer);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
else => {}, // Ignore other node types
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
|
|
||||||
var it = parent.childrenIterator();
|
|
||||||
while (it.next()) |child| {
|
|
||||||
try render(child, state, writer, page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
|
|
||||||
const tag = el.getTag();
|
|
||||||
|
|
||||||
// Skip hidden/metadata elements
|
|
||||||
switch (tag) {
|
|
||||||
.script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return,
|
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderChildren(self: *Context, parent: *Node) !void {
|
||||||
|
var it = parent.childrenIterator();
|
||||||
|
while (it.next()) |child| {
|
||||||
|
try self.render(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderElement(self: *Context, el: *Element) !void {
|
||||||
|
const tag = el.getTag();
|
||||||
|
|
||||||
|
if (!isVisibleElement(el)) return;
|
||||||
|
|
||||||
// --- Opening Tag Logic ---
|
// --- Opening Tag Logic ---
|
||||||
|
|
||||||
// Ensure block elements start on a new line (double newline for paragraphs etc)
|
// Ensure block elements start on a new line (double newline for paragraphs etc)
|
||||||
if (isBlock(tag)) {
|
if (tag.isBlock() and !self.state.in_table) {
|
||||||
if (!state.in_table) {
|
try self.ensureNewline();
|
||||||
try ensureNewline(state, writer);
|
|
||||||
if (shouldAddSpacing(tag)) {
|
if (shouldAddSpacing(tag)) {
|
||||||
// Add an extra newline for spacing between blocks
|
try self.writer.writeByte('\n');
|
||||||
try writer.writeByte('\n');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (tag == .li or tag == .tr) {
|
} else if (tag == .li or tag == .tr) {
|
||||||
try ensureNewline(state, writer);
|
try self.ensureNewline();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefixes
|
// Prefixes
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
.h1 => try writer.writeAll("# "),
|
.h1 => try self.writer.writeAll("# "),
|
||||||
.h2 => try writer.writeAll("## "),
|
.h2 => try self.writer.writeAll("## "),
|
||||||
.h3 => try writer.writeAll("### "),
|
.h3 => try self.writer.writeAll("### "),
|
||||||
.h4 => try writer.writeAll("#### "),
|
.h4 => try self.writer.writeAll("#### "),
|
||||||
.h5 => try writer.writeAll("##### "),
|
.h5 => try self.writer.writeAll("##### "),
|
||||||
.h6 => try writer.writeAll("###### "),
|
.h6 => try self.writer.writeAll("###### "),
|
||||||
.ul => {
|
.ul => {
|
||||||
if (state.list_depth < state.list_stack.len) {
|
if (self.state.list_depth < self.state.list_stack.len) {
|
||||||
state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
|
self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
|
||||||
state.list_depth += 1;
|
self.state.list_depth += 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.ol => {
|
.ol => {
|
||||||
if (state.list_depth < state.list_stack.len) {
|
if (self.state.list_depth < self.state.list_stack.len) {
|
||||||
state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
|
self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
|
||||||
state.list_depth += 1;
|
self.state.list_depth += 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.li => {
|
.li => {
|
||||||
const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
|
const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
|
||||||
for (0..indent) |_| try writer.writeAll(" ");
|
for (0..indent) |_| try self.writer.writeAll(" ");
|
||||||
|
|
||||||
if (state.list_depth > 0) {
|
if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
|
||||||
const current_list = &state.list_stack[state.list_depth - 1];
|
const current_list = &self.state.list_stack[self.state.list_depth - 1];
|
||||||
if (current_list.type == .ordered) {
|
try self.writer.print("{d}. ", .{current_list.index});
|
||||||
try writer.print("{d}. ", .{current_list.index});
|
|
||||||
current_list.index += 1;
|
current_list.index += 1;
|
||||||
} else {
|
} else {
|
||||||
try writer.writeAll("- ");
|
try self.writer.writeAll("- ");
|
||||||
}
|
}
|
||||||
} else {
|
self.state.last_char_was_newline = false;
|
||||||
try writer.writeAll("- ");
|
|
||||||
}
|
|
||||||
state.last_char_was_newline = false;
|
|
||||||
},
|
},
|
||||||
.table => {
|
.table => {
|
||||||
state.in_table = true;
|
self.state.in_table = true;
|
||||||
state.table_row_index = 0;
|
self.state.table_row_index = 0;
|
||||||
state.table_col_count = 0;
|
self.state.table_col_count = 0;
|
||||||
},
|
},
|
||||||
.tr => {
|
.tr => {
|
||||||
state.table_col_count = 0;
|
self.state.table_col_count = 0;
|
||||||
try writer.writeByte('|');
|
try self.writer.writeByte('|');
|
||||||
},
|
},
|
||||||
.td, .th => {
|
.td, .th => {
|
||||||
// Note: leading pipe handled by previous cell closing or tr opening
|
// Note: leading pipe handled by previous cell closing or tr opening
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
try writer.writeByte(' ');
|
try self.writer.writeByte(' ');
|
||||||
},
|
},
|
||||||
.blockquote => {
|
.blockquote => {
|
||||||
try writer.writeAll("> ");
|
try self.writer.writeAll("> ");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.pre => {
|
.pre => {
|
||||||
try writer.writeAll("```\n");
|
try self.writer.writeAll("```\n");
|
||||||
state.in_pre = true;
|
self.state.pre_node = el.asNode();
|
||||||
state.pre_node = el.asNode();
|
self.state.last_char_was_newline = true;
|
||||||
state.last_char_was_newline = true;
|
|
||||||
},
|
},
|
||||||
.code => {
|
.code => {
|
||||||
if (!state.in_pre) {
|
if (self.state.pre_node == null) {
|
||||||
try writer.writeByte('`');
|
try self.writer.writeByte('`');
|
||||||
state.in_code = true;
|
self.state.in_code = true;
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.b, .strong => {
|
.b, .strong => {
|
||||||
try writer.writeAll("**");
|
try self.writer.writeAll("**");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.i, .em => {
|
.i, .em => {
|
||||||
try writer.writeAll("*");
|
try self.writer.writeAll("*");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.s, .del => {
|
.s, .del => {
|
||||||
try writer.writeAll("~~");
|
try self.writer.writeAll("~~");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.hr => {
|
.hr => {
|
||||||
try writer.writeAll("---\n");
|
try self.writer.writeAll("---\n");
|
||||||
state.last_char_was_newline = true;
|
self.state.last_char_was_newline = true;
|
||||||
return; // Void element
|
return;
|
||||||
},
|
},
|
||||||
.br => {
|
.br => {
|
||||||
if (state.in_table) {
|
if (self.state.in_table) {
|
||||||
try writer.writeByte(' ');
|
try self.writer.writeByte(' ');
|
||||||
} else {
|
} else {
|
||||||
try writer.writeByte('\n');
|
try self.writer.writeByte('\n');
|
||||||
state.last_char_was_newline = true;
|
self.state.last_char_was_newline = true;
|
||||||
}
|
}
|
||||||
return; // Void element
|
return;
|
||||||
},
|
},
|
||||||
.img => {
|
.img => {
|
||||||
try writer.writeAll(";
|
try self.writer.writeAll("](");
|
||||||
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
|
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||||
try writer.writeAll(src);
|
const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src;
|
||||||
|
try self.writer.writeAll(absolute_src);
|
||||||
}
|
}
|
||||||
try writer.writeAll(")");
|
try self.writer.writeAll(")");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
return; // Treat as void
|
return;
|
||||||
},
|
},
|
||||||
.anchor => {
|
.anchor => {
|
||||||
try writer.writeByte('[');
|
const has_content = hasVisibleContent(el.asNode());
|
||||||
try renderChildren(el.asNode(), state, writer, page);
|
const label = getAnchorLabel(el);
|
||||||
try writer.writeAll("](");
|
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
|
||||||
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
|
|
||||||
try writer.writeAll(href);
|
if (!has_content and label == null and href_raw == null) return;
|
||||||
|
|
||||||
|
const has_block = hasBlockDescendant(el.asNode());
|
||||||
|
const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
|
||||||
|
|
||||||
|
if (has_block) {
|
||||||
|
try self.renderChildren(el.asNode());
|
||||||
|
if (href) |h| {
|
||||||
|
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||||
|
try self.writer.writeAll("([](");
|
||||||
|
try self.writer.writeAll(h);
|
||||||
|
try self.writer.writeAll("))\n");
|
||||||
|
self.state.last_char_was_newline = true;
|
||||||
}
|
}
|
||||||
try writer.writeByte(')');
|
return;
|
||||||
state.last_char_was_newline = false;
|
}
|
||||||
|
|
||||||
|
if (isStandaloneAnchor(el)) {
|
||||||
|
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||||
|
try self.writer.writeByte('[');
|
||||||
|
if (has_content) {
|
||||||
|
try self.renderChildren(el.asNode());
|
||||||
|
} else {
|
||||||
|
try self.writer.writeAll(label orelse "");
|
||||||
|
}
|
||||||
|
try self.writer.writeAll("](");
|
||||||
|
if (href) |h| {
|
||||||
|
try self.writer.writeAll(h);
|
||||||
|
}
|
||||||
|
try self.writer.writeAll(")\n");
|
||||||
|
self.state.last_char_was_newline = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.writer.writeByte('[');
|
||||||
|
if (has_content) {
|
||||||
|
try self.renderChildren(el.asNode());
|
||||||
|
} else {
|
||||||
|
try self.writer.writeAll(label orelse "");
|
||||||
|
}
|
||||||
|
try self.writer.writeAll("](");
|
||||||
|
if (href) |h| {
|
||||||
|
try self.writer.writeAll(h);
|
||||||
|
}
|
||||||
|
try self.writer.writeByte(')');
|
||||||
|
self.state.last_char_was_newline = false;
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
.input => {
|
.input => {
|
||||||
if (el.getAttributeSafe(comptime .wrap("type"))) |type_attr| {
|
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
|
||||||
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
|
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
|
||||||
if (el.getAttributeSafe(comptime .wrap("checked"))) |_| {
|
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
|
||||||
try writer.writeAll("[x] ");
|
try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
|
||||||
} else {
|
self.state.last_char_was_newline = false;
|
||||||
try writer.writeAll("[ ] ");
|
|
||||||
}
|
|
||||||
state.last_char_was_newline = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
@@ -265,97 +352,85 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Render Children ---
|
// --- Render Children ---
|
||||||
try renderChildren(el.asNode(), state, writer, page);
|
try self.renderChildren(el.asNode());
|
||||||
|
|
||||||
// --- Closing Tag Logic ---
|
// --- Closing Tag Logic ---
|
||||||
|
|
||||||
// Suffixes
|
// Suffixes
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
.pre => {
|
.pre => {
|
||||||
if (!state.last_char_was_newline) {
|
if (!self.state.last_char_was_newline) {
|
||||||
try writer.writeByte('\n');
|
try self.writer.writeByte('\n');
|
||||||
}
|
}
|
||||||
try writer.writeAll("```\n");
|
try self.writer.writeAll("```\n");
|
||||||
state.in_pre = false;
|
self.state.pre_node = null;
|
||||||
state.pre_node = null;
|
self.state.last_char_was_newline = true;
|
||||||
state.last_char_was_newline = true;
|
|
||||||
},
|
},
|
||||||
.code => {
|
.code => {
|
||||||
if (!state.in_pre) {
|
if (self.state.pre_node == null) {
|
||||||
try writer.writeByte('`');
|
try self.writer.writeByte('`');
|
||||||
state.in_code = false;
|
self.state.in_code = false;
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.b, .strong => {
|
.b, .strong => {
|
||||||
try writer.writeAll("**");
|
try self.writer.writeAll("**");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.i, .em => {
|
.i, .em => {
|
||||||
try writer.writeAll("*");
|
try self.writer.writeAll("*");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.s, .del => {
|
.s, .del => {
|
||||||
try writer.writeAll("~~");
|
try self.writer.writeAll("~~");
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
.blockquote => {},
|
.blockquote => {},
|
||||||
.ul, .ol => {
|
.ul, .ol => {
|
||||||
if (state.list_depth > 0) state.list_depth -= 1;
|
if (self.state.list_depth > 0) self.state.list_depth -= 1;
|
||||||
},
|
},
|
||||||
.table => {
|
.table => {
|
||||||
state.in_table = false;
|
self.state.in_table = false;
|
||||||
},
|
},
|
||||||
.tr => {
|
.tr => {
|
||||||
try writer.writeByte('\n');
|
try self.writer.writeByte('\n');
|
||||||
if (state.table_row_index == 0) {
|
if (self.state.table_row_index == 0) {
|
||||||
try writer.writeByte('|');
|
try self.writer.writeByte('|');
|
||||||
var i: usize = 0;
|
for (0..self.state.table_col_count) |_| {
|
||||||
while (i < state.table_col_count) : (i += 1) {
|
try self.writer.writeAll("---|");
|
||||||
try writer.writeAll("---|");
|
|
||||||
}
|
}
|
||||||
try writer.writeByte('\n');
|
try self.writer.writeByte('\n');
|
||||||
}
|
}
|
||||||
state.table_row_index += 1;
|
self.state.table_row_index += 1;
|
||||||
state.last_char_was_newline = true;
|
self.state.last_char_was_newline = true;
|
||||||
},
|
},
|
||||||
.td, .th => {
|
.td, .th => {
|
||||||
try writer.writeAll(" |");
|
try self.writer.writeAll(" |");
|
||||||
state.table_col_count += 1;
|
self.state.table_col_count += 1;
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
},
|
},
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-block newlines
|
// Post-block newlines
|
||||||
if (isBlock(tag)) {
|
if (tag.isBlock() and !self.state.in_table) {
|
||||||
if (!state.in_table) {
|
try self.ensureNewline();
|
||||||
try ensureNewline(state, writer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
|
fn renderText(self: *Context, text: []const u8) !void {
|
||||||
if (text.len == 0) return;
|
if (text.len == 0) return;
|
||||||
|
|
||||||
if (state.in_pre) {
|
if (self.state.pre_node) |_| {
|
||||||
try writer.writeAll(text);
|
try self.writer.writeAll(text);
|
||||||
if (text.len > 0 and text[text.len - 1] == '\n') {
|
self.state.last_char_was_newline = text[text.len - 1] == '\n';
|
||||||
state.last_char_was_newline = true;
|
|
||||||
} else {
|
|
||||||
state.last_char_was_newline = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for pure whitespace
|
// Check for pure whitespace
|
||||||
const is_all_whitespace = for (text) |c| {
|
if (isAllWhitespace(text)) {
|
||||||
if (!std.ascii.isWhitespace(c)) break false;
|
if (!self.state.last_char_was_newline) {
|
||||||
} else true;
|
try self.writer.writeByte(' ');
|
||||||
|
|
||||||
if (is_all_whitespace) {
|
|
||||||
if (!state.last_char_was_newline) {
|
|
||||||
try writer.writeByte(' ');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,45 +439,53 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
|
|||||||
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
|
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
|
||||||
var first = true;
|
var first = true;
|
||||||
while (it.next()) |word| {
|
while (it.next()) |word| {
|
||||||
if (first) {
|
if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
|
||||||
if (!state.last_char_was_newline) {
|
try self.writer.writeByte(' ');
|
||||||
if (text.len > 0 and std.ascii.isWhitespace(text[0])) {
|
|
||||||
try writer.writeByte(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try writer.writeByte(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try escapeMarkdown(writer, word);
|
try self.escape(word);
|
||||||
state.last_char_was_newline = false;
|
self.state.last_char_was_newline = false;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle trailing whitespace from the original text
|
// Handle trailing whitespace from the original text
|
||||||
if (!first and !state.last_char_was_newline) {
|
if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
|
||||||
if (text.len > 0 and std.ascii.isWhitespace(text[text.len - 1])) {
|
try self.writer.writeByte(' ');
|
||||||
try writer.writeByte(' ');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
|
fn escape(self: *Context, text: []const u8) !void {
|
||||||
for (text) |c| {
|
for (text) |c| {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
|
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
|
||||||
try writer.writeByte('\\');
|
try self.writer.writeByte('\\');
|
||||||
try writer.writeByte(c);
|
try self.writer.writeByte(c);
|
||||||
},
|
},
|
||||||
else => try writer.writeByte(c),
|
else => try self.writer.writeByte(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||||
|
_ = opts;
|
||||||
|
var ctx: Context = .{
|
||||||
|
.state = .{},
|
||||||
|
.writer = writer,
|
||||||
|
.page = page,
|
||||||
|
};
|
||||||
|
try ctx.render(node);
|
||||||
|
if (!ctx.state.last_char_was_newline) {
|
||||||
|
try writer.writeByte('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
|
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
const page = try testing.test_session.createPage();
|
const page = try testing.test_session.createPage();
|
||||||
defer testing.test_session.removePage();
|
defer testing.test_session.removePage();
|
||||||
|
page.url = "http://localhost/";
|
||||||
|
|
||||||
const doc = page.window._document;
|
const doc = page.window._document;
|
||||||
|
|
||||||
const div = try doc.createElement("div", null, page);
|
const div = try doc.createElement("div", null, page);
|
||||||
@@ -415,35 +498,35 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
|
|||||||
try testing.expectString(expected, aw.written());
|
try testing.expectString(expected, aw.written());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: basic" {
|
test "browser.markdown: basic" {
|
||||||
try testMarkdownHTML("Hello world", "Hello world\n");
|
try testMarkdownHTML("Hello world", "Hello world\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: whitespace" {
|
test "browser.markdown: whitespace" {
|
||||||
try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
|
try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: escaping" {
|
test "browser.markdown: escaping" {
|
||||||
try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
|
try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: strikethrough" {
|
test "browser.markdown: strikethrough" {
|
||||||
try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
|
try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: task list" {
|
test "browser.markdown: task list" {
|
||||||
try testMarkdownHTML(
|
try testMarkdownHTML(
|
||||||
\\<input type="checkbox" checked><input type="checkbox">
|
\\<input type="checkbox" checked><input type="checkbox">
|
||||||
, "[x] [ ] \n");
|
, "[x] [ ] \n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: ordered list" {
|
test "browser.markdown: ordered list" {
|
||||||
try testMarkdownHTML(
|
try testMarkdownHTML(
|
||||||
\\<ol><li>First</li><li>Second</li></ol>
|
\\<ol><li>First</li><li>Second</li></ol>
|
||||||
, "1. First\n2. Second\n");
|
, "1. First\n2. Second\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: table" {
|
test "browser.markdown: table" {
|
||||||
try testMarkdownHTML(
|
try testMarkdownHTML(
|
||||||
\\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
|
\\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
|
||||||
\\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
|
\\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
|
||||||
@@ -456,7 +539,7 @@ test "markdown: table" {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: nested lists" {
|
test "browser.markdown: nested lists" {
|
||||||
try testMarkdownHTML(
|
try testMarkdownHTML(
|
||||||
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
|
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
|
||||||
,
|
,
|
||||||
@@ -466,19 +549,19 @@ test "markdown: nested lists" {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: blockquote" {
|
test "browser.markdown: blockquote" {
|
||||||
try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
|
try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: links" {
|
test "browser.markdown: links" {
|
||||||
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
|
try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: images" {
|
test "browser.markdown: images" {
|
||||||
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "\n");
|
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: headings" {
|
test "browser.markdown: headings" {
|
||||||
try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
|
try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
|
||||||
\\
|
\\
|
||||||
\\# Title
|
\\# Title
|
||||||
@@ -488,7 +571,7 @@ test "markdown: headings" {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "markdown: code" {
|
test "browser.markdown: code" {
|
||||||
try testMarkdownHTML(
|
try testMarkdownHTML(
|
||||||
\\<p>Use git push</p>
|
\\<p>Use git push</p>
|
||||||
\\<pre><code>line 1
|
\\<pre><code>line 1
|
||||||
@@ -504,3 +587,106 @@ test "markdown: code" {
|
|||||||
\\
|
\\
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: block link" {
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<a href="https://example.com">
|
||||||
|
\\ <h3>Title</h3>
|
||||||
|
\\ <p>Description</p>
|
||||||
|
\\</a>
|
||||||
|
,
|
||||||
|
\\
|
||||||
|
\\### Title
|
||||||
|
\\
|
||||||
|
\\Description
|
||||||
|
\\([](https://example.com))
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: inline link" {
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<p>Visit <a href="https://example.com">Example</a>.</p>
|
||||||
|
,
|
||||||
|
\\
|
||||||
|
\\Visit [Example](https://example.com).
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: standalone anchors" {
|
||||||
|
// Inside main, with whitespace between anchors -> treated as blocks
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<main>
|
||||||
|
\\ <a href="1">Link 1</a>
|
||||||
|
\\ <a href="2">Link 2</a>
|
||||||
|
\\</main>
|
||||||
|
,
|
||||||
|
\\[Link 1](http://localhost/1)
|
||||||
|
\\[Link 2](http://localhost/2)
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: mixed anchors in main" {
|
||||||
|
// Anchors surrounded by text should remain inline
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<main>
|
||||||
|
\\ Welcome <a href="1">Link 1</a>.
|
||||||
|
\\</main>
|
||||||
|
,
|
||||||
|
\\Welcome [Link 1](http://localhost/1).
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: skip empty links" {
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<a href="/"></a>
|
||||||
|
\\<a href="/"><svg></svg></a>
|
||||||
|
,
|
||||||
|
\\[](http://localhost/)
|
||||||
|
\\[](http://localhost/)
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: resolve links" {
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
const page = try testing.test_session.createPage();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
page.url = "https://example.com/a/index.html";
|
||||||
|
|
||||||
|
const doc = page.window._document;
|
||||||
|
const div = try doc.createElement("div", null, page);
|
||||||
|
try page.parseHtmlAsChildren(div.asNode(),
|
||||||
|
\\<a href="b">Link</a>
|
||||||
|
\\<img src="../c.png" alt="Img">
|
||||||
|
\\<a href="/my page">Space</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||||
|
defer aw.deinit();
|
||||||
|
try dump(div.asNode(), .{}, &aw.writer, page);
|
||||||
|
|
||||||
|
try testing.expectString(
|
||||||
|
\\[Link](https://example.com/a/b)
|
||||||
|
\\
|
||||||
|
\\[Space](https://example.com/my%20page)
|
||||||
|
\\
|
||||||
|
, aw.written());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.markdown: anchor fallback label" {
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
|
||||||
|
, "[Discord Server](http://localhost/discord)\n");
|
||||||
|
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<a href="/search" title="Search Site"><svg></svg></a>
|
||||||
|
, "[Search Site](http://localhost/search)\n");
|
||||||
|
|
||||||
|
try testMarkdownHTML(
|
||||||
|
\\<a href="/no-label"><svg></svg></a>
|
||||||
|
, "[](http://localhost/no-label)\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const h5e = @import("html5ever.zig");
|
|||||||
const Page = @import("../Page.zig");
|
const Page = @import("../Page.zig");
|
||||||
const Node = @import("../webapi/Node.zig");
|
const Node = @import("../webapi/Node.zig");
|
||||||
const Element = @import("../webapi/Element.zig");
|
const Element = @import("../webapi/Element.zig");
|
||||||
|
|
||||||
|
pub const AttributeIterator = h5e.AttributeIterator;
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||||
|
|
||||||
|
|||||||
489
src/browser/structured_data.zig
Normal file
489
src/browser/structured_data.zig
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
// 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 Page = @import("Page.zig");
|
||||||
|
const URL = @import("URL.zig");
|
||||||
|
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||||
|
const Element = @import("webapi/Element.zig");
|
||||||
|
const Node = @import("webapi/Node.zig");
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
/// Key-value pair for structured data properties.
|
||||||
|
pub const Property = struct {
|
||||||
|
key: []const u8,
|
||||||
|
value: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const AlternateLink = struct {
|
||||||
|
href: []const u8,
|
||||||
|
hreflang: ?[]const u8,
|
||||||
|
type: ?[]const u8,
|
||||||
|
title: ?[]const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const StructuredData = struct {
|
||||||
|
json_ld: []const []const u8,
|
||||||
|
open_graph: []const Property,
|
||||||
|
twitter_card: []const Property,
|
||||||
|
meta: []const Property,
|
||||||
|
links: []const Property,
|
||||||
|
alternate: []const AlternateLink,
|
||||||
|
|
||||||
|
pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
|
||||||
|
try jw.objectField("jsonLd");
|
||||||
|
try jw.write(self.json_ld);
|
||||||
|
|
||||||
|
try jw.objectField("openGraph");
|
||||||
|
try writeProperties(jw, self.open_graph);
|
||||||
|
|
||||||
|
try jw.objectField("twitterCard");
|
||||||
|
try writeProperties(jw, self.twitter_card);
|
||||||
|
|
||||||
|
try jw.objectField("meta");
|
||||||
|
try writeProperties(jw, self.meta);
|
||||||
|
|
||||||
|
try jw.objectField("links");
|
||||||
|
try writeProperties(jw, self.links);
|
||||||
|
|
||||||
|
if (self.alternate.len > 0) {
|
||||||
|
try jw.objectField("alternate");
|
||||||
|
try jw.beginArray();
|
||||||
|
for (self.alternate) |alt| {
|
||||||
|
try jw.beginObject();
|
||||||
|
try jw.objectField("href");
|
||||||
|
try jw.write(alt.href);
|
||||||
|
if (alt.hreflang) |v| {
|
||||||
|
try jw.objectField("hreflang");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
if (alt.type) |v| {
|
||||||
|
try jw.objectField("type");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
if (alt.title) |v| {
|
||||||
|
try jw.objectField("title");
|
||||||
|
try jw.write(v);
|
||||||
|
}
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
try jw.endArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Serializes properties as a JSON object. When a key appears multiple times
|
||||||
|
/// (e.g. multiple og:image tags), values are grouped into an array.
|
||||||
|
/// Alternatives considered: always-array values (verbose), or an array of
|
||||||
|
/// {key, value} pairs (preserves order but less ergonomic for consumers).
|
||||||
|
fn writeProperties(jw: anytype, properties: []const Property) !void {
|
||||||
|
try jw.beginObject();
|
||||||
|
for (properties, 0..) |prop, i| {
|
||||||
|
// Skip keys already written by an earlier occurrence.
|
||||||
|
var already_written = false;
|
||||||
|
for (properties[0..i]) |prev| {
|
||||||
|
if (std.mem.eql(u8, prev.key, prop.key)) {
|
||||||
|
already_written = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (already_written) continue;
|
||||||
|
|
||||||
|
// Count total occurrences to decide string vs array.
|
||||||
|
var count: usize = 0;
|
||||||
|
for (properties) |p| {
|
||||||
|
if (std.mem.eql(u8, p.key, prop.key)) count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField(prop.key);
|
||||||
|
if (count == 1) {
|
||||||
|
try jw.write(prop.value);
|
||||||
|
} else {
|
||||||
|
try jw.beginArray();
|
||||||
|
for (properties) |p| {
|
||||||
|
if (std.mem.eql(u8, p.key, prop.key)) {
|
||||||
|
try jw.write(p.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try jw.endArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try jw.endObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all structured data from the page.
|
||||||
|
pub fn collectStructuredData(
|
||||||
|
root: *Node,
|
||||||
|
arena: Allocator,
|
||||||
|
page: *Page,
|
||||||
|
) !StructuredData {
|
||||||
|
var json_ld: std.ArrayList([]const u8) = .empty;
|
||||||
|
var open_graph: std.ArrayList(Property) = .empty;
|
||||||
|
var twitter_card: std.ArrayList(Property) = .empty;
|
||||||
|
var meta: std.ArrayList(Property) = .empty;
|
||||||
|
var links: std.ArrayList(Property) = .empty;
|
||||||
|
var alternate: std.ArrayList(AlternateLink) = .empty;
|
||||||
|
|
||||||
|
// Extract language from the root <html> element.
|
||||||
|
if (root.is(Element)) |root_el| {
|
||||||
|
if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
|
||||||
|
try meta.append(arena, .{ .key = "language", .value = lang });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Root is document — check documentElement.
|
||||||
|
var children = root.childrenIterator();
|
||||||
|
while (children.next()) |child| {
|
||||||
|
const el = child.is(Element) orelse continue;
|
||||||
|
if (el.getTag() == .html) {
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
|
||||||
|
try meta.append(arena, .{ .key = "language", .value = lang });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tw = TreeWalker.Full.init(root, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
const el = node.is(Element) orelse continue;
|
||||||
|
|
||||||
|
switch (el.getTag()) {
|
||||||
|
.script => {
|
||||||
|
try collectJsonLd(el, arena, &json_ld);
|
||||||
|
tw.skipChildren();
|
||||||
|
},
|
||||||
|
.meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {},
|
||||||
|
.title => try collectTitle(node, arena, &meta),
|
||||||
|
.link => try collectLink(el, arena, page, &links, &alternate),
|
||||||
|
// Skip body subtree for non-JSON-LD — all other metadata is in <head>.
|
||||||
|
// JSON-LD can appear in <body> so we don't skip the whole body.
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.json_ld = json_ld.items,
|
||||||
|
.open_graph = open_graph.items,
|
||||||
|
.twitter_card = twitter_card.items,
|
||||||
|
.meta = meta.items,
|
||||||
|
.links = links.items,
|
||||||
|
.alternate = alternate.items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectJsonLd(
|
||||||
|
el: *Element,
|
||||||
|
arena: Allocator,
|
||||||
|
json_ld: *std.ArrayList([]const u8),
|
||||||
|
) !void {
|
||||||
|
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
|
||||||
|
if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return;
|
||||||
|
|
||||||
|
var buf: std.Io.Writer.Allocating = .init(arena);
|
||||||
|
try el.asNode().getTextContent(&buf.writer);
|
||||||
|
const text = buf.written();
|
||||||
|
if (text.len > 0) {
|
||||||
|
try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectMeta(
|
||||||
|
el: *Element,
|
||||||
|
open_graph: *std.ArrayList(Property),
|
||||||
|
twitter_card: *std.ArrayList(Property),
|
||||||
|
meta: *std.ArrayList(Property),
|
||||||
|
arena: Allocator,
|
||||||
|
) !void {
|
||||||
|
// charset: <meta charset="..."> (no content attribute needed).
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| {
|
||||||
|
try meta.append(arena, .{ .key = "charset", .value = charset });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
|
||||||
|
|
||||||
|
// Open Graph: <meta property="og:...">
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("property"))) |property| {
|
||||||
|
if (std.mem.startsWith(u8, property, "og:")) {
|
||||||
|
try open_graph.append(arena, .{ .key = property[3..], .value = content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Article, profile, etc. are OG sub-namespaces.
|
||||||
|
if (std.mem.startsWith(u8, property, "article:") or
|
||||||
|
std.mem.startsWith(u8, property, "profile:") or
|
||||||
|
std.mem.startsWith(u8, property, "book:") or
|
||||||
|
std.mem.startsWith(u8, property, "music:") or
|
||||||
|
std.mem.startsWith(u8, property, "video:"))
|
||||||
|
{
|
||||||
|
try open_graph.append(arena, .{ .key = property, .value = content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitter Cards: <meta name="twitter:...">
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("name"))) |name| {
|
||||||
|
if (std.mem.startsWith(u8, name, "twitter:")) {
|
||||||
|
try twitter_card.append(arena, .{ .key = name[8..], .value = content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard meta tags by name.
|
||||||
|
const known_names = [_][]const u8{
|
||||||
|
"description", "author", "keywords", "robots",
|
||||||
|
"viewport", "generator", "theme-color",
|
||||||
|
};
|
||||||
|
for (known_names) |known| {
|
||||||
|
if (std.ascii.eqlIgnoreCase(name, known)) {
|
||||||
|
try meta.append(arena, .{ .key = known, .value = content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// http-equiv (e.g. Content-Type, refresh)
|
||||||
|
if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| {
|
||||||
|
try meta.append(arena, .{ .key = http_equiv, .value = content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectTitle(
|
||||||
|
node: *Node,
|
||||||
|
arena: Allocator,
|
||||||
|
meta: *std.ArrayList(Property),
|
||||||
|
) !void {
|
||||||
|
var buf: std.Io.Writer.Allocating = .init(arena);
|
||||||
|
try node.getTextContent(&buf.writer);
|
||||||
|
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
|
||||||
|
if (text.len > 0) {
|
||||||
|
try meta.append(arena, .{ .key = "title", .value = text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectLink(
|
||||||
|
el: *Element,
|
||||||
|
arena: Allocator,
|
||||||
|
page: *Page,
|
||||||
|
links: *std.ArrayList(Property),
|
||||||
|
alternate: *std.ArrayList(AlternateLink),
|
||||||
|
) !void {
|
||||||
|
const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return;
|
||||||
|
const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return;
|
||||||
|
const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href;
|
||||||
|
|
||||||
|
if (std.ascii.eqlIgnoreCase(rel, "alternate")) {
|
||||||
|
try alternate.append(arena, .{
|
||||||
|
.href = href,
|
||||||
|
.hreflang = el.getAttributeSafe(comptime .wrap("hreflang")),
|
||||||
|
.type = el.getAttributeSafe(comptime .wrap("type")),
|
||||||
|
.title = el.getAttributeSafe(comptime .wrap("title")),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevant_rels = [_][]const u8{
|
||||||
|
"canonical", "icon", "manifest", "shortcut icon",
|
||||||
|
"apple-touch-icon", "search", "author", "license",
|
||||||
|
"dns-prefetch", "preconnect",
|
||||||
|
};
|
||||||
|
for (relevant_rels) |known| {
|
||||||
|
if (std.ascii.eqlIgnoreCase(rel, known)) {
|
||||||
|
try links.append(arena, .{ .key = known, .value = href });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
|
||||||
|
fn testStructuredData(html: []const u8) !StructuredData {
|
||||||
|
const page = try testing.test_session.createPage();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
|
||||||
|
const doc = page.window._document;
|
||||||
|
const div = try doc.createElement("div", null, page);
|
||||||
|
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||||
|
|
||||||
|
return collectStructuredData(div.asNode(), page.call_arena, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn findProperty(props: []const Property, key: []const u8) ?[]const u8 {
|
||||||
|
for (props) |p| {
|
||||||
|
if (std.mem.eql(u8, p.key, key)) return p.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: json-ld" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<script type="application/ld+json">
|
||||||
|
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
|
||||||
|
\\</script>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, data.json_ld.len);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: multiple json-ld" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<script type="application/ld+json">{"@type":"Organization"}</script>
|
||||||
|
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
|
||||||
|
\\<script type="text/javascript">var x = 1;</script>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, data.json_ld.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: open graph" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<meta property="og:title" content="My Page">
|
||||||
|
\\<meta property="og:description" content="A description">
|
||||||
|
\\<meta property="og:image" content="https://example.com/img.jpg">
|
||||||
|
\\<meta property="og:url" content="https://example.com">
|
||||||
|
\\<meta property="og:type" content="article">
|
||||||
|
\\<meta property="article:published_time" content="2026-03-10">
|
||||||
|
);
|
||||||
|
try testing.expectEqual(6, data.open_graph.len);
|
||||||
|
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
|
||||||
|
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
|
||||||
|
try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: open graph duplicate keys" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<meta property="og:title" content="My Page">
|
||||||
|
\\<meta property="og:image" content="https://example.com/img1.jpg">
|
||||||
|
\\<meta property="og:image" content="https://example.com/img2.jpg">
|
||||||
|
\\<meta property="og:image" content="https://example.com/img3.jpg">
|
||||||
|
);
|
||||||
|
// Duplicate keys are preserved as separate Property entries.
|
||||||
|
try testing.expectEqual(4, data.open_graph.len);
|
||||||
|
|
||||||
|
// Verify serialization groups duplicates into arrays.
|
||||||
|
const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{});
|
||||||
|
defer testing.allocator.free(json);
|
||||||
|
|
||||||
|
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
|
||||||
|
defer parsed.deinit();
|
||||||
|
const og = parsed.value.object.get("openGraph").?.object;
|
||||||
|
// "title" appears once → string.
|
||||||
|
switch (og.get("title").?) {
|
||||||
|
.string => {},
|
||||||
|
else => return error.TestUnexpectedResult,
|
||||||
|
}
|
||||||
|
// "image" appears 3 times → array.
|
||||||
|
switch (og.get("image").?) {
|
||||||
|
.array => |arr| try testing.expectEqual(3, arr.items.len),
|
||||||
|
else => return error.TestUnexpectedResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: twitter card" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<meta name="twitter:card" content="summary_large_image">
|
||||||
|
\\<meta name="twitter:site" content="@example">
|
||||||
|
\\<meta name="twitter:title" content="My Page">
|
||||||
|
);
|
||||||
|
try testing.expectEqual(3, data.twitter_card.len);
|
||||||
|
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
|
||||||
|
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: meta tags" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<title>Page Title</title>
|
||||||
|
\\<meta name="description" content="A test page">
|
||||||
|
\\<meta name="author" content="Test Author">
|
||||||
|
\\<meta name="keywords" content="test, example">
|
||||||
|
\\<meta name="robots" content="index, follow">
|
||||||
|
);
|
||||||
|
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
|
||||||
|
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
|
||||||
|
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
|
||||||
|
try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?);
|
||||||
|
try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: link elements" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<link rel="canonical" href="https://example.com/page">
|
||||||
|
\\<link rel="icon" href="/favicon.ico">
|
||||||
|
\\<link rel="manifest" href="/manifest.json">
|
||||||
|
\\<link rel="stylesheet" href="/style.css">
|
||||||
|
);
|
||||||
|
try testing.expectEqual(3, data.links.len);
|
||||||
|
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
|
||||||
|
// stylesheet should be filtered out
|
||||||
|
try testing.expectEqual(null, findProperty(data.links, "stylesheet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: alternate links" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
|
||||||
|
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, data.alternate.len);
|
||||||
|
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
|
||||||
|
try testing.expectEqual("French", data.alternate[0].title.?);
|
||||||
|
try testing.expectEqual("de", data.alternate[1].hreflang.?);
|
||||||
|
try testing.expectEqual(null, data.alternate[1].title);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: non-metadata elements ignored" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<div>Just text</div>
|
||||||
|
\\<p>More text</p>
|
||||||
|
\\<a href="/link">Link</a>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(0, data.json_ld.len);
|
||||||
|
try testing.expectEqual(0, data.open_graph.len);
|
||||||
|
try testing.expectEqual(0, data.twitter_card.len);
|
||||||
|
try testing.expectEqual(0, data.meta.len);
|
||||||
|
try testing.expectEqual(0, data.links.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: charset and http-equiv" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<meta charset="utf-8">
|
||||||
|
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
);
|
||||||
|
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
|
||||||
|
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "structured_data: mixed content" {
|
||||||
|
const data = try testStructuredData(
|
||||||
|
\\<title>My Site</title>
|
||||||
|
\\<meta property="og:title" content="OG Title">
|
||||||
|
\\<meta name="twitter:card" content="summary">
|
||||||
|
\\<meta name="description" content="A page">
|
||||||
|
\\<link rel="canonical" href="https://example.com">
|
||||||
|
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, data.json_ld.len);
|
||||||
|
try testing.expectEqual(1, data.open_graph.len);
|
||||||
|
try testing.expectEqual(1, data.twitter_card.len);
|
||||||
|
try testing.expectEqual("My Site", findProperty(data.meta, "title").?);
|
||||||
|
try testing.expectEqual("A page", findProperty(data.meta, "description").?);
|
||||||
|
try testing.expectEqual(1, data.links.len);
|
||||||
|
}
|
||||||
@@ -3,13 +3,68 @@
|
|||||||
|
|
||||||
<script id=animation>
|
<script id=animation>
|
||||||
let a1 = document.createElement('div').animate(null, null);
|
let a1 = document.createElement('div').animate(null, null);
|
||||||
testing.expectEqual('finished', a1.playState);
|
testing.expectEqual('idle', a1.playState);
|
||||||
|
|
||||||
let cb = [];
|
let cb = [];
|
||||||
a1.ready.then(() => { cb.push('ready') });
|
|
||||||
a1.finished.then((x) => {
|
a1.finished.then((x) => {
|
||||||
cb.push('finished');
|
cb.push(a1.playState);
|
||||||
cb.push(x == a1);
|
cb.push(x == a1);
|
||||||
});
|
});
|
||||||
testing.eventually(() => testing.expectEqual(['finished', true], cb));
|
a1.ready.then(() => {
|
||||||
|
cb.push(a1.playState);
|
||||||
|
a1.play();
|
||||||
|
cb.push(a1.playState);
|
||||||
|
});
|
||||||
|
testing.onload(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- <script id=startTime>
|
||||||
|
let a2 = document.createElement('div').animate(null, null);
|
||||||
|
// startTime defaults to null
|
||||||
|
testing.expectEqual(null, a2.startTime);
|
||||||
|
// startTime is settable
|
||||||
|
a2.startTime = 42.5;
|
||||||
|
testing.expectEqual(42.5, a2.startTime);
|
||||||
|
// startTime can be reset to null
|
||||||
|
a2.startTime = null;
|
||||||
|
testing.expectEqual(null, a2.startTime);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=onfinish>
|
||||||
|
let a3 = document.createElement('div').animate(null, null);
|
||||||
|
// onfinish defaults to null
|
||||||
|
testing.expectEqual(null, a3.onfinish);
|
||||||
|
|
||||||
|
let calls = [];
|
||||||
|
// onfinish callback should be scheduled and called asynchronously
|
||||||
|
a3.onfinish = function() { calls.push('finish'); };
|
||||||
|
a3.play();
|
||||||
|
testing.onload(() => testing.expectEqual(['finish'], calls));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=pause>
|
||||||
|
let a4 = document.createElement('div').animate(null, null);
|
||||||
|
let cb4 = [];
|
||||||
|
a4.finished.then((x) => { cb4.push(a4.playState) });
|
||||||
|
a4.ready.then(() => {
|
||||||
|
a4.play();
|
||||||
|
cb4.push(a4.playState)
|
||||||
|
a4.pause();
|
||||||
|
cb4.push(a4.playState)
|
||||||
|
});
|
||||||
|
testing.onload(() => testing.expectEqual(['running', 'paused'], cb4));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=finish>
|
||||||
|
let a5 = document.createElement('div').animate(null, null);
|
||||||
|
testing.expectEqual('idle', a5.playState);
|
||||||
|
|
||||||
|
let cb5 = [];
|
||||||
|
a5.finished.then((x) => { cb5.push(a5.playState) });
|
||||||
|
a5.ready.then(() => {
|
||||||
|
cb5.push(a5.playState);
|
||||||
|
a5.play();
|
||||||
|
});
|
||||||
|
testing.onload(() => testing.expectEqual(['idle', 'finished'], cb5));
|
||||||
|
</script>
|
||||||
|
-->
|
||||||
|
|||||||
@@ -98,6 +98,64 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=mime_parsing>
|
||||||
|
// MIME types are lowercased
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "TEXT/HTML" });
|
||||||
|
testing.expectEqual("text/html", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "Application/JSON" });
|
||||||
|
testing.expectEqual("application/json", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME with parameters - lowercased
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
|
||||||
|
testing.expectEqual("text/html; charset=utf-8", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any ASCII string is accepted and lowercased (no MIME structure validation)
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "invalid" });
|
||||||
|
testing.expectEqual("invalid", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "/" });
|
||||||
|
testing.expectEqual("/", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "ý/x" });
|
||||||
|
testing.expectEqual("", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "text/plàin" });
|
||||||
|
testing.expectEqual("", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control characters cause empty string
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "text/html\x00" });
|
||||||
|
testing.expectEqual("", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty type stays empty
|
||||||
|
{
|
||||||
|
const blob = new Blob([]);
|
||||||
|
testing.expectEqual("", blob.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const blob = new Blob([], { type: "" });
|
||||||
|
testing.expectEqual("", blob.type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id=slice>
|
<script id=slice>
|
||||||
{
|
{
|
||||||
const parts = ["la", "symphonie", "des", "éclairs"];
|
const parts = ["la", "symphonie", "des", "éclairs"];
|
||||||
|
|||||||
@@ -33,3 +33,105 @@
|
|||||||
testing.expectEqual(ctx.fillStyle, "rgba(255, 0, 0, 0.06)");
|
testing.expectEqual(ctx.fillStyle, "rgba(255, 0, 0, 0.06)");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="CanvasRenderingContext2D#createImageData(width, height)">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
|
||||||
|
const imageData = ctx.createImageData(100, 200);
|
||||||
|
testing.expectEqual(true, imageData instanceof ImageData);
|
||||||
|
testing.expectEqual(imageData.width, 100);
|
||||||
|
testing.expectEqual(imageData.height, 200);
|
||||||
|
testing.expectEqual(imageData.data.length, 100 * 200 * 4);
|
||||||
|
testing.expectEqual(true, imageData.data instanceof Uint8ClampedArray);
|
||||||
|
|
||||||
|
// All pixels should be initialized to 0.
|
||||||
|
testing.expectEqual(imageData.data[0], 0);
|
||||||
|
testing.expectEqual(imageData.data[1], 0);
|
||||||
|
testing.expectEqual(imageData.data[2], 0);
|
||||||
|
testing.expectEqual(imageData.data[3], 0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CanvasRenderingContext2D#createImageData(imageData)">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
|
||||||
|
const source = ctx.createImageData(50, 75);
|
||||||
|
const imageData = ctx.createImageData(source);
|
||||||
|
testing.expectEqual(true, imageData instanceof ImageData);
|
||||||
|
testing.expectEqual(imageData.width, 50);
|
||||||
|
testing.expectEqual(imageData.height, 75);
|
||||||
|
testing.expectEqual(imageData.data.length, 50 * 75 * 4);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CanvasRenderingContext2D#putImageData">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
|
||||||
|
const imageData = ctx.createImageData(10, 10);
|
||||||
|
testing.expectEqual(true, imageData instanceof ImageData);
|
||||||
|
// Modify some pixel data.
|
||||||
|
imageData.data[0] = 255;
|
||||||
|
imageData.data[1] = 0;
|
||||||
|
imageData.data[2] = 0;
|
||||||
|
imageData.data[3] = 255;
|
||||||
|
|
||||||
|
// putImageData should not throw.
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
ctx.putImageData(imageData, 10, 20);
|
||||||
|
// With dirty rect parameters.
|
||||||
|
ctx.putImageData(imageData, 0, 0, 0, 0, 5, 5);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CanvasRenderingContext2D#getImageData">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
element.width = 100;
|
||||||
|
element.height = 50;
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, 10, 20);
|
||||||
|
testing.expectEqual(true, imageData instanceof ImageData);
|
||||||
|
testing.expectEqual(imageData.width, 10);
|
||||||
|
testing.expectEqual(imageData.height, 20);
|
||||||
|
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
|
||||||
|
testing.expectEqual(true, imageData.data instanceof Uint8ClampedArray);
|
||||||
|
|
||||||
|
// Undrawn canvas should return transparent black pixels.
|
||||||
|
testing.expectEqual(imageData.data[0], 0);
|
||||||
|
testing.expectEqual(imageData.data[1], 0);
|
||||||
|
testing.expectEqual(imageData.data[2], 0);
|
||||||
|
testing.expectEqual(imageData.data[3], 0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CanvasRenderingContext2D#getImageData invalid">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
|
||||||
|
// Zero or negative width/height should throw IndexSizeError.
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, 0));
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, -5, 10));
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<script id="getter">
|
||||||
|
{
|
||||||
|
const element = document.createElement("canvas");
|
||||||
|
const ctx = element.getContext("2d");
|
||||||
|
testing.expectEqual('10px sans-serif', ctx.font);
|
||||||
|
ctx.font = 'bold 48px serif'
|
||||||
|
testing.expectEqual('bold 48px serif', ctx.font);
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
87
src/browser/tests/canvas/offscreen_canvas.html
Normal file
87
src/browser/tests/canvas/offscreen_canvas.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../testing.js"></script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvas>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(256, 256);
|
||||||
|
testing.expectEqual(true, canvas instanceof OffscreenCanvas);
|
||||||
|
testing.expectEqual(canvas.width, 256);
|
||||||
|
testing.expectEqual(canvas.height, 256);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvas#width>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(100, 200);
|
||||||
|
testing.expectEqual(canvas.width, 100);
|
||||||
|
canvas.width = 300;
|
||||||
|
testing.expectEqual(canvas.width, 300);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvas#height>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(100, 200);
|
||||||
|
testing.expectEqual(canvas.height, 200);
|
||||||
|
canvas.height = 400;
|
||||||
|
testing.expectEqual(canvas.height, 400);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvas#getContext>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(64, 64);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
testing.expectEqual(true, ctx instanceof OffscreenCanvasRenderingContext2D);
|
||||||
|
// We can't really test rendering but let's try to call it at least.
|
||||||
|
ctx.fillRect(0, 0, 10, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvas#convertToBlob>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(64, 64);
|
||||||
|
const promise = canvas.convertToBlob();
|
||||||
|
testing.expectEqual(true, promise instanceof Promise);
|
||||||
|
// The promise should resolve to a Blob (even if empty)
|
||||||
|
promise.then(blob => {
|
||||||
|
testing.expectEqual(true, blob instanceof Blob);
|
||||||
|
testing.expectEqual(blob.size, 0); // Empty since no rendering
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=HTMLCanvasElement#transferControlToOffscreen>
|
||||||
|
{
|
||||||
|
const htmlCanvas = document.createElement("canvas");
|
||||||
|
htmlCanvas.width = 128;
|
||||||
|
htmlCanvas.height = 96;
|
||||||
|
const offscreen = htmlCanvas.transferControlToOffscreen();
|
||||||
|
testing.expectEqual(true, offscreen instanceof OffscreenCanvas);
|
||||||
|
testing.expectEqual(offscreen.width, 128);
|
||||||
|
testing.expectEqual(offscreen.height, 96);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=OffscreenCanvasRenderingContext2D#getImageData>
|
||||||
|
{
|
||||||
|
const canvas = new OffscreenCanvas(100, 50);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, 10, 20);
|
||||||
|
testing.expectEqual(true, imageData instanceof ImageData);
|
||||||
|
testing.expectEqual(imageData.width, 10);
|
||||||
|
testing.expectEqual(imageData.height, 20);
|
||||||
|
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
|
||||||
|
|
||||||
|
// Undrawn canvas should return transparent black pixels.
|
||||||
|
testing.expectEqual(imageData.data[0], 0);
|
||||||
|
testing.expectEqual(imageData.data[1], 0);
|
||||||
|
testing.expectEqual(imageData.data[2], 0);
|
||||||
|
testing.expectEqual(imageData.data[3], 0);
|
||||||
|
|
||||||
|
// Zero or negative dimensions should throw.
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
|
||||||
|
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,4 +4,6 @@
|
|||||||
<script id=comment>
|
<script id=comment>
|
||||||
testing.expectEqual('', new Comment().data);
|
testing.expectEqual('', new Comment().data);
|
||||||
testing.expectEqual('over 9000! ', new Comment('over 9000! ').data);
|
testing.expectEqual('over 9000! ', new Comment('over 9000! ').data);
|
||||||
|
|
||||||
|
testing.expectEqual('null', new Comment(null).data);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<a id="link" href="foo" class="ok">OK</a>
|
<a id="link" href="foo" class="ok">OK</a>
|
||||||
|
|
||||||
<script src="../../testing.js"></script>
|
<script src="../testing.js"></script>
|
||||||
<script id=text>
|
<script id=text>
|
||||||
let t = new Text('foo');
|
let t = new Text('foo');
|
||||||
testing.expectEqual('foo', t.data);
|
testing.expectEqual('foo', t.data);
|
||||||
@@ -16,4 +16,7 @@
|
|||||||
let split = text.splitText('OK'.length);
|
let split = text.splitText('OK'.length);
|
||||||
testing.expectEqual(' modified', split.data);
|
testing.expectEqual(' modified', split.data);
|
||||||
testing.expectEqual('OK', text.data);
|
testing.expectEqual('OK', text.data);
|
||||||
|
|
||||||
|
let x = new Text(null);
|
||||||
|
testing.expectEqual("null", x.data);
|
||||||
</script>
|
</script>
|
||||||
@@ -69,3 +69,11 @@
|
|||||||
testing.expectEqual(true, CSS.supports('z-index', '10'));
|
testing.expectEqual(true, CSS.supports('z-index', '10'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="escape_null_character">
|
||||||
|
{
|
||||||
|
testing.expectEqual('\uFFFD', CSS.escape('\x00'));
|
||||||
|
testing.expectEqual('test\uFFFDvalue', CSS.escape('test\x00value'));
|
||||||
|
testing.expectEqual('\uFFFDabc', CSS.escape('\x00abc'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
63
src/browser/tests/css/font_face.html
Normal file
63
src/browser/tests/css/font_face.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../testing.js"></script>
|
||||||
|
|
||||||
|
<script id="constructor_basic">
|
||||||
|
{
|
||||||
|
const face = new FontFace("TestFont", "url(test.woff)");
|
||||||
|
testing.expectTrue(face instanceof FontFace);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="constructor_name">
|
||||||
|
{
|
||||||
|
testing.expectEqual('FontFace', FontFace.name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="family_property">
|
||||||
|
{
|
||||||
|
const face = new FontFace("MyFont", "url(font.woff2)");
|
||||||
|
testing.expectEqual("MyFont", face.family);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="status_is_loaded">
|
||||||
|
{
|
||||||
|
const face = new FontFace("F", "url(f.woff)");
|
||||||
|
testing.expectEqual("loaded", face.status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="loaded_is_promise">
|
||||||
|
{
|
||||||
|
const face = new FontFace("F", "url(f.woff)");
|
||||||
|
testing.expectTrue(face.loaded instanceof Promise);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="load_returns_promise">
|
||||||
|
{
|
||||||
|
const face = new FontFace("F", "url(f.woff)");
|
||||||
|
testing.expectTrue(face.load() instanceof Promise);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="default_descriptors">
|
||||||
|
{
|
||||||
|
const face = new FontFace("F", "url(f.woff)");
|
||||||
|
testing.expectEqual("normal", face.style);
|
||||||
|
testing.expectEqual("normal", face.weight);
|
||||||
|
testing.expectEqual("normal", face.stretch);
|
||||||
|
testing.expectEqual("normal", face.variant);
|
||||||
|
testing.expectEqual("normal", face.featureSettings);
|
||||||
|
testing.expectEqual("auto", face.display);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_add">
|
||||||
|
{
|
||||||
|
const face = new FontFace("AddedFont", "url(added.woff)");
|
||||||
|
const result = document.fonts.add(face);
|
||||||
|
testing.expectTrue(result === document.fonts);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
80
src/browser/tests/css/font_face_set.html
Normal file
80
src/browser/tests/css/font_face_set.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../testing.js"></script>
|
||||||
|
|
||||||
|
<script id="document_fonts_exists">
|
||||||
|
{
|
||||||
|
testing.expectTrue(document.fonts !== undefined);
|
||||||
|
testing.expectTrue(document.fonts !== null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_same_instance">
|
||||||
|
{
|
||||||
|
// Should return same instance each time
|
||||||
|
const f1 = document.fonts;
|
||||||
|
const f2 = document.fonts;
|
||||||
|
testing.expectTrue(f1 === f2);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_status">
|
||||||
|
{
|
||||||
|
testing.expectEqual('loaded', document.fonts.status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_size">
|
||||||
|
{
|
||||||
|
testing.expectEqual(0, document.fonts.size);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_ready_is_promise">
|
||||||
|
{
|
||||||
|
const ready = document.fonts.ready;
|
||||||
|
testing.expectTrue(ready instanceof Promise);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_ready_resolves">
|
||||||
|
{
|
||||||
|
let resolved = false;
|
||||||
|
document.fonts.ready.then(() => { resolved = true; });
|
||||||
|
// Promise resolution is async; just confirm .then() does not throw
|
||||||
|
testing.expectTrue(typeof document.fonts.ready.then === 'function');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_check">
|
||||||
|
{
|
||||||
|
testing.expectTrue(document.fonts.check('16px sans-serif'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_constructor_name">
|
||||||
|
{
|
||||||
|
testing.expectEqual('FontFaceSet', document.fonts.constructor.name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="document_fonts_addEventListener">
|
||||||
|
{
|
||||||
|
let loading = false;
|
||||||
|
document.fonts.addEventListener('loading', function() {
|
||||||
|
loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let loadingdone = false;
|
||||||
|
document.fonts.addEventListener('loadingdone', function() {
|
||||||
|
loadingdone = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.fonts.load("italic bold 16px Roboto");
|
||||||
|
|
||||||
|
testing.onload(() => {
|
||||||
|
testing.expectEqual(true, loading);
|
||||||
|
testing.expectEqual(true, loadingdone);
|
||||||
|
});
|
||||||
|
testing.expectEqual(true, true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -205,3 +205,331 @@
|
|||||||
testing.expectEqual('', style.getPropertyPriority('content'));
|
testing.expectEqual('', style.getPropertyPriority('content'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_style_syncs_to_attribute">
|
||||||
|
{
|
||||||
|
// JS style modifications must be reflected in getAttribute.
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
// Named property assignment (element.style.X = ...)
|
||||||
|
div.style.opacity = '0';
|
||||||
|
testing.expectEqual('opacity: 0;', div.getAttribute('style'));
|
||||||
|
|
||||||
|
// Update existing property
|
||||||
|
div.style.opacity = '1';
|
||||||
|
testing.expectEqual('opacity: 1;', div.getAttribute('style'));
|
||||||
|
|
||||||
|
// Add a second property
|
||||||
|
div.style.color = 'red';
|
||||||
|
testing.expectTrue(div.getAttribute('style').includes('opacity: 1'));
|
||||||
|
testing.expectTrue(div.getAttribute('style').includes('color: red'));
|
||||||
|
|
||||||
|
// removeProperty syncs back
|
||||||
|
div.style.removeProperty('opacity');
|
||||||
|
testing.expectTrue(!div.getAttribute('style').includes('opacity'));
|
||||||
|
testing.expectTrue(div.getAttribute('style').includes('color: red'));
|
||||||
|
|
||||||
|
// setCssText syncs back
|
||||||
|
div.style.cssText = 'filter: blur(0px)';
|
||||||
|
testing.expectEqual('filter: blur(0px);', div.getAttribute('style'));
|
||||||
|
|
||||||
|
// setCssText with empty string clears attribute
|
||||||
|
div.style.cssText = '';
|
||||||
|
testing.expectEqual('', div.getAttribute('style'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_outerHTML_reflects_style_changes">
|
||||||
|
{
|
||||||
|
// outerHTML must reflect JS-modified styles (regression test for
|
||||||
|
// DOM serialization reading stale HTML-parsed attribute values).
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('style', 'filter:blur(10px);opacity:0');
|
||||||
|
|
||||||
|
div.style.filter = 'blur(0px)';
|
||||||
|
div.style.opacity = '1';
|
||||||
|
|
||||||
|
const html = div.outerHTML;
|
||||||
|
testing.expectTrue(html.includes('filter: blur(0px)'));
|
||||||
|
testing.expectTrue(html.includes('opacity: 1'));
|
||||||
|
testing.expectTrue(!html.includes('blur(10px)'));
|
||||||
|
testing.expectTrue(!html.includes('opacity:0'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_non_ascii_custom_property">
|
||||||
|
{
|
||||||
|
// Regression test: accessing element.style must not crash when the inline
|
||||||
|
// style attribute contains CSS custom properties with non-ASCII (UTF-8
|
||||||
|
// multibyte) names, such as French accented characters.
|
||||||
|
// The CSS Tokenizer's consumeName() must advance over whole UTF-8 sequences
|
||||||
|
// rather than byte-by-byte to avoid landing on a continuation byte.
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('style',
|
||||||
|
'--color-store-bulles-\u00e9t\u00e9-fg: #6a818f;' +
|
||||||
|
'--color-store-soir\u00e9es-odl-fg: #56b3b3;' +
|
||||||
|
'color: red;'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Must not crash, and ASCII properties that follow non-ASCII ones must be readable.
|
||||||
|
testing.expectEqual('red', div.style.getPropertyValue('color'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
|
||||||
|
{
|
||||||
|
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.style.width = '0';
|
||||||
|
testing.expectEqual('0px', div.style.width);
|
||||||
|
|
||||||
|
div.style.margin = '0';
|
||||||
|
testing.expectEqual('0px', div.style.margin);
|
||||||
|
|
||||||
|
div.style.padding = '0';
|
||||||
|
testing.expectEqual('0px', div.style.padding);
|
||||||
|
|
||||||
|
div.style.top = '0';
|
||||||
|
testing.expectEqual('0px', div.style.top);
|
||||||
|
|
||||||
|
// Scroll properties
|
||||||
|
div.style.scrollMarginTop = '0';
|
||||||
|
testing.expectEqual('0px', div.style.scrollMarginTop);
|
||||||
|
|
||||||
|
div.style.scrollPaddingBottom = '0';
|
||||||
|
testing.expectEqual('0px', div.style.scrollPaddingBottom);
|
||||||
|
|
||||||
|
// Multi-column
|
||||||
|
div.style.columnWidth = '0';
|
||||||
|
testing.expectEqual('0px', div.style.columnWidth);
|
||||||
|
|
||||||
|
div.style.columnRuleWidth = '0';
|
||||||
|
testing.expectEqual('0px', div.style.columnRuleWidth);
|
||||||
|
|
||||||
|
// Outline shorthand
|
||||||
|
div.style.outline = '0';
|
||||||
|
testing.expectEqual('0px', div.style.outline);
|
||||||
|
|
||||||
|
// Shapes
|
||||||
|
div.style.shapeMargin = '0';
|
||||||
|
testing.expectEqual('0px', div.style.shapeMargin);
|
||||||
|
|
||||||
|
// Non-length properties should not be affected
|
||||||
|
div.style.opacity = '0';
|
||||||
|
testing.expectEqual('0', div.style.opacity);
|
||||||
|
|
||||||
|
div.style.zIndex = '0';
|
||||||
|
testing.expectEqual('0', div.style.zIndex);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_normalize_first_baseline">
|
||||||
|
{
|
||||||
|
// "first baseline" should serialize canonically as "baseline"
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.style.alignItems = 'first baseline';
|
||||||
|
testing.expectEqual('baseline', div.style.alignItems);
|
||||||
|
|
||||||
|
div.style.alignContent = 'first baseline';
|
||||||
|
testing.expectEqual('baseline', div.style.alignContent);
|
||||||
|
|
||||||
|
div.style.alignSelf = 'first baseline';
|
||||||
|
testing.expectEqual('baseline', div.style.alignSelf);
|
||||||
|
|
||||||
|
div.style.justifySelf = 'first baseline';
|
||||||
|
testing.expectEqual('baseline', div.style.justifySelf);
|
||||||
|
|
||||||
|
// "last baseline" should remain unchanged
|
||||||
|
div.style.alignItems = 'last baseline';
|
||||||
|
testing.expectEqual('last baseline', div.style.alignItems);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_normalize_duplicate_values">
|
||||||
|
{
|
||||||
|
// For 2-value shorthand properties, "X X" should collapse to "X"
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.style.placeContent = 'center center';
|
||||||
|
testing.expectEqual('center', div.style.placeContent);
|
||||||
|
|
||||||
|
div.style.placeContent = 'start start';
|
||||||
|
testing.expectEqual('start', div.style.placeContent);
|
||||||
|
|
||||||
|
div.style.gap = '10px 10px';
|
||||||
|
testing.expectEqual('10px', div.style.gap);
|
||||||
|
|
||||||
|
// Different values should not collapse
|
||||||
|
div.style.placeContent = 'center start';
|
||||||
|
testing.expectEqual('center start', div.style.placeContent);
|
||||||
|
|
||||||
|
div.style.gap = '10px 20px';
|
||||||
|
testing.expectEqual('10px 20px', div.style.gap);
|
||||||
|
|
||||||
|
// New shorthands
|
||||||
|
div.style.overflow = 'hidden hidden';
|
||||||
|
testing.expectEqual('hidden', div.style.overflow);
|
||||||
|
|
||||||
|
div.style.scrollSnapAlign = 'start start';
|
||||||
|
testing.expectEqual('start', div.style.scrollSnapAlign);
|
||||||
|
|
||||||
|
div.style.overscrollBehavior = 'auto auto';
|
||||||
|
testing.expectEqual('auto', div.style.overscrollBehavior);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleDeclaration_normalize_anchor_size">
|
||||||
|
{
|
||||||
|
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
// Already canonical order - should stay the same
|
||||||
|
div.style.width = 'anchor-size(--foo width)';
|
||||||
|
testing.expectEqual('anchor-size(--foo width)', div.style.width);
|
||||||
|
|
||||||
|
// Non-canonical order - should be reordered
|
||||||
|
div.style.width = 'anchor-size(width --foo)';
|
||||||
|
testing.expectEqual('anchor-size(--foo width)', div.style.width);
|
||||||
|
|
||||||
|
// With fallback value
|
||||||
|
div.style.width = 'anchor-size(height --bar, 100px)';
|
||||||
|
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
|
||||||
|
|
||||||
|
// Different size keywords
|
||||||
|
div.style.width = 'anchor-size(block --baz)';
|
||||||
|
testing.expectEqual('anchor-size(--baz block)', div.style.width);
|
||||||
|
|
||||||
|
div.style.width = 'anchor-size(inline --qux)';
|
||||||
|
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
|
||||||
|
|
||||||
|
div.style.width = 'anchor-size(self-block --test)';
|
||||||
|
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
|
||||||
|
|
||||||
|
div.style.width = 'anchor-size(self-inline --test)';
|
||||||
|
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
|
||||||
|
|
||||||
|
// Without anchor name (implicit default anchor)
|
||||||
|
div.style.width = 'anchor-size(width)';
|
||||||
|
testing.expectEqual('anchor-size(width)', div.style.width);
|
||||||
|
|
||||||
|
// Nested anchor-size in fallback
|
||||||
|
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
|
||||||
|
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleSheet_insertRule_deleteRule">
|
||||||
|
{
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet;
|
||||||
|
|
||||||
|
testing.expectEqual(0, sheet.cssRules.length);
|
||||||
|
|
||||||
|
sheet.insertRule('.test { color: green; }', 0);
|
||||||
|
testing.expectEqual(1, sheet.cssRules.length);
|
||||||
|
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
|
||||||
|
testing.expectEqual('green', sheet.cssRules[0].style.color);
|
||||||
|
|
||||||
|
sheet.deleteRule(0);
|
||||||
|
testing.expectEqual(0, sheet.cssRules.length);
|
||||||
|
|
||||||
|
let caught = false;
|
||||||
|
try {
|
||||||
|
sheet.deleteRule(5);
|
||||||
|
} catch (e) {
|
||||||
|
caught = true;
|
||||||
|
testing.expectEqual('IndexSizeError', e.name);
|
||||||
|
}
|
||||||
|
testing.expectTrue(caught);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleSheet_insertRule_default_index">
|
||||||
|
{
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet;
|
||||||
|
|
||||||
|
testing.expectEqual(0, sheet.cssRules.length);
|
||||||
|
|
||||||
|
// Call without index, should default to 0
|
||||||
|
sheet.insertRule('.test-default { color: blue; }');
|
||||||
|
testing.expectEqual(1, sheet.cssRules.length);
|
||||||
|
testing.expectEqual('.test-default', sheet.cssRules[0].selectorText);
|
||||||
|
|
||||||
|
// Insert another rule without index, should default to 0 and push the first one to index 1
|
||||||
|
sheet.insertRule('.test-at-0 { color: red; }');
|
||||||
|
testing.expectEqual(2, sheet.cssRules.length);
|
||||||
|
testing.expectEqual('.test-at-0', sheet.cssRules[0].selectorText);
|
||||||
|
testing.expectEqual('.test-default', sheet.cssRules[1].selectorText);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleSheet_insertRule_semicolon">
|
||||||
|
{
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet;
|
||||||
|
|
||||||
|
// Should not throw even with trailing semicolon
|
||||||
|
sheet.insertRule('*{};');
|
||||||
|
testing.expectEqual(1, sheet.cssRules.length);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleSheet_insertRule_multiple_rules">
|
||||||
|
{
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet;
|
||||||
|
|
||||||
|
let caught = false;
|
||||||
|
try {
|
||||||
|
sheet.insertRule('a { color: red; } b { color: blue; }');
|
||||||
|
} catch (e) {
|
||||||
|
caught = true;
|
||||||
|
testing.expectEqual('SyntaxError', e.name);
|
||||||
|
}
|
||||||
|
testing.expectTrue(caught);
|
||||||
|
testing.expectEqual(0, sheet.cssRules.length);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleSheet_replaceSync">
|
||||||
|
{
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
testing.expectEqual(0, sheet.cssRules.length);
|
||||||
|
|
||||||
|
sheet.replaceSync('.test { color: blue; }');
|
||||||
|
testing.expectEqual(1, sheet.cssRules.length);
|
||||||
|
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
|
||||||
|
testing.expectEqual('blue', sheet.cssRules[0].style.color);
|
||||||
|
|
||||||
|
let replacedAsync = false;
|
||||||
|
testing.async(async () => {
|
||||||
|
const result = await sheet.replace('.async-test { margin: 10px; }');
|
||||||
|
testing.expectTrue(result === sheet);
|
||||||
|
testing.expectEqual(1, sheet.cssRules.length);
|
||||||
|
testing.expectEqual('.async-test', sheet.cssRules[0].selectorText);
|
||||||
|
replacedAsync = true;
|
||||||
|
});
|
||||||
|
testing.onload(() => testing.expectTrue(replacedAsync));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="CSSStyleRule_cssText">
|
||||||
|
{
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
sheet.replaceSync('.test { color: red; margin: 10px; }');
|
||||||
|
|
||||||
|
// Check serialization format
|
||||||
|
const cssText = sheet.cssRules[0].cssText;
|
||||||
|
testing.expectTrue(cssText.includes('.test { '));
|
||||||
|
testing.expectTrue(cssText.includes('color: red;'));
|
||||||
|
testing.expectTrue(cssText.includes('margin: 10px;'));
|
||||||
|
testing.expectTrue(cssText.includes('}'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -53,3 +53,78 @@
|
|||||||
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
|
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div id=clone_container></div>
|
||||||
|
|
||||||
|
<script id=clone>
|
||||||
|
{
|
||||||
|
let calls = 0;
|
||||||
|
class MyCloneElementA extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
calls += 1;
|
||||||
|
$('#clone_container').appendChild(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('my-clone_element_a', MyCloneElementA);
|
||||||
|
const original = document.createElement('my-clone_element_a');
|
||||||
|
$('#clone_container').cloneNode(true);
|
||||||
|
testing.expectEqual(2, calls);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id=fragment_clone_container></div>
|
||||||
|
|
||||||
|
<script id=clone_fragment>
|
||||||
|
{
|
||||||
|
let calls = 0;
|
||||||
|
class MyFragmentCloneElement extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
calls += 1;
|
||||||
|
$('#fragment_clone_container').appendChild(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('my-fragment-clone-element', MyFragmentCloneElement);
|
||||||
|
|
||||||
|
// Create a DocumentFragment with a custom element
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const customEl = document.createElement('my-fragment-clone-element');
|
||||||
|
fragment.appendChild(customEl);
|
||||||
|
|
||||||
|
// Clone the fragment - this should trigger the crash
|
||||||
|
// because the constructor will attach the element during cloning
|
||||||
|
const clonedFragment = fragment.cloneNode(true);
|
||||||
|
testing.expectEqual(2, calls);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id=range_clone_container></div>
|
||||||
|
|
||||||
|
<script id=clone_range>
|
||||||
|
{
|
||||||
|
let calls = 0;
|
||||||
|
class MyRangeCloneElement extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
calls += 1;
|
||||||
|
$('#range_clone_container').appendChild(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('my-range-clone-element', MyRangeCloneElement);
|
||||||
|
|
||||||
|
// Create a container with a custom element
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const customEl = document.createElement('my-range-clone-element');
|
||||||
|
container.appendChild(customEl);
|
||||||
|
|
||||||
|
// Create a range that includes the custom element
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(container);
|
||||||
|
|
||||||
|
// Clone the range contents - this should trigger the crash
|
||||||
|
// because the constructor will attach the element during cloning
|
||||||
|
const clonedContents = range.cloneContents();
|
||||||
|
testing.expectEqual(2, calls);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<script src="../testing.js"></script>
|
||||||
|
<script>
|
||||||
|
// Test that document.open/write/close throw InvalidStateError during custom element
|
||||||
|
// reactions when the element is parsed from HTML
|
||||||
|
|
||||||
|
window.constructorOpenException = null;
|
||||||
|
window.constructorWriteException = null;
|
||||||
|
window.constructorCloseException = null;
|
||||||
|
window.constructorCalled = false;
|
||||||
|
|
||||||
|
class ThrowTestElement extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
window.constructorCalled = true;
|
||||||
|
|
||||||
|
// Try document.open on the same document during constructor - should throw
|
||||||
|
try {
|
||||||
|
document.open();
|
||||||
|
} catch (e) {
|
||||||
|
window.constructorOpenException = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try document.write on the same document during constructor - should throw
|
||||||
|
try {
|
||||||
|
document.write('<b>test</b>');
|
||||||
|
} catch (e) {
|
||||||
|
window.constructorWriteException = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try document.close on the same document during constructor - should throw
|
||||||
|
try {
|
||||||
|
document.close();
|
||||||
|
} catch (e) {
|
||||||
|
window.constructorCloseException = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('throw-test-element', ThrowTestElement);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- This element will be parsed from HTML, triggering the constructor -->
|
||||||
|
<throw-test-element id="test-element"></throw-test-element>
|
||||||
|
|
||||||
|
<script id="verify_throws">
|
||||||
|
{
|
||||||
|
// Verify the constructor was called
|
||||||
|
testing.expectEqual(true, window.constructorCalled);
|
||||||
|
|
||||||
|
// Verify document.open threw InvalidStateError
|
||||||
|
testing.expectEqual(true, window.constructorOpenException !== null);
|
||||||
|
testing.expectEqual('InvalidStateError', window.constructorOpenException.name);
|
||||||
|
|
||||||
|
// Verify document.write threw InvalidStateError
|
||||||
|
testing.expectEqual(true, window.constructorWriteException !== null);
|
||||||
|
testing.expectEqual('InvalidStateError', window.constructorWriteException.name);
|
||||||
|
|
||||||
|
// Verify document.close threw InvalidStateError
|
||||||
|
testing.expectEqual(true, window.constructorCloseException !== null);
|
||||||
|
testing.expectEqual('InvalidStateError', window.constructorCloseException.name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
testing.expectEqual(true, htmlDiv1 instanceof HTMLDivElement);
|
testing.expectEqual(true, htmlDiv1 instanceof HTMLDivElement);
|
||||||
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv1.namespaceURI);
|
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv1.namespaceURI);
|
||||||
|
|
||||||
|
// Per spec, createElementNS does NOT lowercase — 'DIV' != 'div', so this
|
||||||
|
// creates an HTMLUnknownElement, not an HTMLDivElement.
|
||||||
const htmlDiv2 = document.createElementNS('http://www.w3.org/1999/xhtml', 'DIV');
|
const htmlDiv2 = document.createElementNS('http://www.w3.org/1999/xhtml', 'DIV');
|
||||||
testing.expectEqual('DIV', htmlDiv2.tagName);
|
testing.expectEqual('DIV', htmlDiv2.tagName);
|
||||||
testing.expectEqual(true, htmlDiv2 instanceof HTMLDivElement);
|
testing.expectEqual(false, htmlDiv2 instanceof HTMLDivElement);
|
||||||
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv2.namespaceURI);
|
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv2.namespaceURI);
|
||||||
|
|
||||||
const svgRect = document.createElementNS('http://www.w3.org/2000/svg', 'RecT');
|
const svgRect = document.createElementNS('http://www.w3.org/2000/svg', 'RecT');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<head id="the_head">
|
<head id="the_head">
|
||||||
|
<meta charset="UTF-8">
|
||||||
<title>Test Document Title</title>
|
<title>Test Document Title</title>
|
||||||
<script src="../testing.js"></script>
|
<script src="../testing.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
testing.expectEqual(10, document.childNodes[0].nodeType);
|
testing.expectEqual(10, document.childNodes[0].nodeType);
|
||||||
testing.expectEqual(null, document.parentNode);
|
testing.expectEqual(null, document.parentNode);
|
||||||
testing.expectEqual(undefined, document.getCurrentScript);
|
testing.expectEqual(undefined, document.getCurrentScript);
|
||||||
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL);
|
testing.expectEqual(testing.BASE_URL + 'document/document.html', document.URL);
|
||||||
testing.expectEqual(window, document.defaultView);
|
testing.expectEqual(window, document.defaultView);
|
||||||
testing.expectEqual(false, document.hidden);
|
testing.expectEqual(false, document.hidden);
|
||||||
testing.expectEqual("visible", document.visibilityState);
|
testing.expectEqual("visible", document.visibilityState);
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
testing.expectEqual('CSS1Compat', document.compatMode);
|
testing.expectEqual('CSS1Compat', document.compatMode);
|
||||||
testing.expectEqual(document.URL, document.documentURI);
|
testing.expectEqual(document.URL, document.documentURI);
|
||||||
testing.expectEqual('', document.referrer);
|
testing.expectEqual('', document.referrer);
|
||||||
testing.expectEqual('127.0.0.1', document.domain);
|
testing.expectEqual(testing.HOST, document.domain);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=programmatic_document_metadata>
|
<script id=programmatic_document_metadata>
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
testing.expectEqual('CSS1Compat', doc.compatMode);
|
testing.expectEqual('CSS1Compat', doc.compatMode);
|
||||||
testing.expectEqual('', doc.referrer);
|
testing.expectEqual('', doc.referrer);
|
||||||
// Programmatic document should have empty domain (no URL/origin)
|
// Programmatic document should have empty domain (no URL/origin)
|
||||||
testing.expectEqual('127.0.0.1', doc.domain);
|
testing.expectEqual(testing.HOST, doc.domain);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Test anchors and links -->
|
<!-- Test anchors and links -->
|
||||||
@@ -176,15 +177,111 @@
|
|||||||
testing.expectEqual(initialLength, anchors.length);
|
testing.expectEqual(initialLength, anchors.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=cookie>
|
<script id=cookie_basic>
|
||||||
testing.expectEqual('', document.cookie);
|
// Basic cookie operations
|
||||||
document.cookie = 'name=Oeschger;';
|
document.cookie = 'testbasic1=Oeschger';
|
||||||
document.cookie = 'favorite_food=tripe;';
|
testing.expectEqual(true, document.cookie.includes('testbasic1=Oeschger'));
|
||||||
|
|
||||||
testing.expectEqual('name=Oeschger; favorite_food=tripe', document.cookie);
|
document.cookie = 'testbasic2=tripe';
|
||||||
// "" should be returned, but the framework overrules it atm
|
testing.expectEqual(true, document.cookie.includes('testbasic1=Oeschger'));
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testbasic2=tripe'));
|
||||||
|
|
||||||
|
// HttpOnly should be ignored from JavaScript
|
||||||
|
const beforeHttp = document.cookie;
|
||||||
document.cookie = 'IgnoreMy=Ghost; HttpOnly';
|
document.cookie = 'IgnoreMy=Ghost; HttpOnly';
|
||||||
testing.expectEqual('name=Oeschger; favorite_food=tripe', document.cookie);
|
testing.expectEqual(false, document.cookie.includes('IgnoreMy=Ghost'));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.cookie = 'testbasic1=; Max-Age=0';
|
||||||
|
document.cookie = 'testbasic2=; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_special_chars>
|
||||||
|
// Test special characters in cookie values
|
||||||
|
document.cookie = 'testspaces=hello world';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testspaces=hello world'));
|
||||||
|
document.cookie = 'testspaces=; Max-Age=0';
|
||||||
|
|
||||||
|
// Test various allowed special characters
|
||||||
|
document.cookie = 'testspecial=!#$%&\'()*+-./';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testspecial='));
|
||||||
|
document.cookie = 'testspecial=; Max-Age=0';
|
||||||
|
|
||||||
|
// Semicolon terminates the cookie value
|
||||||
|
document.cookie = 'testsemi=before;after';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testsemi=before'));
|
||||||
|
testing.expectEqual(false, document.cookie.includes('after'));
|
||||||
|
document.cookie = 'testsemi=; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_empty_name>
|
||||||
|
// Cookie with empty name (just a value)
|
||||||
|
document.cookie = 'teststandalone';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('teststandalone'));
|
||||||
|
document.cookie = 'teststandalone; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_whitespace>
|
||||||
|
// Names and values should be trimmed
|
||||||
|
document.cookie = ' testtrim = trimmed_value ';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testtrim=trimmed_value'));
|
||||||
|
document.cookie = 'testtrim=; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_max_age>
|
||||||
|
// Max-Age=0 should immediately delete
|
||||||
|
document.cookie = 'testtemp0=value; Max-Age=0';
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testtemp0=value'));
|
||||||
|
|
||||||
|
// Negative Max-Age should also delete
|
||||||
|
document.cookie = 'testinstant=value';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testinstant=value'));
|
||||||
|
document.cookie = 'testinstant=value; Max-Age=-1';
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testinstant=value'));
|
||||||
|
|
||||||
|
// Positive Max-Age should keep cookie
|
||||||
|
document.cookie = 'testkept=value; Max-Age=3600';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testkept=value'));
|
||||||
|
document.cookie = 'testkept=; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_overwrite>
|
||||||
|
// Setting a cookie with the same name should overwrite
|
||||||
|
document.cookie = 'testoverwrite=first';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testoverwrite=first'));
|
||||||
|
|
||||||
|
document.cookie = 'testoverwrite=second';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testoverwrite=second'));
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testoverwrite=first'));
|
||||||
|
|
||||||
|
document.cookie = 'testoverwrite=; Max-Age=0';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_path>
|
||||||
|
// Path attribute
|
||||||
|
document.cookie = 'testpath1=value; Path=/';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testpath1=value'));
|
||||||
|
|
||||||
|
// Different path cookie should coexist
|
||||||
|
document.cookie = 'testpath2=value2; Path=/src';
|
||||||
|
testing.expectEqual(true, document.cookie.includes('testpath1=value'));
|
||||||
|
|
||||||
|
document.cookie = 'testpath1=; Max-Age=0; Path=/';
|
||||||
|
document.cookie = 'testpath2=; Max-Age=0; Path=/src';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=cookie_invalid_chars>
|
||||||
|
// Control characters (< 32 or > 126) should be rejected
|
||||||
|
const beforeBad = document.cookie;
|
||||||
|
|
||||||
|
document.cookie = 'testbad1\x00=value';
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testbad1'));
|
||||||
|
|
||||||
|
document.cookie = 'testbad2\x1F=value';
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testbad2'));
|
||||||
|
|
||||||
|
document.cookie = 'testbad3=val\x7F';
|
||||||
|
testing.expectEqual(false, document.cookie.includes('testbad3'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=createAttribute>
|
<script id=createAttribute>
|
||||||
|
|||||||
@@ -297,3 +297,25 @@
|
|||||||
testing.expectEqual(btn, document.activeElement);
|
testing.expectEqual(btn, document.activeElement);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="focus_disconnected_no_blur">
|
||||||
|
{
|
||||||
|
const input1 = $('#input1');
|
||||||
|
|
||||||
|
if (document.activeElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
input1.focus();
|
||||||
|
testing.expectEqual(input1, document.activeElement);
|
||||||
|
|
||||||
|
let blurCount = 0;
|
||||||
|
input1.addEventListener('blur', () => { blurCount++ });
|
||||||
|
|
||||||
|
// Focusing a disconnected element should be a no-op:
|
||||||
|
// blur must not fire on the currently focused element
|
||||||
|
document.createElement('a').focus();
|
||||||
|
testing.expectEqual(input1, document.activeElement);
|
||||||
|
testing.expectEqual(0, blurCount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -24,11 +24,10 @@
|
|||||||
|
|
||||||
<script id=byId name="test1">
|
<script id=byId name="test1">
|
||||||
testing.expectEqual(1, document.querySelector.length);
|
testing.expectEqual(1, document.querySelector.length);
|
||||||
testing.expectError("SyntaxError: Syntax Error", () => document.querySelector(''));
|
testing.expectError("SyntaxError", () => document.querySelector(''));
|
||||||
testing.withError((err) => {
|
testing.withError((err) => {
|
||||||
testing.expectEqual(12, err.code);
|
testing.expectEqual(12, err.code);
|
||||||
testing.expectEqual("SyntaxError", err.name);
|
testing.expectEqual("SyntaxError", err.name);
|
||||||
testing.expectEqual("Syntax Error", err.message);
|
|
||||||
}, () => document.querySelector(''));
|
}, () => document.querySelector(''));
|
||||||
|
|
||||||
testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name'));
|
testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name'));
|
||||||
|
|||||||
@@ -27,16 +27,17 @@
|
|||||||
testing.expectEqual(expected.length, result.length);
|
testing.expectEqual(expected.length, result.length);
|
||||||
testing.expectEqual(expected, Array.from(result).map((e) => e.textContent));
|
testing.expectEqual(expected, Array.from(result).map((e) => e.textContent));
|
||||||
testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent));
|
testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent));
|
||||||
|
|
||||||
testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys()));
|
testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys()));
|
||||||
|
testing.expectEqual(expected.map((e, i) => i.toString()), Object.keys(result));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=script1 name="test1">
|
<script id=script1 name="test1">
|
||||||
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
|
testing.expectError("SyntaxError", () => document.querySelectorAll(''));
|
||||||
testing.withError((err) => {
|
testing.withError((err) => {
|
||||||
testing.expectEqual(12, err.code);
|
testing.expectEqual(12, err.code);
|
||||||
testing.expectEqual("SyntaxError", err.name);
|
testing.expectEqual("SyntaxError", err.name);
|
||||||
testing.expectEqual("Syntax Error", err.message);
|
|
||||||
}, () => document.querySelectorAll(''));
|
}, () => document.querySelectorAll(''));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -376,3 +377,93 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<form id="form-validity-test">
|
||||||
|
<input id="vi-required-empty" type="text" required>
|
||||||
|
<input id="vi-optional" type="text">
|
||||||
|
<input id="vi-hidden-required" type="hidden" required>
|
||||||
|
<fieldset id="vi-fieldset">
|
||||||
|
<input id="vi-nested-required" type="text" required>
|
||||||
|
<select id="vi-select-required" required>
|
||||||
|
<option value="">Pick one</option>
|
||||||
|
<option value="a">A</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<input id="vi-checkbox" type="checkbox">
|
||||||
|
|
||||||
|
<script id=invalidPseudo>
|
||||||
|
{
|
||||||
|
// Inputs with required + empty value are :invalid
|
||||||
|
testing.expectEqual(true, document.getElementById('vi-required-empty').matches(':invalid'));
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-required-empty').matches(':valid'));
|
||||||
|
|
||||||
|
// Inputs without required are :valid
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-optional').matches(':invalid'));
|
||||||
|
testing.expectEqual(true, document.getElementById('vi-optional').matches(':valid'));
|
||||||
|
|
||||||
|
// hidden inputs are not candidates for constraint validation
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':invalid'));
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':valid'));
|
||||||
|
|
||||||
|
// select with required and empty selected value is :invalid
|
||||||
|
testing.expectEqual(true, document.getElementById('vi-select-required').matches(':invalid'));
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-select-required').matches(':valid'));
|
||||||
|
|
||||||
|
// fieldset containing invalid controls is :invalid
|
||||||
|
testing.expectEqual(true, document.getElementById('vi-fieldset').matches(':invalid'));
|
||||||
|
testing.expectEqual(false, document.getElementById('vi-fieldset').matches(':valid'));
|
||||||
|
|
||||||
|
// form containing invalid controls is :invalid
|
||||||
|
testing.expectEqual(true, document.getElementById('form-validity-test').matches(':invalid'));
|
||||||
|
testing.expectEqual(false, document.getElementById('form-validity-test').matches(':valid'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=validAfterValueSet>
|
||||||
|
{
|
||||||
|
// After setting a value, a required input becomes :valid
|
||||||
|
const input = document.getElementById('vi-required-empty');
|
||||||
|
input.value = 'hello';
|
||||||
|
testing.expectEqual(false, input.matches(':invalid'));
|
||||||
|
testing.expectEqual(true, input.matches(':valid'));
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=indeterminatePseudo>
|
||||||
|
{
|
||||||
|
const cb = document.getElementById('vi-checkbox');
|
||||||
|
testing.expectEqual(false, cb.matches(':indeterminate'));
|
||||||
|
cb.indeterminate = true;
|
||||||
|
testing.expectEqual(true, cb.matches(':indeterminate'));
|
||||||
|
cb.indeterminate = false;
|
||||||
|
testing.expectEqual(false, cb.matches(':indeterminate'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=iterator_list_lifetime>
|
||||||
|
// This test is intended to ensure that a list remains alive as long as it
|
||||||
|
// must, i.e. as long as any iterator referencing the list is alive.
|
||||||
|
// This test depends on being able to force the v8 GC to cleanup, which
|
||||||
|
// we have no way of controlling. At worst, the test will pass without
|
||||||
|
// actually testing correct lifetime. But it was at least manually verified
|
||||||
|
// for me that this triggers plenty of GCs.
|
||||||
|
const expected = Array.from(document.querySelectorAll('*')).length;
|
||||||
|
{
|
||||||
|
let keys = [];
|
||||||
|
|
||||||
|
// Phase 1: Create many lists+iterators to fill up the arena pool
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
let list = document.querySelectorAll('*');
|
||||||
|
keys.push(list.keys());
|
||||||
|
|
||||||
|
// Create an Event every iteration to compete for arenas
|
||||||
|
new Event('arena_compete');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let k of keys) {
|
||||||
|
const result = Array.from(k);
|
||||||
|
testing.expectEqual(expected, result.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -111,3 +111,15 @@
|
|||||||
const containerDataTest = document.querySelector('#container [data-test]');
|
const containerDataTest = document.querySelector('#container [data-test]');
|
||||||
testing.expectEqual('First', containerDataTest.innerText);
|
testing.expectEqual('First', containerDataTest.innerText);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<link rel="preload" as="image" imagesrcset="url1.png 1x, url2.png 2x" id="preload-link">
|
||||||
|
|
||||||
|
<script id="commaInAttrValue">
|
||||||
|
// Commas inside quoted attribute values must not be treated as selector separators
|
||||||
|
const el = document.querySelector('link[rel="preload"][as="image"][imagesrcset="url1.png 1x, url2.png 2x"]');
|
||||||
|
testing.expectEqual('preload-link', el.id);
|
||||||
|
|
||||||
|
// Also test with single quotes inside selector
|
||||||
|
const el2 = document.querySelector("link[imagesrcset='url1.png 1x, url2.png 2x']");
|
||||||
|
testing.expectEqual('preload-link', el2.id);
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -342,3 +342,4 @@
|
|||||||
testing.expectEqual('html', doc.lastChild.nodeName);
|
testing.expectEqual('html', doc.lastChild.nodeName);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
document.open();
|
document.open();
|
||||||
}, 5);
|
}, 5);
|
||||||
|
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
// The element should be gone now
|
// The element should be gone now
|
||||||
const afterOpen = document.getElementById('will_be_removed');
|
const afterOpen = document.getElementById('will_be_removed');
|
||||||
testing.expectEqual(null, afterOpen);
|
testing.expectEqual(null, afterOpen);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
testing.withError((err) => {
|
testing.withError((err) => {
|
||||||
testing.expectEqual(3, err.code);
|
testing.expectEqual(3, err.code);
|
||||||
testing.expectEqual('Hierarchy Error', err.message);
|
testing.expectEqual('HierarchyRequestError', err.name);
|
||||||
testing.expectEqual(true, err instanceof DOMException);
|
testing.expectEqual(true, err instanceof DOMException);
|
||||||
testing.expectEqual(true, err instanceof Error);
|
testing.expectEqual(true, err instanceof Error);
|
||||||
}, () => link.appendChild(content));
|
}, () => link.appendChild(content));
|
||||||
|
|||||||
@@ -108,6 +108,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=createHTMLDocument_nulll_title>
|
||||||
|
{
|
||||||
|
const impl = document.implementation;
|
||||||
|
const doc = impl.createHTMLDocument(null);
|
||||||
|
|
||||||
|
testing.expectEqual('null', doc.title);
|
||||||
|
|
||||||
|
// Should have title element in head
|
||||||
|
const titleElement = doc.head.querySelector('title');
|
||||||
|
testing.expectEqual(true, titleElement !== null);
|
||||||
|
testing.expectEqual('null', titleElement.textContent);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id=createHTMLDocument_structure>
|
<script id=createHTMLDocument_structure>
|
||||||
{
|
{
|
||||||
const impl = document.implementation;
|
const impl = document.implementation;
|
||||||
|
|||||||
@@ -3,11 +3,19 @@
|
|||||||
<body></body>
|
<body></body>
|
||||||
|
|
||||||
<script id=basic>
|
<script id=basic>
|
||||||
|
{
|
||||||
{
|
{
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
testing.expectEqual('object', typeof parser);
|
testing.expectEqual('object', typeof parser);
|
||||||
testing.expectEqual('function', typeof parser.parseFromString);
|
testing.expectEqual('function', typeof parser.parseFromString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const parser = new DOMParser();
|
||||||
|
let d = parser.parseFromString('', 'text/xml');
|
||||||
|
testing.expectEqual('<parsererror>error</parsererror>', new XMLSerializer().serializeToString(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=parseSimpleHTML>
|
<script id=parseSimpleHTML>
|
||||||
@@ -389,3 +397,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=getElementsByTagName-xml>
|
||||||
|
{
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
|
||||||
|
|
||||||
|
// Test getElementsByTagName on document
|
||||||
|
const rows = doc.getElementsByTagName('row');
|
||||||
|
testing.expectEqual(1, rows.length);
|
||||||
|
|
||||||
|
// Test getElementsByTagName on element
|
||||||
|
const row = rows[0];
|
||||||
|
const cols = row.getElementsByTagName('col');
|
||||||
|
testing.expectEqual(2, cols.length);
|
||||||
|
testing.expectEqual('A', cols[0].textContent);
|
||||||
|
testing.expectEqual('B', cols[1].textContent);
|
||||||
|
|
||||||
|
// Test getElementsByTagName('*') on element
|
||||||
|
const allElements = row.getElementsByTagName('*');
|
||||||
|
testing.expectEqual(2, allElements.length);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
testing.withError((err) => {
|
testing.withError((err) => {
|
||||||
testing.expectEqual(8, err.code);
|
testing.expectEqual(8, err.code);
|
||||||
testing.expectEqual("NotFoundError", err.name);
|
testing.expectEqual("NotFoundError", err.name);
|
||||||
testing.expectEqual("Not Found", err.message);
|
|
||||||
}, () => el1.removeAttributeNode(script_id_node));
|
}, () => el1.removeAttributeNode(script_id_node));
|
||||||
|
|
||||||
testing.expectEqual(an1, el1.removeAttributeNode(an1));
|
testing.expectEqual(an1, el1.removeAttributeNode(an1));
|
||||||
|
|||||||
226
src/browser/tests/element/check_visibility.html
Normal file
226
src/browser/tests/element/check_visibility.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../testing.js"></script>
|
||||||
|
<body></body>
|
||||||
|
|
||||||
|
<!-
|
||||||
|
<script id="inline_display_none">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.style.display = "none";
|
||||||
|
testing.expectEqual(false, el.checkVisibility());
|
||||||
|
|
||||||
|
el.style.display = "block";
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="inline_visibility_hidden">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
el.style.visibility = "hidden";
|
||||||
|
// Without visibilityProperty option, visibility:hidden is not checked
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
// With visibilityProperty: true, visibility:hidden is detected
|
||||||
|
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
|
||||||
|
|
||||||
|
el.style.visibility = "collapse";
|
||||||
|
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
|
||||||
|
|
||||||
|
el.style.visibility = "visible";
|
||||||
|
testing.expectEqual(true, el.checkVisibility({ visibilityProperty: true }));
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="inline_opacity_zero">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
el.style.opacity = "0";
|
||||||
|
// Without checkOpacity option, opacity:0 is not checked
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
// With checkOpacity: true, opacity:0 is detected
|
||||||
|
testing.expectEqual(false, el.checkVisibility({ checkOpacity: true }));
|
||||||
|
|
||||||
|
el.style.opacity = "0.5";
|
||||||
|
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
|
||||||
|
|
||||||
|
el.style.opacity = "1";
|
||||||
|
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="parent_hidden_hides_child">
|
||||||
|
{
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
const child = document.createElement("span");
|
||||||
|
parent.appendChild(child);
|
||||||
|
document.body.appendChild(parent);
|
||||||
|
|
||||||
|
testing.expectEqual(true, child.checkVisibility());
|
||||||
|
|
||||||
|
// display:none on parent hides children (no option needed)
|
||||||
|
parent.style.display = "none";
|
||||||
|
testing.expectEqual(false, child.checkVisibility());
|
||||||
|
|
||||||
|
// visibility:hidden on parent - needs visibilityProperty option
|
||||||
|
parent.style.display = "block";
|
||||||
|
parent.style.visibility = "hidden";
|
||||||
|
testing.expectEqual(true, child.checkVisibility()); // without option
|
||||||
|
testing.expectEqual(false, child.checkVisibility({ visibilityProperty: true }));
|
||||||
|
|
||||||
|
// opacity:0 on parent - needs checkOpacity option
|
||||||
|
parent.style.visibility = "visible";
|
||||||
|
parent.style.opacity = "0";
|
||||||
|
testing.expectEqual(true, child.checkVisibility()); // without option
|
||||||
|
testing.expectEqual(false, child.checkVisibility({ checkOpacity: true }));
|
||||||
|
|
||||||
|
parent.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style id="style-basic">
|
||||||
|
.hidden-by-class { display: none; }
|
||||||
|
.visible-by-class { display: block; }
|
||||||
|
</style>
|
||||||
|
<script id="style_tag_basic">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.className = "hidden-by-class";
|
||||||
|
testing.expectEqual(false, el.checkVisibility());
|
||||||
|
|
||||||
|
el.className = "visible-by-class";
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.className = "";
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style id="style-specificity">
|
||||||
|
.spec-hidden { display: none; }
|
||||||
|
#spec-visible { display: block; }
|
||||||
|
</style>
|
||||||
|
<script id="specificity_id_beats_class">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = "spec-visible";
|
||||||
|
el.className = "spec-hidden";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
// ID selector (#spec-visible: display:block) should beat class selector (.spec-hidden: display:none)
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style id="style-order-1">
|
||||||
|
.order-test { display: none; }
|
||||||
|
</style>
|
||||||
|
<style id="style-order-2">
|
||||||
|
.order-test { display: block; }
|
||||||
|
</style>
|
||||||
|
<script id="rule_order_later_wins">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "order-test";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
// Second style block should win (display: block)
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style id="style-override">
|
||||||
|
.should-be-hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
<script id="inline_overrides_stylesheet">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "should-be-hidden";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
testing.expectEqual(false, el.checkVisibility());
|
||||||
|
|
||||||
|
// Inline style should override
|
||||||
|
el.style.display = "block";
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="dynamic_style_element">
|
||||||
|
{
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "dynamic-style-test";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
// Add a style element
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = ".dynamic-style-test { display: none; }";
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
testing.expectEqual(false, el.checkVisibility());
|
||||||
|
|
||||||
|
// Remove the style element
|
||||||
|
style.remove();
|
||||||
|
testing.expectEqual(true, el.checkVisibility());
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="deep_nesting">
|
||||||
|
{
|
||||||
|
const levels = 5;
|
||||||
|
let current = document.body;
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < levels; i++) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
current.appendChild(el);
|
||||||
|
elements.push(el);
|
||||||
|
current = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All should be visible
|
||||||
|
for (let i = 0; i < levels; i++) {
|
||||||
|
testing.expectEqual(true, elements[i].checkVisibility());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide middle element
|
||||||
|
elements[2].style.display = "none";
|
||||||
|
|
||||||
|
// Elements 0, 1 should still be visible
|
||||||
|
testing.expectEqual(true, elements[0].checkVisibility());
|
||||||
|
testing.expectEqual(true, elements[1].checkVisibility());
|
||||||
|
|
||||||
|
// Elements 2, 3, 4 should be hidden
|
||||||
|
testing.expectEqual(false, elements[2].checkVisibility());
|
||||||
|
testing.expectEqual(false, elements[3].checkVisibility());
|
||||||
|
testing.expectEqual(false, elements[4].checkVisibility());
|
||||||
|
|
||||||
|
elements[0].remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -154,3 +154,11 @@
|
|||||||
testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function');
|
testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div id=crash1 style="background-position: 5% .1em"></div>
|
||||||
|
<script id="crash_case_1">
|
||||||
|
{
|
||||||
|
testing.expectEqual('5% .1em', $('#crash1').style.backgroundPosition);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
<script id=empty_href>
|
<script id=empty_href>
|
||||||
testing.expectEqual('', $('#a0').href);
|
testing.expectEqual('', $('#a0').href);
|
||||||
|
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/anchor1.html', $('#a1').href);
|
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
|
||||||
testing.expectEqual('http://127.0.0.1:9582/hello/world/anchor2.html', $('#a2').href);
|
testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
|
||||||
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
|
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
|
||||||
|
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', $('#link').href);
|
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=dynamic_anchor_defaults>
|
<script id=dynamic_anchor_defaults>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
testing.expectEqual('https://foo.bar/?q=bar#frag', link.href);
|
testing.expectEqual('https://foo.bar/?q=bar#frag', link.href);
|
||||||
|
|
||||||
link.href = 'foo';
|
link.href = 'foo';
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', link.href);
|
testing.expectEqual(testing.BASE_URL + 'element/html/foo', link.href);
|
||||||
|
|
||||||
testing.expectEqual('', link.type);
|
testing.expectEqual('', link.type);
|
||||||
link.type = 'text/html';
|
link.type = 'text/html';
|
||||||
@@ -245,3 +245,11 @@
|
|||||||
testing.expectEqual('', b.toString());
|
testing.expectEqual('', b.toString());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=url_encode>
|
||||||
|
{
|
||||||
|
let a = document.createElement('a');
|
||||||
|
a.href = 'over 9000!';
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
63
src/browser/tests/element/html/details.html
Normal file
63
src/browser/tests/element/html/details.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../../testing.js"></script>
|
||||||
|
|
||||||
|
<!-- Details elements -->
|
||||||
|
<details id="details1">
|
||||||
|
<summary>Summary</summary>
|
||||||
|
Content
|
||||||
|
</details>
|
||||||
|
<details id="details2" open>
|
||||||
|
<summary>Open Summary</summary>
|
||||||
|
Content
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<script id="instanceof">
|
||||||
|
{
|
||||||
|
const details = document.createElement('details')
|
||||||
|
testing.expectTrue(details instanceof HTMLDetailsElement)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="open_initial">
|
||||||
|
testing.expectEqual(false, $('#details1').open)
|
||||||
|
testing.expectEqual(true, $('#details2').open)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="open_set">
|
||||||
|
{
|
||||||
|
$('#details1').open = true
|
||||||
|
testing.expectEqual(true, $('#details1').open)
|
||||||
|
|
||||||
|
$('#details2').open = false
|
||||||
|
testing.expectEqual(false, $('#details2').open)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="open_reflects_attribute">
|
||||||
|
{
|
||||||
|
const details = document.createElement('details')
|
||||||
|
testing.expectEqual(null, details.getAttribute('open'))
|
||||||
|
|
||||||
|
details.open = true
|
||||||
|
testing.expectEqual('', details.getAttribute('open'))
|
||||||
|
|
||||||
|
details.open = false
|
||||||
|
testing.expectEqual(null, details.getAttribute('open'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="name_initial">
|
||||||
|
{
|
||||||
|
const details = document.createElement('details')
|
||||||
|
testing.expectEqual('', details.name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="name_set">
|
||||||
|
{
|
||||||
|
const details = document.createElement('details')
|
||||||
|
details.name = 'group1'
|
||||||
|
testing.expectEqual('group1', details.name)
|
||||||
|
testing.expectEqual('group1', details.getAttribute('name'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -143,6 +143,29 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="js_setter_null_clears_listener">
|
||||||
|
{
|
||||||
|
// Setting an event handler property to null must silently clear it (not throw).
|
||||||
|
// Browsers also accept undefined and non-function values without throwing.
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.onload = () => 42;
|
||||||
|
testing.expectEqual('function', typeof div.onload);
|
||||||
|
|
||||||
|
// Setting to null removes the listener; getter returns null
|
||||||
|
div.onload = null;
|
||||||
|
testing.expectEqual(null, div.onload);
|
||||||
|
|
||||||
|
div.onerror = () => {};
|
||||||
|
div.onerror = null;
|
||||||
|
testing.expectEqual(null, div.onerror);
|
||||||
|
|
||||||
|
div.onclick = () => {};
|
||||||
|
div.onclick = null;
|
||||||
|
testing.expectEqual(null, div.onclick);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id="different_event_types_independent">
|
<script id="different_event_types_independent">
|
||||||
{
|
{
|
||||||
// Test that different event types are stored independently
|
// Test that different event types are stored independently
|
||||||
@@ -509,6 +532,6 @@
|
|||||||
testing.expectEqual(true, result);
|
testing.expectEqual(true, result);
|
||||||
});
|
});
|
||||||
|
|
||||||
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched));
|
testing.onload(() => testing.expectEqual(true, asyncBlockDispatched));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="action">
|
||||||
|
{
|
||||||
|
const form = document.createElement('form')
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/form.html', form.action)
|
||||||
|
|
||||||
|
form.action = 'hello';
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
|
||||||
|
|
||||||
|
form.action = '/hello';
|
||||||
|
testing.expectEqual(testing.ORIGIN + '/hello', form.action)
|
||||||
|
|
||||||
|
form.action = 'https://lightpanda.io/hello';
|
||||||
|
testing.expectEqual('https://lightpanda.io/hello', form.action)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Test fixtures for form.method -->
|
<!-- Test fixtures for form.method -->
|
||||||
<form id="form_get" method="get"></form>
|
<form id="form_get" method="get"></form>
|
||||||
<form id="form_post" method="post"></form>
|
<form id="form_post" method="post"></form>
|
||||||
@@ -327,3 +343,164 @@
|
|||||||
testing.expectEqual('', form.elements['choice'].value)
|
testing.expectEqual('', form.elements['choice'].value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() fires the submit event (unlike submit()) -->
|
||||||
|
<form id="test_form2" action="/should-not-navigate2" method="get">
|
||||||
|
<input name="q" value="test2">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_fires_submit_event">
|
||||||
|
{
|
||||||
|
const form = $('#test_form2');
|
||||||
|
let submitFired = false;
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitFired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.requestSubmit();
|
||||||
|
|
||||||
|
testing.expectEqual(true, submitFired);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() with preventDefault stops navigation -->
|
||||||
|
<form id="test_form3" action="/should-not-navigate3" method="get">
|
||||||
|
<input name="q" value="test3">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_respects_preventDefault">
|
||||||
|
{
|
||||||
|
const form = $('#test_form3');
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.requestSubmit();
|
||||||
|
|
||||||
|
// Form submission was prevented, so no navigation should be scheduled
|
||||||
|
testing.expectEqual(true, true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() with non-submit-button submitter throws TypeError -->
|
||||||
|
<form id="test_form_rs1" action="/should-not-navigate4" method="get">
|
||||||
|
<input id="rs1_text" type="text" name="q" value="test">
|
||||||
|
<input id="rs1_submit" type="submit" value="Go">
|
||||||
|
<input id="rs1_image" type="image" src="x.png">
|
||||||
|
<button id="rs1_btn_submit" type="submit">Submit</button>
|
||||||
|
<button id="rs1_btn_reset" type="reset">Reset</button>
|
||||||
|
<button id="rs1_btn_button" type="button">Button</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_rejects_non_submit_button">
|
||||||
|
{
|
||||||
|
const form = $('#test_form_rs1');
|
||||||
|
form.addEventListener('submit', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
// A text input is not a submit button — should throw TypeError
|
||||||
|
testing.expectError('TypeError', () => {
|
||||||
|
form.requestSubmit($('#rs1_text'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A reset button is not a submit button — should throw TypeError
|
||||||
|
testing.expectError('TypeError', () => {
|
||||||
|
form.requestSubmit($('#rs1_btn_reset'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A <button type="button"> is not a submit button — should throw TypeError
|
||||||
|
testing.expectError('TypeError', () => {
|
||||||
|
form.requestSubmit($('#rs1_btn_button'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A <div> is not a submit button — should throw TypeError
|
||||||
|
const div = document.createElement('div');
|
||||||
|
form.appendChild(div);
|
||||||
|
testing.expectError('TypeError', () => {
|
||||||
|
form.requestSubmit(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() accepts valid submit buttons -->
|
||||||
|
<script id="requestSubmit_accepts_submit_buttons">
|
||||||
|
{
|
||||||
|
const form = $('#test_form_rs1');
|
||||||
|
let submitCount = 0;
|
||||||
|
form.addEventListener('submit', (e) => { e.preventDefault(); submitCount++; });
|
||||||
|
|
||||||
|
// <input type="submit"> is a valid submitter
|
||||||
|
form.requestSubmit($('#rs1_submit'));
|
||||||
|
testing.expectEqual(1, submitCount);
|
||||||
|
|
||||||
|
// <input type="image"> is a valid submitter
|
||||||
|
form.requestSubmit($('#rs1_image'));
|
||||||
|
testing.expectEqual(2, submitCount);
|
||||||
|
|
||||||
|
// <button type="submit"> is a valid submitter
|
||||||
|
form.requestSubmit($('#rs1_btn_submit'));
|
||||||
|
testing.expectEqual(3, submitCount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() with submitter not owned by form throws NotFoundError -->
|
||||||
|
<form id="test_form_rs2" action="/should-not-navigate5" method="get">
|
||||||
|
<input type="text" name="q" value="test">
|
||||||
|
</form>
|
||||||
|
<form id="test_form_rs3">
|
||||||
|
<input id="rs3_submit" type="submit" value="Other Submit">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_rejects_wrong_form_submitter">
|
||||||
|
{
|
||||||
|
const form = $('#test_form_rs2');
|
||||||
|
|
||||||
|
// Submit button belongs to a different form — should throw NotFoundError
|
||||||
|
testing.expectError('NotFoundError', () => {
|
||||||
|
form.requestSubmit($('#rs3_submit'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit(submitter) sets SubmitEvent.submitter -->
|
||||||
|
<form id="test_form_submitter" action="/should-not-navigate6" method="get">
|
||||||
|
<button id="submitter_btn" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_sets_submitter">
|
||||||
|
{
|
||||||
|
const form = $('#test_form_submitter');
|
||||||
|
const btn = $('#submitter_btn');
|
||||||
|
let capturedSubmitter = undefined;
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
capturedSubmitter = e.submitter;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.requestSubmit(btn);
|
||||||
|
testing.expectEqual(btn, capturedSubmitter);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Test: requestSubmit() without submitter sets submitter to the form element -->
|
||||||
|
<form id="test_form_submitter2" action="/should-not-navigate7" method="get">
|
||||||
|
<input type="text" name="q" value="test">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script id="requestSubmit_default_submitter_is_form">
|
||||||
|
{
|
||||||
|
const form = $('#test_form_submitter2');
|
||||||
|
let capturedSubmitter = undefined;
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
capturedSubmitter = e.submitter;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.requestSubmit();
|
||||||
|
testing.expectEqual(form, capturedSubmitter);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -29,15 +29,17 @@
|
|||||||
|
|
||||||
testing.expectEqual('', img.src);
|
testing.expectEqual('', img.src);
|
||||||
testing.expectEqual('', img.alt);
|
testing.expectEqual('', img.alt);
|
||||||
|
testing.expectEqual('', img.currentSrc);
|
||||||
|
|
||||||
img.src = 'test.png';
|
img.src = 'test.png';
|
||||||
// src property returns resolved absolute URL
|
// src property returns resolved absolute URL
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.png', img.src);
|
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src);
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.currentSrc);
|
||||||
// getAttribute returns the raw attribute value
|
// getAttribute returns the raw attribute value
|
||||||
testing.expectEqual('test.png', img.getAttribute('src'));
|
testing.expectEqual('test.png', img.getAttribute('src'));
|
||||||
|
|
||||||
img.src = '/absolute/path.png';
|
img.src = '/absolute/path.png';
|
||||||
testing.expectEqual('http://127.0.0.1:9582/absolute/path.png', img.src);
|
testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src);
|
||||||
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
|
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
|
||||||
|
|
||||||
img.src = 'https://example.com/image.png';
|
img.src = 'https://example.com/image.png';
|
||||||
@@ -114,48 +116,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id="load-trigger-event">
|
<body></body>
|
||||||
|
|
||||||
|
<script id="img-load-event">
|
||||||
{
|
{
|
||||||
|
// An img fires a load event when src is set.
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
let count = 0;
|
let result = false;
|
||||||
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
|
|
||||||
testing.expectEqual(true, count < 3);
|
|
||||||
count++;
|
|
||||||
|
|
||||||
testing.expectEqual(false, bubbles);
|
|
||||||
testing.expectEqual(false, cancelBubble);
|
|
||||||
testing.expectEqual(false, cancelable);
|
|
||||||
testing.expectEqual(false, composed);
|
|
||||||
testing.expectEqual(true, isTrusted);
|
|
||||||
testing.expectEqual(img, target);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
|
|
||||||
testing.expectEqual("https://cdn.lightpanda.io/website/assets/images/docs/hn.png", img.src);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure count is incremented asynchronously.
|
|
||||||
testing.expectEqual(0, count);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<img
|
|
||||||
id="inline-img"
|
|
||||||
src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png"
|
|
||||||
onload="(() => testing.expectEqual(true, true))()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<script id="inline-on-load">
|
|
||||||
{
|
|
||||||
const img = document.getElementById("inline-img");
|
|
||||||
testing.expectEqual(true, img.onload instanceof Function);
|
|
||||||
// Also call inline to double check.
|
|
||||||
img.onload();
|
|
||||||
|
|
||||||
// Make sure ones attached with `addEventListener` also executed.
|
|
||||||
testing.async(async () => {
|
testing.async(async () => {
|
||||||
const result = await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
|
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
|
||||||
testing.expectEqual(false, bubbles);
|
testing.expectEqual(false, bubbles);
|
||||||
testing.expectEqual(false, cancelBubble);
|
testing.expectEqual(false, cancelBubble);
|
||||||
@@ -163,12 +132,46 @@
|
|||||||
testing.expectEqual(false, composed);
|
testing.expectEqual(false, composed);
|
||||||
testing.expectEqual(true, isTrusted);
|
testing.expectEqual(true, isTrusted);
|
||||||
testing.expectEqual(img, target);
|
testing.expectEqual(img, target);
|
||||||
|
result = true;
|
||||||
return resolve(true);
|
return resolve();
|
||||||
|
});
|
||||||
|
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testing.expectEqual(true, result);
|
testing.onload(() => testing.expectEqual(true, result));
|
||||||
});
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="img-no-load-without-src">
|
||||||
|
{
|
||||||
|
// An img without src should not fire a load event.
|
||||||
|
let fired = false;
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.addEventListener("load", () => { fired = true; });
|
||||||
|
document.body.appendChild(img);
|
||||||
|
testing.onload(() => testing.expectEqual(false, fired));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="lazy-src-set">
|
||||||
|
{
|
||||||
|
// Append to DOM first, then set src — load should still fire.
|
||||||
|
const img = document.createElement("img");
|
||||||
|
let result = false;
|
||||||
|
img.onload = () => result = true;
|
||||||
|
document.body.appendChild(img);
|
||||||
|
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
|
||||||
|
|
||||||
|
testing.onload(() => testing.expectEqual(true, result));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=url_encode>
|
||||||
|
{
|
||||||
|
let img = document.createElement('img');
|
||||||
|
img.src = 'over 9000!?hello=world !';
|
||||||
|
testing.expectEqual('over 9000!?hello=world !', img.getAttribute('src'));
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!?hello=world%20!', img.src);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
testing.expectEqual(5, input.maxLength);
|
testing.expectEqual(5, input.maxLength);
|
||||||
input.maxLength = 'banana';
|
input.maxLength = 'banana';
|
||||||
testing.expectEqual(0, input.maxLength);
|
testing.expectEqual(0, input.maxLength);
|
||||||
testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;});
|
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { input.maxLength = -45;});
|
||||||
|
|
||||||
testing.expectEqual(20, input.size);
|
testing.expectEqual(20, input.size);
|
||||||
input.size = 5;
|
input.size = 5;
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
|
|
||||||
testing.expectEqual('', input.src);
|
testing.expectEqual('', input.src);
|
||||||
input.src = 'foo'
|
input.src = 'foo'
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', input.src);
|
testing.expectEqual(testing.BASE_URL + 'element/html/foo', input.src);
|
||||||
input.src = '-3'
|
input.src = '-3'
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/-3', input.src);
|
testing.expectEqual(testing.BASE_URL + 'element/html/-3', input.src);
|
||||||
input.src = ''
|
input.src = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
});
|
});
|
||||||
input.setSelectionRange(1, 4);
|
input.setSelectionRange(1, 4);
|
||||||
|
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
testing.expectEqual(5, eventCount);
|
testing.expectEqual(5, eventCount);
|
||||||
testing.expectEqual('selectionchange', lastEvent.type);
|
testing.expectEqual('selectionchange', lastEvent.type);
|
||||||
testing.expectEqual(input, lastEvent.target);
|
testing.expectEqual(input, lastEvent.target);
|
||||||
@@ -221,6 +221,44 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="select_event">
|
||||||
|
{
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = 'Hello World';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
let eventCount = 0;
|
||||||
|
let lastEvent = null;
|
||||||
|
|
||||||
|
input.addEventListener('select', (e) => {
|
||||||
|
eventCount++;
|
||||||
|
lastEvent = e;
|
||||||
|
});
|
||||||
|
|
||||||
|
let onselectFired = false;
|
||||||
|
input.onselect = () => { onselectFired = true; };
|
||||||
|
|
||||||
|
let bubbledToBody = false;
|
||||||
|
document.body.addEventListener('select', () => {
|
||||||
|
bubbledToBody = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
testing.expectEqual(0, eventCount);
|
||||||
|
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
testing.onload(() => {
|
||||||
|
testing.expectEqual(1, eventCount);
|
||||||
|
testing.expectEqual('select', lastEvent.type);
|
||||||
|
testing.expectEqual(input, lastEvent.target);
|
||||||
|
testing.expectEqual(true, lastEvent.bubbles);
|
||||||
|
testing.expectEqual(false, lastEvent.cancelable);
|
||||||
|
testing.expectEqual(true, bubbledToBody);
|
||||||
|
testing.expectEqual(true, onselectFired);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id="defaultChecked">
|
<script id="defaultChecked">
|
||||||
testing.expectEqual(true, $('#check1').defaultChecked)
|
testing.expectEqual(true, $('#check1').defaultChecked)
|
||||||
testing.expectEqual(false, $('#check2').defaultChecked)
|
testing.expectEqual(false, $('#check2').defaultChecked)
|
||||||
|
|||||||
@@ -16,3 +16,59 @@
|
|||||||
testing.expectEqual('', l2.htmlFor);
|
testing.expectEqual('', l2.htmlFor);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<label id="l2" for="input1"><span>Name</span></label>
|
||||||
|
<input id="input2" type="text">
|
||||||
|
<input id="input-hidden" type="hidden">
|
||||||
|
<select id="sel1"><option>a</option></select>
|
||||||
|
<button id="btn1">Click</button>
|
||||||
|
<label id="l3"><input id="input3"><span>desc</span></label>
|
||||||
|
<label id="l4"><span>no control here</span></label>
|
||||||
|
<label id="l5"><label id="l5-inner"><input id="input5"></label></label>
|
||||||
|
|
||||||
|
<script id="control">
|
||||||
|
{
|
||||||
|
// for attribute pointing to a text input
|
||||||
|
const l2 = document.getElementById('l2');
|
||||||
|
testing.expectEqual('input1', l2.control.id);
|
||||||
|
|
||||||
|
// for attribute pointing to a non-existent id
|
||||||
|
const lMissing = document.createElement('label');
|
||||||
|
lMissing.htmlFor = 'does-not-exist';
|
||||||
|
testing.expectEqual(null, lMissing.control);
|
||||||
|
|
||||||
|
// for attribute pointing to a hidden input -> not labelable, returns null
|
||||||
|
const lHidden = document.createElement('label');
|
||||||
|
lHidden.htmlFor = 'input-hidden';
|
||||||
|
document.body.appendChild(lHidden);
|
||||||
|
testing.expectEqual(null, lHidden.control);
|
||||||
|
|
||||||
|
// for attribute pointing to a select
|
||||||
|
const lSel = document.createElement('label');
|
||||||
|
lSel.htmlFor = 'sel1';
|
||||||
|
document.body.appendChild(lSel);
|
||||||
|
testing.expectEqual('sel1', lSel.control.id);
|
||||||
|
|
||||||
|
// for attribute pointing to a button
|
||||||
|
const lBtn = document.createElement('label');
|
||||||
|
lBtn.htmlFor = 'btn1';
|
||||||
|
document.body.appendChild(lBtn);
|
||||||
|
testing.expectEqual('btn1', lBtn.control.id);
|
||||||
|
|
||||||
|
// no for attribute: first labelable descendant
|
||||||
|
const l3 = document.getElementById('l3');
|
||||||
|
testing.expectEqual('input3', l3.control.id);
|
||||||
|
|
||||||
|
// no for attribute: no labelable descendant -> null
|
||||||
|
const l4 = document.getElementById('l4');
|
||||||
|
testing.expectEqual(null, l4.control);
|
||||||
|
|
||||||
|
// no for attribute: nested labels, first labelable in tree order
|
||||||
|
const l5 = document.getElementById('l5');
|
||||||
|
testing.expectEqual('input5', l5.control.id);
|
||||||
|
|
||||||
|
// label with no for and not in document -> null
|
||||||
|
const lDetached = document.createElement('label');
|
||||||
|
testing.expectEqual(null, lDetached.control);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
|
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
|
||||||
|
|
||||||
l2.href = '/over/9000';
|
l2.href = '/over/9000';
|
||||||
testing.expectEqual('http://127.0.0.1:9582/over/9000', l2.href);
|
testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
|
||||||
|
|
||||||
l2.crossOrigin = 'nope';
|
l2.crossOrigin = 'nope';
|
||||||
testing.expectEqual('anonymous', l2.crossOrigin);
|
testing.expectEqual('anonymous', l2.crossOrigin);
|
||||||
@@ -19,3 +19,89 @@
|
|||||||
l2.crossOrigin = '';
|
l2.crossOrigin = '';
|
||||||
testing.expectEqual('anonymous', l2.crossOrigin);
|
testing.expectEqual('anonymous', l2.crossOrigin);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="link-load-event">
|
||||||
|
{
|
||||||
|
// A link with rel=stylesheet and a non-empty href fires a load event when appended to the DOM
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = 'https://lightpanda.io/opensource-browser/15';
|
||||||
|
|
||||||
|
testing.async(async () => {
|
||||||
|
const result = await new Promise(resolve => {
|
||||||
|
link.addEventListener('load', ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
|
||||||
|
testing.expectEqual(false, bubbles);
|
||||||
|
testing.expectEqual(false, cancelBubble);
|
||||||
|
testing.expectEqual(false, cancelable);
|
||||||
|
testing.expectEqual(false, composed);
|
||||||
|
testing.expectEqual(true, isTrusted);
|
||||||
|
testing.expectEqual(link, target);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
testing.expectEqual(true, result);
|
||||||
|
});
|
||||||
|
testing.expectEqual(true, true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="link-no-load-without-href">
|
||||||
|
{
|
||||||
|
// A link with rel=stylesheet but no href should not fire a load event
|
||||||
|
let fired = false;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.addEventListener('load', () => { fired = true; });
|
||||||
|
document.head.appendChild(link);
|
||||||
|
testing.onload(() => testing.expectEqual(false, fired));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="link-no-load-wrong-rel">
|
||||||
|
{
|
||||||
|
// A link without rel=stylesheet should not fire a load event
|
||||||
|
let fired = false;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.href = 'https://lightpanda.io/opensource-browser/15';
|
||||||
|
link.addEventListener('load', () => { fired = true; });
|
||||||
|
document.head.appendChild(link);
|
||||||
|
testing.onload(() => testing.expectEqual(false, fired));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="lazy-href-set">
|
||||||
|
{
|
||||||
|
let result = false;
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.onload = () => result = true;
|
||||||
|
// Append to DOM,
|
||||||
|
document.head.appendChild(link);
|
||||||
|
// then set href.
|
||||||
|
link.href = 'https://lightpanda.io/opensource-browser/15';
|
||||||
|
|
||||||
|
testing.onload(() => testing.expectEqual(true, result));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id="refs">
|
||||||
|
{
|
||||||
|
const rels = ['stylesheet', 'preload', 'modulepreload'];
|
||||||
|
const results = rels.map(() => false);
|
||||||
|
rels.forEach((rel, i) => {
|
||||||
|
let link = document.createElement('link')
|
||||||
|
link.rel = rel;
|
||||||
|
link.href = '/nope';
|
||||||
|
link.onload = () => results[i] = true;
|
||||||
|
document.documentElement.appendChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
testing.onload(() => {
|
||||||
|
results.forEach((r) => {
|
||||||
|
testing.expectEqual(true, r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -236,9 +236,11 @@
|
|||||||
{
|
{
|
||||||
const audio = document.createElement('audio');
|
const audio = document.createElement('audio');
|
||||||
testing.expectEqual('', audio.src);
|
testing.expectEqual('', audio.src);
|
||||||
|
testing.expectEqual('', audio.currentSrc);
|
||||||
|
|
||||||
audio.src = 'test.mp3';
|
audio.src = 'test.mp3';
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.mp3', audio.src);
|
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
|
||||||
|
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.currentSrc);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
testing.expectEqual('', video.poster);
|
testing.expectEqual('', video.poster);
|
||||||
|
|
||||||
video.poster = 'poster.jpg';
|
video.poster = 'poster.jpg';
|
||||||
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/poster.jpg', video.poster);
|
testing.expectEqual(testing.BASE_URL + 'element/html/poster.jpg', video.poster);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
testing.expectEqual('Text 3', $('#opt3').text)
|
testing.expectEqual('Text 3', $('#opt3').text)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="text_set">
|
||||||
|
$('#opt1').text = 'New Text 1'
|
||||||
|
testing.expectEqual('New Text 1', $('#opt1').text)
|
||||||
|
testing.expectEqual('New Text 1', $('#opt1').textContent)
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id="selected">
|
<script id="selected">
|
||||||
testing.expectEqual(false, $('#opt1').selected)
|
testing.expectEqual(false, $('#opt1').selected)
|
||||||
testing.expectEqual(true, $('#opt2').selected)
|
testing.expectEqual(true, $('#opt2').selected)
|
||||||
|
|||||||
61
src/browser/tests/element/html/script/async_text.html
Normal file
61
src/browser/tests/element/html/script/async_text.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../../../testing.js"></script>
|
||||||
|
|
||||||
|
<script id=force_async>
|
||||||
|
{
|
||||||
|
// Dynamically created scripts have async=true by default
|
||||||
|
let s = document.createElement('script');
|
||||||
|
testing.expectEqual(true, s.async);
|
||||||
|
|
||||||
|
// Setting async=false clears the force async flag and removes attribute
|
||||||
|
s.async = false;
|
||||||
|
testing.expectEqual(false, s.async);
|
||||||
|
testing.expectEqual(false, s.hasAttribute('async'));
|
||||||
|
|
||||||
|
// Setting async=true adds the attribute
|
||||||
|
s.async = true;
|
||||||
|
testing.expectEqual(true, s.async);
|
||||||
|
testing.expectEqual(true, s.hasAttribute('async'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script></script>
|
||||||
|
<script id=empty>
|
||||||
|
{
|
||||||
|
// Empty parser-inserted script should have async=true (force async retained)
|
||||||
|
let scripts = document.getElementsByTagName('script');
|
||||||
|
let emptyScript = scripts[scripts.length - 2];
|
||||||
|
testing.expectEqual(true, emptyScript.async);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=text_content>
|
||||||
|
{
|
||||||
|
let s = document.createElement('script');
|
||||||
|
s.appendChild(document.createComment('COMMENT'));
|
||||||
|
s.appendChild(document.createTextNode(' TEXT '));
|
||||||
|
s.appendChild(document.createProcessingInstruction('P', 'I'));
|
||||||
|
let a = s.appendChild(document.createElement('a'));
|
||||||
|
a.appendChild(document.createTextNode('ELEMENT'));
|
||||||
|
|
||||||
|
// script.text should return only direct Text node children
|
||||||
|
testing.expectEqual(' TEXT ', s.text);
|
||||||
|
// script.textContent should return all descendant text
|
||||||
|
testing.expectEqual(' TEXT ELEMENT', s.textContent);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=lazy_inline>
|
||||||
|
{
|
||||||
|
// Empty script in DOM, then append text - should execute
|
||||||
|
window.lazyScriptRan = false;
|
||||||
|
let s = document.createElement('script');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
// Script is in DOM but empty, so not yet executed
|
||||||
|
testing.expectEqual(false, window.lazyScriptRan);
|
||||||
|
// Append text node with code
|
||||||
|
s.appendChild(document.createTextNode('window.lazyScriptRan = true;'));
|
||||||
|
// Now it should have executed
|
||||||
|
testing.expectEqual(true, window.lazyScriptRan);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
script1.async = false;
|
script1.async = false;
|
||||||
script1.src = "dynamic1.js";
|
script1.src = "dynamic1.js";
|
||||||
document.getElementsByTagName('head')[0].appendChild(script1);
|
document.getElementsByTagName('head')[0].appendChild(script1);
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
testing.expectEqual(1, loaded1);
|
testing.expectEqual(1, loaded1);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=no_double_execute>
|
<script id=no_double_execute>
|
||||||
document.getElementsByTagName('head')[0].appendChild(script1);
|
document.getElementsByTagName('head')[0].appendChild(script1);
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
testing.expectEqual(1, loaded1);
|
testing.expectEqual(1, loaded1);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
const script2a = document.createElement('script');
|
const script2a = document.createElement('script');
|
||||||
script2a.src = "dynamic2.js";
|
script2a.src = "dynamic2.js";
|
||||||
document.getElementsByTagName('head')[0].appendChild(script2a);
|
document.getElementsByTagName('head')[0].appendChild(script2a);
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
testing.expectEqual(2, loaded2);
|
testing.expectEqual(2, loaded2);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=src_after_append>
|
<script id=src_after_append>
|
||||||
testing.eventually(() => {
|
testing.onload(() => {
|
||||||
testing.expectEqual(2, loaded2);
|
testing.expectEqual(2, loaded2);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal file
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head></head>
|
||||||
|
<script src="../../../testing.js"></script>
|
||||||
|
|
||||||
|
<script id=textContent_inline>
|
||||||
|
window.inline_executed = false;
|
||||||
|
const s1 = document.createElement('script');
|
||||||
|
s1.textContent = 'window.inline_executed = true;';
|
||||||
|
document.head.appendChild(s1);
|
||||||
|
testing.expectTrue(window.inline_executed);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=text_property_inline>
|
||||||
|
window.text_executed = false;
|
||||||
|
const s2 = document.createElement('script');
|
||||||
|
s2.text = 'window.text_executed = true;';
|
||||||
|
document.head.appendChild(s2);
|
||||||
|
testing.expectTrue(window.text_executed);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=innerHTML_inline>
|
||||||
|
window.innerHTML_executed = false;
|
||||||
|
const s3 = document.createElement('script');
|
||||||
|
s3.innerHTML = 'window.innerHTML_executed = true;';
|
||||||
|
document.head.appendChild(s3);
|
||||||
|
testing.expectTrue(window.innerHTML_executed);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=no_double_execute_inline>
|
||||||
|
window.inline_counter = 0;
|
||||||
|
const s4 = document.createElement('script');
|
||||||
|
s4.textContent = 'window.inline_counter++;';
|
||||||
|
document.head.appendChild(s4);
|
||||||
|
document.head.appendChild(s4);
|
||||||
|
testing.expectEqual(1, window.inline_counter);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=empty_script_no_execute>
|
||||||
|
window.empty_ran = false;
|
||||||
|
const s5 = document.createElement('script');
|
||||||
|
document.head.appendChild(s5);
|
||||||
|
testing.expectFalse(window.empty_ran);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=module_inline>
|
||||||
|
window.module_executed = false;
|
||||||
|
const s6 = document.createElement('script');
|
||||||
|
s6.type = 'module';
|
||||||
|
s6.textContent = 'window.module_executed = true;';
|
||||||
|
document.head.appendChild(s6);
|
||||||
|
testing.onload(() => {
|
||||||
|
testing.expectTrue(window.module_executed);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user