Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f423c5dc4e | ||
|
|
0a1b9ce27a | ||
|
|
bb9429e907 | ||
|
|
1213854644 | ||
|
|
e3d4100055 | ||
|
|
418426419d | ||
|
|
707048a1df | ||
|
|
e9ab1fd16e | ||
|
|
cc74359e83 | ||
|
|
9be862e37b | ||
|
|
2133c1e16a | ||
|
|
e46d4cff27 | ||
|
|
38b362e5fe | ||
|
|
6d01461e53 | ||
|
|
d40d4ec7fb | ||
|
|
96f720c195 | ||
|
|
3f5c54d5de | ||
|
|
c62805ae57 | ||
|
|
acb1b72036 | ||
|
|
443f452689 | ||
|
|
72a347aa9f | ||
|
|
ab7ffa0d94 | ||
|
|
1259439603 | ||
|
|
208ed63c96 | ||
|
|
dd439d5ec3 | ||
|
|
44a14f326b | ||
|
|
4d4a9523f8 | ||
|
|
b24a676f22 | ||
|
|
4e692d608c | ||
|
|
b54eb0cca9 | ||
|
|
24ad2f45cc | ||
|
|
27b5ce8d60 | ||
|
|
1ce350b12e | ||
|
|
405d53220b | ||
|
|
3123e90d5a | ||
|
|
ea7a9811e4 | ||
|
|
74b04ff922 | ||
|
|
4b24cd68ce | ||
|
|
c94089842a | ||
|
|
9c0743d668 | ||
|
|
d023af20a0 | ||
|
|
b81ddd8dd9 | ||
|
|
c1e7f58567 | ||
|
|
8c055d0b72 | ||
|
|
44a30f7a0d | ||
|
|
f837ad2270 | ||
|
|
6b909bd0e3 | ||
|
|
23d7dea0bb | ||
|
|
fc13053545 | ||
|
|
e874ccb5b1 | ||
|
|
fd11e8c1fe | ||
|
|
294c5ab06d | ||
|
|
576dac3aff | ||
|
|
4b80310c0d | ||
|
|
bd9cb2e257 | ||
|
|
64733d55f8 | ||
|
|
88483af607 | ||
|
|
1726e023ea | ||
|
|
7e9b12f818 | ||
|
|
834a9c9ac5 | ||
|
|
3f098cd33a | ||
|
|
4193d61e0b | ||
|
|
e8d464a244 | ||
|
|
4c1c375eef | ||
|
|
65a2bbae0a | ||
|
|
57616aba62 | ||
|
|
4396bd4625 | ||
|
|
df4f5685be | ||
|
|
1e5f797195 | ||
|
|
8f06ceaedd | ||
|
|
6b58b72028 | ||
|
|
51dc1b1618 | ||
|
|
be5549bf81 | ||
|
|
c4b7bd4d71 | ||
|
|
f25eb2901e | ||
|
|
65025b57c5 | ||
|
|
8e3b212df0 | ||
|
|
d93af3d6e4 | ||
|
|
dd0c17a9d4 | ||
|
|
d334726991 | ||
|
|
9aea4e5677 | ||
|
|
ffd9efeb98 | ||
|
|
5bf3bd7ce5 | ||
|
|
693fce1bb1 | ||
|
|
48fbb1076f | ||
|
|
7bc994b99f | ||
|
|
8586d1cc12 | ||
|
|
de280c23ea | ||
|
|
06887003cf | ||
|
|
e2eacbc051 | ||
|
|
93b81493c0 | ||
|
|
4287f60ab3 | ||
|
|
f62e9baa4a | ||
|
|
2d2da6e939 | ||
|
|
2bf3d5372b | ||
|
|
a4f8e20293 | ||
|
|
ac915e8973 | ||
|
|
e19a521c4e | ||
|
|
bafea756ee | ||
|
|
1bdfc8a5d2 | ||
|
|
6e6285c5ae | ||
|
|
f49411b3a9 | ||
|
|
1d3dee1950 | ||
|
|
caf3ddd68a | ||
|
|
17b5168bde | ||
|
|
9bfc16a4d5 | ||
|
|
98f5245317 | ||
|
|
4286c528f5 | ||
|
|
8f5f9d6238 | ||
|
|
26d3a53d44 | ||
|
|
de8e444aba | ||
|
|
35da556697 | ||
|
|
52ac5e5424 | ||
|
|
517206364a | ||
|
|
5e82eea9f1 | ||
|
|
b6e8768767 | ||
|
|
5df3d4f63b | ||
|
|
c59e9fc3f5 | ||
|
|
febe9871b9 | ||
|
|
4d5d881af6 | ||
|
|
2735deff73 | ||
|
|
ee93add6fc | ||
|
|
8ffab2ae70 | ||
|
|
fa216dddd5 | ||
|
|
34ec39a858 | ||
|
|
7dd611b61a | ||
|
|
5d017258b7 | ||
|
|
e58c00ecdf | ||
|
|
b88848ba8a | ||
|
|
6b6e7c647a | ||
|
|
b972422cc6 | ||
|
|
52ded23315 | ||
|
|
b3399f419e | ||
|
|
e2d60dde43 | ||
|
|
214b3c75d0 | ||
|
|
95659839b2 | ||
|
|
cab3f57737 | ||
|
|
dbbd01a83d | ||
|
|
90e66841df | ||
|
|
5ba41c6ea0 | ||
|
|
63c0de42db | ||
|
|
5646a22245 | ||
|
|
0e13801a7a | ||
|
|
7b0f7d639a | ||
|
|
301766f4aa | ||
|
|
6bdbfa2657 | ||
|
|
f5dafe6d43 | ||
|
|
623f25e521 | ||
|
|
a0e8a7f935 | ||
|
|
61bd31e421 | ||
|
|
84425e1319 | ||
|
|
09615f8ec4 | ||
|
|
3d368482bb | ||
|
|
df2cec5a94 | ||
|
|
369512c6c1 | ||
|
|
10a4cc270a | ||
|
|
1eda5e5860 | ||
|
|
3fc0d9418f | ||
|
|
c4f243f4b0 | ||
|
|
e057d740d8 | ||
|
|
a20c5677b4 | ||
|
|
7f687051f8 | ||
|
|
450c5c9829 | ||
|
|
0ab019e5ab | ||
|
|
40b3fa91ac | ||
|
|
f59c8da000 | ||
|
|
3555d01b68 | ||
|
|
b201b32b46 | ||
|
|
89f79e0977 | ||
|
|
aa0233f49b | ||
|
|
df4a45afb2 | ||
|
|
def0144dab | ||
|
|
ce16fa154c | ||
|
|
063a21997b | ||
|
|
92fce802c5 | ||
|
|
f76d597cbf | ||
|
|
ecbbbc2d6b | ||
|
|
2bb00a177e | ||
|
|
e13b2d4edc | ||
|
|
bb3a94eb84 | ||
|
|
49b460eee3 | ||
|
|
28da1a0ec9 | ||
|
|
6176e6ff40 | ||
|
|
4406287a5f | ||
|
|
3f63f289ed | ||
|
|
5c07dd3023 | ||
|
|
fe71591200 | ||
|
|
3f87e23bd7 | ||
|
|
9d2d99b5ba | ||
|
|
76df43fe66 | ||
|
|
0d72f6b987 | ||
|
|
507b6b9133 | ||
|
|
4911d61d78 | ||
|
|
bdf5f82959 | ||
|
|
86310083f2 | ||
|
|
29faa4932d | ||
|
|
f498bdabb3 | ||
|
|
f4a1f06ba6 | ||
|
|
317b0fc4d7 | ||
|
|
5d081f27d1 | ||
|
|
9ca126f3cd | ||
|
|
736d447d9e | ||
|
|
f2f27ac639 | ||
|
|
90f6fd83ba | ||
|
|
d9e9465415 | ||
|
|
f73fc49ee4 | ||
|
|
afe448647c | ||
|
|
091242bedc | ||
|
|
8d4f76156b | ||
|
|
50eacba438 | ||
|
|
5273dfbbf3 | ||
|
|
db5632d3ba | ||
|
|
9898a8424a | ||
|
|
0f354cfe9b | ||
|
|
b433736241 | ||
|
|
cee48c1f45 | ||
|
|
6d1ea96e79 | ||
|
|
5053f3f0f8 | ||
|
|
7b16c4ebe1 | ||
|
|
b8636029c9 | ||
|
|
357158db3e | ||
|
|
796ee5516f | ||
|
|
c82c1c192f | ||
|
|
c20cacac99 | ||
|
|
decfc5823d | ||
|
|
6da6377bb5 | ||
|
|
eedb46e000 | ||
|
|
fe8466b0e3 | ||
|
|
f10fbc3fa3 | ||
|
|
e186620a60 | ||
|
|
e1316ea48b | ||
|
|
a9adb3bb06 | ||
|
|
5ccab1770b | ||
|
|
3d377b7b1f | ||
|
|
8bdb578b90 | ||
|
|
50a9ea628d | ||
|
|
637ce5c1bc | ||
|
|
7432f4de08 | ||
|
|
594267b936 | ||
|
|
e210eca290 | ||
|
|
189ce3db64 | ||
|
|
07028ad3cd | ||
|
|
0d71781071 | ||
|
|
8e72efdd96 | ||
|
|
e0a656e2ba | ||
|
|
2afab43281 | ||
|
|
d1a09260d5 | ||
|
|
926db1c2b0 | ||
|
|
12dd676e9d | ||
|
|
dac72eec20 | ||
|
|
5e4d3c40f5 | ||
|
|
990237f975 | ||
|
|
4e29d5cbea | ||
|
|
9d58dc20cb | ||
|
|
b6bf77956b | ||
|
|
ae9e520df1 | ||
|
|
ae65c1af3d | ||
|
|
6f892c7dbf | ||
|
|
0c4fad6f54 | ||
|
|
1a9b749017 | ||
|
|
aee81cb824 | ||
|
|
6d6608754d | ||
|
|
326bfc035f | ||
|
|
3ff23e1c47 | ||
|
|
ada82fa7e1 | ||
|
|
51e4265109 | ||
|
|
eaebb3adfa | ||
|
|
76b4ca6ea5 | ||
|
|
b8560c6e15 | ||
|
|
8eec2549a4 | ||
|
|
aaa1ad7eaa | ||
|
|
74c57fc11c | ||
|
|
b7e25c4e1c | ||
|
|
3f97333423 | ||
|
|
71152f64a5 | ||
|
|
531d125dcc | ||
|
|
299e79e890 | ||
|
|
14f60d8dd3 | ||
|
|
a00b1a96c7 | ||
|
|
2847e02c5d | ||
|
|
57b30a1838 | ||
|
|
f2a637ada7 | ||
|
|
b3804586dc | ||
|
|
113c1f0565 | ||
|
|
3948eeaad4 | ||
|
|
f843184970 | ||
|
|
2aa9edeb8f | ||
|
|
5e37cf8224 | ||
|
|
8bd3a1edde | ||
|
|
c68962e2f2 | ||
|
|
6a9a247a87 | ||
|
|
e238370bc8 | ||
|
|
178f2fd135 | ||
|
|
55cd1160db | ||
|
|
06bdbd929c | ||
|
|
100eb01f27 | ||
|
|
00ade4ad29 | ||
|
|
f3a5779102 | ||
|
|
6f0b97e760 | ||
|
|
28f9ed64ff | ||
|
|
b6924f70d4 | ||
|
|
79ecae34c8 | ||
|
|
f4843baa0e | ||
|
|
cd97a275fe | ||
|
|
f2e77a5c3a | ||
|
|
23f4f9bd0b | ||
|
|
e8eb6964db | ||
|
|
9643c5bfcd | ||
|
|
56ebe6bb8b | ||
|
|
5f7c2f07b8 | ||
|
|
bbe965e7a3 | ||
|
|
ebd03c7b1a | ||
|
|
0d02a57600 | ||
|
|
394d3a5b25 | ||
|
|
4f9589fb6b | ||
|
|
048e3396de | ||
|
|
e3d378a4ba | ||
|
|
91f8d9c2b0 | ||
|
|
2285c896d2 | ||
|
|
9ff24918e2 | ||
|
|
e78e794240 | ||
|
|
0eb359542b | ||
|
|
c54cf5a2ba | ||
|
|
6f7f05b456 | ||
|
|
da6a8834dd | ||
|
|
7ed16d63f3 | ||
|
|
d8730cc67b | ||
|
|
b67efe0cac | ||
|
|
835cb2f90f | ||
|
|
f75e99f703 | ||
|
|
7e1bf0fc92 | ||
|
|
82693a8587 | ||
|
|
99eace0336 | ||
|
|
d0257fea8f | ||
|
|
453a5cda2e | ||
|
|
bdbd1a0f08 | ||
|
|
d32b9f78a4 | ||
|
|
46c2d8c49e | ||
|
|
88b5567131 | ||
|
|
0e81d16feb | ||
|
|
a900f0ff65 | ||
|
|
d4faa75b79 | ||
|
|
47b0de1b49 | ||
|
|
fe6a10592b | ||
|
|
8f02349ca5 | ||
|
|
1fb32de428 | ||
|
|
31b66ee6fa | ||
|
|
d22f9b81b2 | ||
|
|
3051d61365 | ||
|
|
53fd2e48e5 | ||
|
|
ef516c123d | ||
|
|
add8962390 | ||
|
|
1a444a481e | ||
|
|
96ca4a7933 | ||
|
|
f07bc615b0 | ||
|
|
2d0c88390a | ||
|
|
52574623fe | ||
|
|
b1d95ddf5f | ||
|
|
092bb285ed | ||
|
|
52ce26796a | ||
|
|
6bd55e4807 | ||
|
|
8325a9297e | ||
|
|
9cb3c78def | ||
|
|
143436ecd3 | ||
|
|
83cf87c8cf | ||
|
|
2f1003e8bc | ||
|
|
e0649eb9bd | ||
|
|
d5c607d370 | ||
|
|
2c2c8bf4cb | ||
|
|
08459ca289 | ||
|
|
162f1fa1d1 | ||
|
|
d9113edbbe | ||
|
|
eb1c5c07e9 | ||
|
|
0574429a93 | ||
|
|
8ff565404b | ||
|
|
b08d4b4cae | ||
|
|
f331662263 | ||
|
|
10693283c5 | ||
|
|
f2a933c51b | ||
|
|
172b8cfb9b | ||
|
|
47738ffa26 | ||
|
|
c8a29b4651 | ||
|
|
c305cc2f7d | ||
|
|
01676e1f99 | ||
|
|
de1e15fb25 | ||
|
|
0aa77c33ad | ||
|
|
22eeb1a47a | ||
|
|
15b3c6617b | ||
|
|
e342a88f7d | ||
|
|
7779efedf1 | ||
|
|
137b4d25b9 | ||
|
|
9be40adabf | ||
|
|
b6d4b7b40d | ||
|
|
3256789082 | ||
|
|
87adc575ac | ||
|
|
30ae0f93f3 | ||
|
|
c339654112 | ||
|
|
25c9770fba | ||
|
|
0bf7326604 | ||
|
|
7fc5a9ba8f | ||
|
|
230f805cae | ||
|
|
bc9c828584 | ||
|
|
6c17a709bf | ||
|
|
dd912ecc8f | ||
|
|
c9b12c4892 | ||
|
|
414aea2637 | ||
|
|
b7bc4dbadc | ||
|
|
8a8b871908 | ||
|
|
1225b27510 | ||
|
|
f86bd86043 | ||
|
|
8133164f44 | ||
|
|
2f447da34b | ||
|
|
41cdc8783e | ||
|
|
ccbb324270 | ||
|
|
8d4a0c34e7 | ||
|
|
2620f75955 | ||
|
|
4ca525f701 | ||
|
|
53a9aa377a | ||
|
|
d166003dfe | ||
|
|
31d80791a9 | ||
|
|
02245aa5f1 | ||
|
|
583b6b9a55 | ||
|
|
d4815e855e | ||
|
|
113665139a | ||
|
|
37e35b0ed6 | ||
|
|
eef8f00d8b | ||
|
|
bdb15a7780 | ||
|
|
2b59912549 | ||
|
|
8623085c2a | ||
|
|
612627d66f | ||
|
|
04700beab3 | ||
|
|
0d3d6c88e4 | ||
|
|
db57f67e98 | ||
|
|
baa145e15c | ||
|
|
c1bae20f01 | ||
|
|
41bc676b12 | ||
|
|
cea74dd63d | ||
|
|
9ccca3b33b | ||
|
|
1606cbab18 | ||
|
|
c428c8ae2d | ||
|
|
b52e84d1e7 | ||
|
|
3fbcbbe8e0 | ||
|
|
74eabd700c | ||
|
|
d7ddae38e5 | ||
|
|
fe7de2db99 | ||
|
|
9b9de42139 | ||
|
|
7b64288765 | ||
|
|
c6c967f89c | ||
|
|
040dba961a | ||
|
|
0146794cda | ||
|
|
b00eba77f9 | ||
|
|
3f27ae249a | ||
|
|
4384cffabe | ||
|
|
fde3234fc6 | ||
|
|
d998c7cd46 | ||
|
|
a8b62fc184 | ||
|
|
65de8e08d3 | ||
|
|
73cc1cd2e5 | ||
|
|
26926071b0 | ||
|
|
5c433129ec | ||
|
|
68bc1a6608 | ||
|
|
e67a0e0bc0 | ||
|
|
f553d45396 | ||
|
|
090a53b95a | ||
|
|
22ab7515fb | ||
|
|
a97dcc5efb | ||
|
|
4b098aef69 | ||
|
|
cb46274288 | ||
|
|
1e60a43075 | ||
|
|
1aa9cd032f | ||
|
|
5e3748315c | ||
|
|
7f67c032d1 | ||
|
|
33621af3ad | ||
|
|
2fb4a7bd6a | ||
|
|
1ff983a03b | ||
|
|
701530ef04 | ||
|
|
719e624ddf | ||
|
|
03a25df367 | ||
|
|
c93ce684a8 | ||
|
|
090708f38b | ||
|
|
49f93e660e | ||
|
|
867680f4e9 | ||
|
|
36ac529cc8 | ||
|
|
0ae388f0f6 | ||
|
|
b53ace765c | ||
|
|
f256299a0c | ||
|
|
4fb05e406a | ||
|
|
035639c68a | ||
|
|
09f53cac92 | ||
|
|
05a27d9003 | ||
|
|
5d4d1807ba | ||
|
|
836e465414 | ||
|
|
94457b24cd | ||
|
|
728aaec24d | ||
|
|
4ccd2d7087 | ||
|
|
9bc6d2928b | ||
|
|
d7d4fb36ad | ||
|
|
4e6a88abec | ||
|
|
a7051ace5d | ||
|
|
cee47f2846 | ||
|
|
f9e60acdf6 | ||
|
|
5f6ba048e6 | ||
|
|
aba91de480 | ||
|
|
118ec757a3 | ||
|
|
eaf712937b | ||
|
|
6c47c406cb | ||
|
|
eaeff3bf6a | ||
|
|
5b5fb3e32d | ||
|
|
07a8dd4912 | ||
|
|
a2f5127222 | ||
|
|
eab48ea202 | ||
|
|
00e2717653 | ||
|
|
d83f150810 | ||
|
|
2a7e6c2dd2 | ||
|
|
f8f792645b | ||
|
|
8c79f39389 | ||
|
|
f7da653e87 | ||
|
|
1ed753eff1 | ||
|
|
385de967a4 | ||
|
|
ac84019777 | ||
|
|
b0c6fe9474 | ||
|
|
94e2b0233a | ||
|
|
c202e3426b | ||
|
|
7e86e83173 | ||
|
|
604857648b | ||
|
|
76fb50473f | ||
|
|
ffee3b225f | ||
|
|
5f1dd42559 | ||
|
|
57a7c78ce2 | ||
|
|
33c6643388 | ||
|
|
74deba61e6 | ||
|
|
bb2f0e6c55 | ||
|
|
b2f6f68d1a | ||
|
|
fdde065a94 | ||
|
|
9231b0914b | ||
|
|
78f49ae7c9 | ||
|
|
95ff81d81f | ||
|
|
92b73f62ad | ||
|
|
1057836788 | ||
|
|
93f8702962 | ||
|
|
3d71454666 | ||
|
|
c0cc2744c6 | ||
|
|
65b1c34d06 | ||
|
|
67615719ae | ||
|
|
491c8ebe77 | ||
|
|
cae23d61e2 | ||
|
|
9cad9ad2f9 | ||
|
|
ba977d6b49 | ||
|
|
88bb2f91a5 | ||
|
|
dda11627f8 | ||
|
|
2df713ca0c | ||
|
|
486ede9396 | ||
|
|
72afcc3e5a | ||
|
|
ef2eaceb88 | ||
|
|
c2db1c70e3 | ||
|
|
13413899eb | ||
|
|
98f074e8d5 | ||
|
|
36a60bae4d | ||
|
|
7c5a5daf2f | ||
|
|
7b74df7f04 | ||
|
|
a6813a7ec1 | ||
|
|
484d7ee3b2 | ||
|
|
7a1bc282e9 | ||
|
|
f45480c0b9 | ||
|
|
23d980910d | ||
|
|
9b939aa8b4 | ||
|
|
838a9c3e7d | ||
|
|
f91de30717 | ||
|
|
0c3299573f | ||
|
|
13e2d2d597 | ||
|
|
a6bbc7119d | ||
|
|
20bf58b84f | ||
|
|
fe176c92c0 | ||
|
|
6009ed53b7 | ||
|
|
79c387ba45 | ||
|
|
dd767d6664 | ||
|
|
61202f1635 | ||
|
|
457d6cc54b | ||
|
|
bf8123b7c5 | ||
|
|
c98ede9b81 | ||
|
|
34671fca59 | ||
|
|
7ad9381aad | ||
|
|
7387eefb10 | ||
|
|
ca3403a5b2 | ||
|
|
8adc895c8c | ||
|
|
6fe49d06ac | ||
|
|
c47e6d0219 | ||
|
|
99c4b240ec | ||
|
|
0947724245 | ||
|
|
15653ef961 | ||
|
|
893c92266a | ||
|
|
50571995a4 | ||
|
|
dc2f6e17cd | ||
|
|
9a861b085a | ||
|
|
714cb318a9 | ||
|
|
2827463b41 | ||
|
|
121a8b11b8 | ||
|
|
3e0e31a5fe | ||
|
|
a7c96f77fe | ||
|
|
d3444e9aa0 | ||
|
|
71d2e61551 | ||
|
|
3756d1185f | ||
|
|
5a22eb9542 | ||
|
|
cb535f1cee | ||
|
|
f304e5efb7 | ||
|
|
5732a8ffc8 | ||
|
|
8b7ccb837a | ||
|
|
f4bb3fc0bc | ||
|
|
62602a88bc | ||
|
|
f63fa9faf2 | ||
|
|
839bfd4756 | ||
|
|
9392d21783 | ||
|
|
ca5594dfbb | ||
|
|
1b938417e5 | ||
|
|
f95e94ac6d | ||
|
|
e9bb72a416 | ||
|
|
2d003febc1 | ||
|
|
42c27af604 | ||
|
|
ee490fc660 | ||
|
|
10500c2fba | ||
|
|
c4011c0817 | ||
|
|
bc49bebb58 | ||
|
|
dcdd93bcaa | ||
|
|
d5fdc3ce63 | ||
|
|
c1b5a8625a | ||
|
|
dd1fd34865 | ||
|
|
9b9e65abd4 | ||
|
|
e5f917a185 | ||
|
|
368bf4a6d9 | ||
|
|
a7df7a5436 | ||
|
|
d3f5236073 | ||
|
|
db6fdbc121 | ||
|
|
dc9aa4d6b4 | ||
|
|
5f2553047d | ||
|
|
d556f84929 | ||
|
|
4345ccf356 | ||
|
|
4032468e10 | ||
|
|
5330f0f8e2 | ||
|
|
39e6ecc920 | ||
|
|
928e8f14ea | ||
|
|
0e4ffcee62 | ||
|
|
955f353a31 | ||
|
|
44df06fac7 | ||
|
|
f099f6f4a9 | ||
|
|
e9259356bd | ||
|
|
2264445772 | ||
|
|
47f0e1f1bf | ||
|
|
79d0898fb5 | ||
|
|
85d1119ad6 | ||
|
|
52e73ca7cd | ||
|
|
b5ac42792e | ||
|
|
6a844e163b | ||
|
|
efa4151850 | ||
|
|
55dc387d3c | ||
|
|
ae47333eef | ||
|
|
5bbe4cde77 | ||
|
|
0ff35d13e8 | ||
|
|
3059ab835b | ||
|
|
798f295912 | ||
|
|
71d115d161 | ||
|
|
d5368e5f90 | ||
|
|
d1f0b2e459 | ||
|
|
ed4ddc0a43 | ||
|
|
61c09a5b49 | ||
|
|
a6194f2535 | ||
|
|
901116fde6 | ||
|
|
d829525ab8 | ||
|
|
ee666a3a6f | ||
|
|
4a8a5829d7 | ||
|
|
fd2271f6d6 | ||
|
|
cac36727e8 | ||
|
|
e6bd0d0006 | ||
|
|
4ef83a844f | ||
|
|
23dc46d697 | ||
|
|
6fc502b0b2 | ||
|
|
a4f22a8542 | ||
|
|
bce7a1ad9a | ||
|
|
49a6e4633a | ||
|
|
41c02cb732 | ||
|
|
0f00111373 | ||
|
|
6521d24925 | ||
|
|
34468198ec | ||
|
|
ab30aae08e | ||
|
|
eb4d8cde7c | ||
|
|
95daa4b02e | ||
|
|
7e7331fb6f | ||
|
|
7485f00f2f | ||
|
|
c3704fa4ff | ||
|
|
db39e79cf7 | ||
|
|
7c8ffec254 | ||
|
|
7e3d5d17f8 | ||
|
|
bbfe228e4c | ||
|
|
a15e04c7d0 | ||
|
|
301b4de31d | ||
|
|
73f48b71a2 | ||
|
|
d7aa24b467 | ||
|
|
377dee7d83 | ||
|
|
079ec94197 | ||
|
|
aee0905ea5 | ||
|
|
5bc32ea89a | ||
|
|
a6162b8440 | ||
|
|
3f389fcf79 | ||
|
|
d78716c873 | ||
|
|
aabbf882df | ||
|
|
6b1b9252b3 | ||
|
|
e26fd5ed6f | ||
|
|
249cfa21e3 | ||
|
|
fb4d6afb07 | ||
|
|
909ffc1aac | ||
|
|
9ec5bd7183 | ||
|
|
dbde3de8e8 | ||
|
|
5c80f3197a | ||
|
|
17a644c5d8 | ||
|
|
9bf420d317 | ||
|
|
be2911707c | ||
|
|
f601044645 | ||
|
|
c55a83cbe2 | ||
|
|
ef9326abc6 | ||
|
|
a219c1b570 | ||
|
|
1920a3e802 | ||
|
|
67d0809e2f | ||
|
|
7c6676c981 | ||
|
|
519f2ba617 | ||
|
|
0b23fad909 | ||
|
|
6fe61a041e | ||
|
|
fb323c2086 | ||
|
|
dccb9bb49c | ||
|
|
e9505f31f7 | ||
|
|
c31e880f63 | ||
|
|
b064e73ef4 | ||
|
|
606c2c6f95 | ||
|
|
c0e02726be | ||
|
|
2ddf2e8341 | ||
|
|
5d2e647f10 | ||
|
|
a141122fbd | ||
|
|
b186677a53 | ||
|
|
c8ba9ef99a | ||
|
|
c994638168 | ||
|
|
b6df64337d | ||
|
|
1c0936d4c1 | ||
|
|
983671f5c3 | ||
|
|
d6f76de349 | ||
|
|
7aede20819 | ||
|
|
0e5c7e1215 | ||
|
|
3cd60e9a8d | ||
|
|
cf79e95c45 | ||
|
|
3c22cd216c | ||
|
|
cb20075599 | ||
|
|
c1257afc4c | ||
|
|
0862116a40 | ||
|
|
6eefc0a623 | ||
|
|
ed4f448370 | ||
|
|
3dc4f4641c | ||
|
|
95a5f5063e | ||
|
|
4cca167fc1 | ||
|
|
301ebd9b6f | ||
|
|
01fe0ca381 | ||
|
|
b6a9f5109c | ||
|
|
3320e993e7 | ||
|
|
239672116b | ||
|
|
4d8e71fff4 | ||
|
|
4babf6d30e | ||
|
|
9fe1808e1c | ||
|
|
ebb9dbc7f1 | ||
|
|
e2dc5e0705 | ||
|
|
f66f464199 | ||
|
|
35d42b9ec5 | ||
|
|
0776551aa7 | ||
|
|
482a890447 | ||
|
|
bc8ebd8db3 | ||
|
|
521a81ca1d | ||
|
|
c4a7b1f129 | ||
|
|
d712eb3a59 | ||
|
|
21cd0cb346 | ||
|
|
b1a0dfa7bd | ||
|
|
ffbedc1e02 | ||
|
|
0a647264d7 | ||
|
|
1385e28911 | ||
|
|
2ab843efdb | ||
|
|
90dac7dd19 | ||
|
|
792f7d8e21 | ||
|
|
5b0eb07d0d | ||
|
|
8abfe93df7 | ||
|
|
3ad6cdef51 | ||
|
|
0da2ad0c10 | ||
|
|
84faabca86 | ||
|
|
49015e3422 | ||
|
|
906f0876a0 | ||
|
|
e18cd87a07 | ||
|
|
35aed5fb55 | ||
|
|
6089e5fe20 | ||
|
|
2a316ebb1e | ||
|
|
1f83fe1b48 | ||
|
|
7d1bb4af5b | ||
|
|
f57fea0a54 | ||
|
|
13787708e9 | ||
|
|
7af3bc519a | ||
|
|
e3d96de203 | ||
|
|
ec08177607 | ||
|
|
362dc9bbcc | ||
|
|
a9fb0b3b08 | ||
|
|
c6b417ca81 | ||
|
|
4e64af0f10 | ||
|
|
2a2d99191d | ||
|
|
b0b6e71d2e | ||
|
|
041904f4c6 | ||
|
|
5b2bf8956a | ||
|
|
a1278fad49 | ||
|
|
d84b831922 | ||
|
|
fe37d4e91c | ||
|
|
f3a08df532 | ||
|
|
f9b0f6d613 | ||
|
|
b72e74e0d3 | ||
|
|
0ba526ebd8 | ||
|
|
92facbb9ef | ||
|
|
bddab92c31 | ||
|
|
3b94a4520c | ||
|
|
88beb41143 | ||
|
|
2f06f4d8d7 | ||
|
|
dc5bd270ea | ||
|
|
6bb2be532a | ||
|
|
ace02138ff | ||
|
|
c85d5042e0 | ||
|
|
bd66dedf71 | ||
|
|
aa34f60ce9 | ||
|
|
9249b7ae3e | ||
|
|
bc3323eb8b | ||
|
|
89b45163e9 | ||
|
|
1e1ed54750 | ||
|
|
595d0156ea | ||
|
|
9f35d4d3cd | ||
|
|
b3ec3512b9 | ||
|
|
52b77468f4 | ||
|
|
5276ad8a26 | ||
|
|
c3b4b30603 | ||
|
|
3ae318c9bc | ||
|
|
ddb95b2653 | ||
|
|
5e6a7fc6b6 | ||
|
|
a2217a9178 | ||
|
|
1226b4cb60 | ||
|
|
1b455367de | ||
|
|
35de0bdcf3 | ||
|
|
36375a507d | ||
|
|
fc5bf1efc5 | ||
|
|
39a9c04449 | ||
|
|
ecef623cde | ||
|
|
d279959042 | ||
|
|
9937670a2e | ||
|
|
ed3df192ab | ||
|
|
13d4b075a8 | ||
|
|
65a4947884 | ||
|
|
0083fcf0fc | ||
|
|
130ae7d3f8 | ||
|
|
10cdd9cda4 | ||
|
|
1887fd2d65 | ||
|
|
edbcb4e937 | ||
|
|
aef75751b2 | ||
|
|
d6b166cb9a | ||
|
|
84a5624eb7 | ||
|
|
c1992dc340 | ||
|
|
6ca13ae5da | ||
|
|
6bc9aa2e5d | ||
|
|
306d1ee953 | ||
|
|
d765925ba3 | ||
|
|
a02fd2496f | ||
|
|
06ea90f073 | ||
|
|
fa120ed43f | ||
|
|
447cf90887 | ||
|
|
a8fda1d0e1 | ||
|
|
312e33d15d | ||
|
|
dd9e41252c | ||
|
|
47397b7b57 | ||
|
|
db3bafd3d2 | ||
|
|
0fc6f85410 | ||
|
|
188d3ecaaa | ||
|
|
0e18ec3a7b | ||
|
|
ad25e33bb3 | ||
|
|
f112b3d676 | ||
|
|
ed1dcd55ce | ||
|
|
12f68312c6 | ||
|
|
9cf3e2e713 | ||
|
|
8f789181f5 | ||
|
|
3cf80c4a4a | ||
|
|
ad00e3f794 | ||
|
|
491ee93c69 | ||
|
|
87bd8339ee | ||
|
|
63e99b2dc4 | ||
|
|
022019bffd | ||
|
|
07083d0098 | ||
|
|
c2abf5a7a4 | ||
|
|
fd5078b540 | ||
|
|
3ff2b36cca | ||
|
|
b51149a8a4 | ||
|
|
32dc60242f | ||
|
|
c9707502d3 | ||
|
|
db7d07af7a | ||
|
|
b2ea3515a5 | ||
|
|
2865c922d7 | ||
|
|
46fb4188f0 | ||
|
|
564884ca08 | ||
|
|
41ddd4961f | ||
|
|
e593d80f51 | ||
|
|
f122188ed1 | ||
|
|
aeb1da74fe | ||
|
|
e3c2f0272f | ||
|
|
2714b2bd11 | ||
|
|
135cddb121 | ||
|
|
3f06c28595 | ||
|
|
7c5c4821f2 | ||
|
|
ab100010d8 | ||
|
|
4a3f83730e | ||
|
|
38520cf14f | ||
|
|
c3653a1725 | ||
|
|
94f065f4f8 | ||
|
|
5b50809291 | ||
|
|
e1f7ca293c | ||
|
|
aa9ba625a1 | ||
|
|
3d7ac45e28 | ||
|
|
d2055287cc | ||
|
|
2074618fd5 | ||
|
|
8ecfcee83c | ||
|
|
2b86a5fdc6 | ||
|
|
8f152c11a9 | ||
|
|
2d00ac81ea | ||
|
|
bf752790f2 | ||
|
|
02f2f4d513 | ||
|
|
69bd752763 | ||
|
|
a4015aac37 | ||
|
|
b730249b34 | ||
|
|
839a891e9d | ||
|
|
3058765b48 | ||
|
|
9ccce18e13 | ||
|
|
831d267106 | ||
|
|
85654aa7a8 | ||
|
|
849e5a24a9 | ||
|
|
87717ba448 | ||
|
|
7b6b7bc58d | ||
|
|
9772403609 | ||
|
|
45a9abebe3 | ||
|
|
2829c8de38 | ||
|
|
43109a8274 | ||
|
|
58b9a44554 | ||
|
|
6e8c73dfe0 | ||
|
|
2e25cd404b | ||
|
|
21c3a7590e | ||
|
|
e06d7f9798 | ||
|
|
20530f2cc0 | ||
|
|
bcbcdb050a | ||
|
|
c76711314a | ||
|
|
6a212b50e6 | ||
|
|
73dc5a6332 | ||
|
|
24d9690b09 | ||
|
|
a8af5915e3 | ||
|
|
01f5c52104 | ||
|
|
4fefb54a13 | ||
|
|
6292bf10b9 | ||
|
|
8aa0b9e63d | ||
|
|
3e8d2e25aa | ||
|
|
d86b93e646 | ||
|
|
a7493fc4a4 | ||
|
|
81416240bc | ||
|
|
eb4e8681d6 | ||
|
|
8c260fe5ed | ||
|
|
61f97f4856 | ||
|
|
87ecb47d3d | ||
|
|
60a30c594e | ||
|
|
565d1185ab | ||
|
|
bd3bf06940 | ||
|
|
dd9ed8af74 | ||
|
|
1d66d9658c | ||
|
|
3b365772d2 | ||
|
|
1570a4ce92 | ||
|
|
2570033791 | ||
|
|
a9f6ac36b3 | ||
|
|
67e501030b | ||
|
|
276cc5f479 | ||
|
|
1f4d658e09 | ||
|
|
3170deef28 | ||
|
|
96e5eb72df | ||
|
|
fce76be041 | ||
|
|
64488cda02 | ||
|
|
b3980706b6 | ||
|
|
dcd06a629a | ||
|
|
ab7e95759d | ||
|
|
c77236b71c | ||
|
|
8c6666a33c | ||
|
|
fc457926a5 | ||
|
|
d8f6780ee4 | ||
|
|
714d35aa27 | ||
|
|
cefec924ce | ||
|
|
82a1f4f934 | ||
|
|
28415a79d5 | ||
|
|
6b644e0078 | ||
|
|
c140fea9e9 | ||
|
|
cff3dcdc87 | ||
|
|
5dfa5b645d | ||
|
|
3001009d3f | ||
|
|
709ee60db3 | ||
|
|
7fcdf2aaa2 | ||
|
|
9b6b0f39f0 | ||
|
|
ec3b77f47d | ||
|
|
e2532ad16e | ||
|
|
c83ca394ac | ||
|
|
27965eb923 | ||
|
|
5dd0fa59c4 | ||
|
|
3f3e250a49 | ||
|
|
df4c93356e | ||
|
|
aba5ef5a51 | ||
|
|
759dc3f8b5 | ||
|
|
13ddba6457 | ||
|
|
8b30366de7 |
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -1,3 +1,10 @@
|
||||
node_modules
|
||||
bower_components
|
||||
results/*
|
||||
package-lock.json
|
||||
.tmp
|
||||
tmp
|
||||
.vagrant
|
||||
results/*
|
||||
coverage
|
||||
front/build
|
||||
package-lock.json
|
||||
har.json
|
||||
@@ -0,0 +1,18 @@
|
||||
language: node_js
|
||||
sudo: false
|
||||
node_js:
|
||||
- "12.18"
|
||||
- "14.7"
|
||||
env:
|
||||
- CXX=g++-4.8
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- g++-4.8
|
||||
before_install:
|
||||
- "npm install -g npm"
|
||||
- "npm install -g grunt-cli"
|
||||
install:
|
||||
- "npm install"
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:10
|
||||
WORKDIR /app
|
||||
ENV VERSION=master
|
||||
EXPOSE 8383
|
||||
RUN git clone --branch ${VERSION} https://github.com/LumberjackOtters/YellowLabTools ylt && cd ylt && yarn install && yarn build
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "/app/ylt/bin/server.js"]
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: gaelmetais
|
||||
@@ -0,0 +1,252 @@
|
||||
module.exports = function(grunt) {
|
||||
|
||||
// Load all grunt modules
|
||||
require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks);
|
||||
|
||||
// Tell our Express server that Grunt launched it
|
||||
process.env.GRUNTED = true;
|
||||
|
||||
// Project configuration.
|
||||
grunt.initConfig({
|
||||
pkg: grunt.file.readJSON('package.json'),
|
||||
settings: grunt.file.readJSON('./server_config/settings.json'),
|
||||
|
||||
less: {
|
||||
all: {
|
||||
files: [
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'front/src/less/',
|
||||
src: ['**/*.less'],
|
||||
dest: 'front/src/css/',
|
||||
ext: '.css'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
jshint: {
|
||||
all: [
|
||||
'*.js',
|
||||
'app/lib/*.js',
|
||||
'bin/*.js',
|
||||
'lib/**/*.js',
|
||||
'app/nodeControllers/*.js',
|
||||
'app/public/scripts/*.js',
|
||||
'phantomas_custom/**/*.js',
|
||||
'test/api/*.js',
|
||||
'test/core/*.js',
|
||||
'test/fixtures/*.js',
|
||||
'front/src/js/**/*.js'
|
||||
],
|
||||
options: {
|
||||
esversion: 6
|
||||
}
|
||||
},
|
||||
clean: {
|
||||
tmp: {
|
||||
src: ['.tmp']
|
||||
},
|
||||
dev: {
|
||||
src: ['front/src/css']
|
||||
},
|
||||
build: {
|
||||
src: ['front/build']
|
||||
}
|
||||
},
|
||||
copy: {
|
||||
build: {
|
||||
files: [
|
||||
{src: ['./front/src/main.html'], dest: './front/build/main.html'},
|
||||
{src: ['./front/src/img/favicon.png'], dest: './front/build/img/favicon.png'},
|
||||
{src: ['./front/src/img/logo-large.png'], dest: './front/build/img/logo-large.png'},
|
||||
]
|
||||
},
|
||||
favicons: {
|
||||
files: [
|
||||
{src: ['./front/src/img/favicon.png'], dest: './front/build/img/favicon.png'},
|
||||
{src: ['./front/src/img/favicon-fail.png'], dest: './front/build/img/favicon-fail.png'},
|
||||
{src: ['./front/src/img/favicon-success.png'], dest: './front/build/img/favicon-success.png'},
|
||||
]
|
||||
}
|
||||
},
|
||||
mochaTest: {
|
||||
test: {
|
||||
options: {
|
||||
reporter: 'spec',
|
||||
},
|
||||
src: ['test/core/*.js', 'test/api/*.js']
|
||||
},
|
||||
'test-current-work': {
|
||||
options: {
|
||||
reporter: 'spec',
|
||||
},
|
||||
src: ['test/core/mediaQueriesCheckerTest.js']
|
||||
}
|
||||
},
|
||||
env: {
|
||||
dev: {
|
||||
NODE_ENV: 'development'
|
||||
},
|
||||
built: {
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
},
|
||||
express: {
|
||||
dev: {
|
||||
options: {
|
||||
port: 8383,
|
||||
server: './bin/server.js',
|
||||
serverreload: true,
|
||||
showStack: true
|
||||
}
|
||||
},
|
||||
built: {
|
||||
options: {
|
||||
port: 8383,
|
||||
server: './bin/server.js',
|
||||
serverreload: true,
|
||||
showStack: true
|
||||
}
|
||||
},
|
||||
test: {
|
||||
options: {
|
||||
port: 8387,
|
||||
server: './bin/server.js',
|
||||
showStack: true
|
||||
}
|
||||
},
|
||||
'test-current-work': {
|
||||
options: {
|
||||
port: 8387,
|
||||
server: './bin/server.js',
|
||||
showStack: true
|
||||
}
|
||||
},
|
||||
testSuite: {
|
||||
options: {
|
||||
port: 8388,
|
||||
bases: 'test/www'
|
||||
}
|
||||
}
|
||||
},
|
||||
useminPrepare: {
|
||||
html: './front/src/main.html',
|
||||
options: {
|
||||
dest: './front/build',
|
||||
root: ['./', './front/src']
|
||||
}
|
||||
},
|
||||
usemin: {
|
||||
html: './front/build/main.html',
|
||||
css: './front/build/css/*.css',
|
||||
options: {
|
||||
assetsDirs: ['front/build']
|
||||
}
|
||||
},
|
||||
htmlmin: {
|
||||
options: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true
|
||||
},
|
||||
main: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: './front/build/',
|
||||
src: 'main.html',
|
||||
flatten: true,
|
||||
dest: './front/build'
|
||||
}]
|
||||
},
|
||||
views: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: './front/src/views',
|
||||
src: '*.html',
|
||||
flatten: true,
|
||||
dest: '.tmp/views/'
|
||||
}]
|
||||
}
|
||||
},
|
||||
inline_angular_templates: {
|
||||
build: {
|
||||
options: {
|
||||
base: '.tmp',
|
||||
method: 'append',
|
||||
unescape: {
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
}
|
||||
},
|
||||
files: {
|
||||
'./front/build/main.html': ['.tmp/views/*.html']
|
||||
}
|
||||
}
|
||||
},
|
||||
filerev: {
|
||||
options: {
|
||||
algorithm: 'md5',
|
||||
length: 8
|
||||
},
|
||||
assets: {
|
||||
src: './front/build/*/*.*'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Custom task that sets a variable for tests
|
||||
grunt.registerTask('test-settings', function() {
|
||||
process.env.IS_TEST = true;
|
||||
});
|
||||
|
||||
grunt.registerTask('build', [
|
||||
'jshint',
|
||||
'clean:build',
|
||||
'copy:build',
|
||||
'less',
|
||||
'useminPrepare',
|
||||
'concat',
|
||||
'uglify',
|
||||
'cssmin',
|
||||
'htmlmin:views',
|
||||
'inline_angular_templates',
|
||||
'filerev',
|
||||
'usemin',
|
||||
'htmlmin:main',
|
||||
'clean:tmp',
|
||||
'copy:favicons'
|
||||
]);
|
||||
|
||||
grunt.registerTask('hint', [
|
||||
'jshint'
|
||||
]);
|
||||
|
||||
grunt.registerTask('dev', [
|
||||
'env:dev',
|
||||
'express:dev'
|
||||
]);
|
||||
|
||||
grunt.registerTask('built', [
|
||||
'env:built',
|
||||
'express:built'
|
||||
]);
|
||||
|
||||
grunt.registerTask('test', [
|
||||
'test-settings',
|
||||
'build',
|
||||
'express:testSuite',
|
||||
'express:test',
|
||||
'mochaTest:test',
|
||||
'clean:tmp'
|
||||
]);
|
||||
|
||||
grunt.registerTask('test-current-work', [
|
||||
'test-settings',
|
||||
'jshint',
|
||||
'express:testSuite',
|
||||
'express:test-current-work',
|
||||
'mochaTest:test-current-work',
|
||||
'clean:tmp'
|
||||
]);
|
||||
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
{description}
|
||||
Copyright (C) {year} {fullname}
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
{signature of Ty Coon}, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,91 @@
|
||||
<img src="./doc/img/logo.png" alt="Yellow Lab Tools"></img>
|
||||
|
||||
Analyzes a webpage and detects **performance** or **front-end code quality** issues. Free, easy to launch, easy to understand, in-depth details.
|
||||
|
||||
|
||||
#
|
||||
|
||||
<img src="./doc/img/commandment.png" alt="No website should go live without being tested with Yellow Lab Tools"></img>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="70%">
|
||||
The <b>online tool</b> (recommended):
|
||||
<a href="https://yellowlab.tools" target="_blank">https://yellowlab.tools</a>
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="./doc/img/home.png"></img>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="70%">
|
||||
The <b>CLI</b> (Command Line Interface) - <a href="https://github.com/YellowLabTools/YellowLabTools/wiki/Command-Line-Interface" target="_blank">Doc here</a>
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="./doc/img/YLT-cli-animated.gif"></img>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="70%">
|
||||
The <b>Grunt task</b>: <a href="https://github.com/gmetais/grunt-yellowlabtools" traget="_blank">gmetais/grunt-yellowlabtools</a>
|
||||
<br>For developers or Continuous Integration
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="./doc/img/grunt-logo.png"></img>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="70%">
|
||||
The <b>NodeJS</b> package - <a href="https://github.com/YellowLabTools/YellowLabTools/wiki/NodeJS-module" target="_blank">Doc here</a>
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="./doc/img/npm-logo.png"></img>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="70%">
|
||||
The <b>public API</b> - <a href="https://github.com/YellowLabTools/YellowLabTools/wiki/Public-API" target="_blank">Doc here</a>
|
||||
<br>
|
||||
Allows you to launch runs from any language
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="./doc/img/api-logo.png"></img>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
## How it works
|
||||
|
||||
The tool loads the given URL and collects various metrics and statistics with the help of [Phantomas](https://github.com/macbre/phantomas). These metrics are then categorized and transformed into scores. It also gives in-depth details so developpers can fix the detected issues.
|
||||
|
||||
By the way, it's free because I am a geek, not businessmen. In return, you can add a ★ on GitHub, it will boost my motivation to add more awesome features!!!
|
||||
|
||||

|
||||
|
||||
|
||||
## Test your localhost
|
||||
|
||||
You can use [ngrok](https://ngrok.com/), a tool that creates a secure tunnel between your localhost and the online tool (or the public API). You can also use the CLI or the Grunt tasks as they run on your machine.
|
||||
|
||||
|
||||
## Install your own private instance
|
||||
|
||||
If your project is not accessible from outside, or if you want to fork and improve the tool, you can build your own instance. The documentation is [here](https://github.com/YellowLabTools/YellowLabTools/wiki/Install-your-private-server).
|
||||
|
||||
|
||||
## Author
|
||||
Gaël Métais. I'm a webperf freelance. Follow me on Twitter [@gaelmetais](https://twitter.com/gaelmetais), I tweet about Web Performances, Front-end and new versions of YellowLabTools!
|
||||
|
||||
<a href='https://ko-fi.com/gaelmetais' target='_blank'><img height='35' style='border:0px;height:46px;' src='https://az743702.vo.msecnd.net/cdn/kofi3.png?v=0' border='0' alt='Buy me a coffee' /><a>
|
||||
|
||||
I can also help your company about Web Performances, visit [my website](https://www.gaelmetais.com).
|
||||
|
||||
|
||||
## Contributors
|
||||
- Achraf Ben Younes [achrafbenyounes](https://github.com/achrafbenyounes)
|
||||
- Km [camlafit](https://github.com/camlafit)
|
||||
- Vincent L. [magikcypress](https://github.com/magikcypress)
|
||||
- Ousama Ben Younes [ousamabenyounes](https://github.com/ousamabenyounes)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
Vagrant.configure("2") do |config|
|
||||
|
||||
config.vm.box = "ubuntu/trusty64"
|
||||
|
||||
config.vm.network :private_network, ip: "10.10.10.10"
|
||||
config.ssh.forward_agent = true
|
||||
|
||||
# http://foo-o-rama.com/vagrant--stdin-is-not-a-tty--fix.html
|
||||
config.vm.provision "fix-no-tty", type: "shell" do |s|
|
||||
s.privileged = false
|
||||
s.inline = "sudo sed -i '/tty/!s/mesg n/tty -s \\&\\& mesg n/' /root/.profile"
|
||||
end
|
||||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
|
||||
vb.customize ["modifyvm", :id, "--memory", 1024]
|
||||
vb.customize ["modifyvm", :id, "--cpus", 2]
|
||||
end
|
||||
|
||||
config.vm.synced_folder "./", "/space/YellowLabTools"
|
||||
|
||||
config.vm.provision :shell, :path => "server_config/server_install.sh"
|
||||
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
angular.module('Spaghetti', [
|
||||
'Results'
|
||||
]);
|
||||
@@ -1,48 +0,0 @@
|
||||
var app = angular.module('Results', []);
|
||||
|
||||
app.controller('ResultsCtrl', function ($scope) {
|
||||
// Grab results from nodeJS served page
|
||||
$scope.phantomasResults = window._phantomas_results;
|
||||
|
||||
$scope.slowRequestsOn = false;
|
||||
$scope.slowRequestsLimit = 5;
|
||||
|
||||
$scope.onNodeDetailsClick = function(node) {
|
||||
var isOpen = node.data.showDetails;
|
||||
if (!isOpen) {
|
||||
// Close all other nodes
|
||||
$scope.phantomasResults.javascript.children.forEach(function(currentNode) {
|
||||
currentNode.data.showDetails = false;
|
||||
});
|
||||
|
||||
// Parse the backtrace
|
||||
if (!node.data.parsedBacktrace) {
|
||||
node.data.parsedBacktrace = parseBacktrace(node.data.backtrace);
|
||||
}
|
||||
|
||||
}
|
||||
node.data.showDetails = !isOpen;
|
||||
};
|
||||
|
||||
function parseBacktrace(str) {
|
||||
var splited = str.split(' / ');
|
||||
var out = [];
|
||||
splited.forEach(function(trace) {
|
||||
var result = /^(\S*)\s?\(?(https?:\/\/\S+):(\d+)\)?$/g.exec(trace);
|
||||
if (result && result[2].length > 0) {
|
||||
var filePath = result[2];
|
||||
var chunks = filePath.split('/');
|
||||
var fileName = chunks[chunks.length - 1];
|
||||
|
||||
out.push({
|
||||
fnName: result[1],
|
||||
fileName: fileName,
|
||||
filePath: filePath,
|
||||
line: result[3]
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
.url {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.launchBtn {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
input[type=submit], input.url {
|
||||
padding: 0 0.5em;
|
||||
margin: 0.5em;
|
||||
font-size: 1.2em;
|
||||
height: 2em;
|
||||
border: 0 solid;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0.1em 0.2em 0 0 #5e2846;
|
||||
outline: none;
|
||||
}
|
||||
input[type=submit]:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
input[type=submit].clicked {
|
||||
color: #ddd;
|
||||
position: relative;
|
||||
left: 0.1em;
|
||||
top: 0.2em;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#status {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
html {
|
||||
margin: 100px 50px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 1280px;
|
||||
background: #9c4274;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body, input[type=submit], input[type=text], input[type=number], button {
|
||||
font-family: 'Century Gothic', helvetica, arial, sans-serif;
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
.testedUrl {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.execution {
|
||||
margin-top: 3em;
|
||||
padding: 1em;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
border: 1px dotted #aaa;
|
||||
}
|
||||
|
||||
.slowRequestsLimit {
|
||||
width: 3em;
|
||||
font-size: 1em;
|
||||
text-align: right;
|
||||
border: 1px solid #aaa;
|
||||
}
|
||||
|
||||
input.textFilter {
|
||||
box-shadow: none;
|
||||
font-size: 1em;
|
||||
padding: 0 0.2em;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: none;
|
||||
width: 15em;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-spacing: 0.25em;
|
||||
}
|
||||
|
||||
.table > div {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.table > .headers > div {
|
||||
font-weight: bold;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.table > div > div {
|
||||
padding: 0.1em 1em;
|
||||
background: #f2f2f2;
|
||||
display: table-cell;
|
||||
text-align: left;
|
||||
}
|
||||
.table > div.jsError > .type, .table > div.jsError > .value {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
.table > div.showingDetails > div {
|
||||
background: #f1c40f;
|
||||
}
|
||||
|
||||
.table > div > .index {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.table > div > .type {
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.table > div > .value {
|
||||
width: 70%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.table > div > .details {
|
||||
position: relative;
|
||||
}
|
||||
.table > div > .details button {
|
||||
color: #f1c40f;
|
||||
font-weight: bold;
|
||||
border: 2px solid #f1c40f;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detailsOverlay {
|
||||
position: absolute;
|
||||
right: 3em;
|
||||
top: -3em;
|
||||
width: 45em;
|
||||
padding: 0 1em 1em;
|
||||
background: #fff;
|
||||
border: 2px solid #f1c40f;
|
||||
border-radius: 0.5em;
|
||||
z-index: 1;
|
||||
}
|
||||
@media screen and (max-width: 1024px) {
|
||||
.detailsOverlay {
|
||||
width: 25em;
|
||||
}
|
||||
}
|
||||
.detailsOverlay .closeBtn {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
color: #f1c40f;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detailsOverlay .trace {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.table > div > .duration {
|
||||
text-align: center;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
background: url('data:image/svg+xml;utf8,<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><defs/><g fill="none" fill-rule="evenodd" id="Icons new Arranged Names Color" stroke="none" stroke-width="1"><g fill="#e74c3c" id="101 Warning"><path d="M14.4242327,6.14839275 C15.2942987,4.74072976 16.707028,4.74408442 17.5750205,6.14839275 L28.3601099,23.59738 C29.5216388,25.4765951 28.6755462,27 26.4714068,27 L5.5278464,27 C3.32321557,27 2.47386317,25.4826642 3.63914331,23.59738 Z M16,20 C16.5522847,20 17,19.5469637 17,19.0029699 L17,12.9970301 C17,12.4463856 16.5561352,12 16,12 C15.4477153,12 15,12.4530363 15,12.9970301 L15,19.0029699 C15,19.5536144 15.4438648,20 16,20 Z M16,24 C16.5522848,24 17,23.5522848 17,23 C17,22.4477152 16.5522848,22 16,22 C15.4477152,22 15,22.4477152 15,23 C15,23.5522848 15.4477152,24 16,24 Z M16,24" id="Triangle 29"/></g></g></svg>') no-repeat 0 3px;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Javascript Spaghetti Profiler</title>
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Javascript Spaghetti Profiler</h1>
|
||||
|
||||
<form method="post" action="/launchTest">
|
||||
<input type="text" name="url" placeholder="http://www.mysite.com" class="url" />
|
||||
<input type="submit" value="Launch test" class="launchBtn" onclick="this.className += ' clicked'" />
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Javascript Spaghetti Profiler</title>
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/launchTest.css">
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Javascript Spaghetti Profiler</h1>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div>%%TEST_URL%%</div>
|
||||
|
||||
<script>
|
||||
var testId = '%%TEST_ID%%';
|
||||
var statusElement = document.getElementById('status');
|
||||
var socket = io();
|
||||
|
||||
function askStatus() {
|
||||
socket.emit('waiting', testId);
|
||||
}
|
||||
|
||||
socket.on('running', function() {
|
||||
statusElement.innerHTML = 'Running';
|
||||
setTimeout(askStatus, 200);
|
||||
});
|
||||
|
||||
socket.on('position', function(position) {
|
||||
statusElement.innerHTML = 'Waiting behind ' + position + ' other tests';
|
||||
setTimeout(askStatus, 2000);
|
||||
});
|
||||
|
||||
socket.on('complete', function() {
|
||||
statusElement.innerHTML = 'Test complete';
|
||||
window.location.replace('/results/' + testId);
|
||||
});
|
||||
|
||||
socket.on('404', function() {
|
||||
statusElement.innerHTML = 'Test not found';
|
||||
});
|
||||
|
||||
askStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,113 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Javascript Spaghetti Profiler</title>
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="/public/styles/results.css">
|
||||
<script src="/bower_components/angular/angular.min.js"></script>
|
||||
<script src="/public/scripts/app.js"></script>
|
||||
<script src="/public/scripts/resultsController.js"></script>
|
||||
<script src="/public/scripts/filters.js"></script>
|
||||
</head>
|
||||
<body ng-app="Spaghetti" ng-controller="ResultsCtrl">
|
||||
<h1>Javascript Spaghetti Profiler</h1>
|
||||
|
||||
<div ng-if="undefined">Untangling and counting the spaghettis...</div>
|
||||
|
||||
<div class="ng-cloak">
|
||||
<div>Tested url: <a class="testedUrl" href="phantomasResults.url" target="_blank">{{phantomasResults.url}}</a></div>
|
||||
|
||||
<div ng-if="phantomasResults.error || !phantomasResults.javascript.data">
|
||||
<h2>Error: {{phantomasResults.error}}</h2>
|
||||
<div ng-if="phantomasResults.error == 252">Phantomas timed out</div>
|
||||
<div ng-if="phantomasResults.error == 253">Phantomas config error</div>
|
||||
<div ng-if="phantomasResults.error == 254">Phantomas failed to load page</div>
|
||||
<div ng-if="phantomasResults.error == 255">Phantomas internal error</div>
|
||||
<div ng-if="!phantomasResults.error && !phantomasResults.javascript.data">Javascript execution tree error</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="!phantomasResults.error" class="execution">
|
||||
<h2>Javascript interactions with the DOM</h2>
|
||||
<div class="filters">
|
||||
<div>
|
||||
<input type="checkbox" ng-model="textFilterOn" />
|
||||
Filter by
|
||||
<input type="text" ng-model="textFilter" placeholder="search..." class="textFilter" ng-change="textFilterOn = true" />
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" ng-model="slowRequestsOn" id="slowRequests" />
|
||||
<label for="slowRequests">Requests slower than</label>
|
||||
<input type="number" value="5" min="0" ng-model="slowRequestsLimit" class="slowRequestsLimit"> ms
|
||||
</div>
|
||||
</div>
|
||||
<div class="table">
|
||||
<div class="headers">
|
||||
<div><!-- index --></div>
|
||||
<div>Type</div>
|
||||
<div>Params</div>
|
||||
<div><!-- details --></div>
|
||||
<div>Duration</div>
|
||||
</div>
|
||||
<div ng-repeat="node in phantomasResults.javascript.children"
|
||||
ng-if="(!slowRequestsOn || node.data.time > slowRequestsLimit) && (!textFilterOn || !textFilter.length || node.data.type.indexOf(textFilter) >= 0 || node.data.callDetails.arguments[0].indexOf(textFilter) >= 0)"
|
||||
ng-class="{showingDetails: node.data.showDetails, jsError: node.data.type == 'error'}">
|
||||
<div class="index">{{$index}}</div>
|
||||
<div class="type">{{node.data.type}}</div>
|
||||
<div class="value">{{node.data.callDetails.arguments[0]}}</div>
|
||||
<div class="details">
|
||||
<button ng-click="onNodeDetailsClick(node)">i</button>
|
||||
<div class="detailsOverlay" ng-show="node.data.showDetails">
|
||||
<div class="closeBtn" ng-click="onNodeDetailsClick(node)">✖</div>
|
||||
|
||||
<div ng-if="node.data.callDetails.context.domElement">
|
||||
<h4>Called on DOM element</h4>
|
||||
<div>{{node.data.callDetails.context.domElement}}</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="node.data.callDetails.context.length == 1 && node.data.callDetails.context.firstElementPath">
|
||||
<h4>Called on 1 jQuery element</h4>
|
||||
<div>{{node.data.callDetails.context.firstElementPath}}</div>
|
||||
</div>
|
||||
|
||||
<h4>Backtrace</h4>
|
||||
<div class="table">
|
||||
<div ng-repeat="trace in node.data.parsedBacktrace track by $index">
|
||||
<div>{{trace.fnName || '(anonymous)'}}</div>
|
||||
<div class="trace"><a href="{{trace.filePath}}" title="{{trace.filePath}}" target="_blank">{{trace.fileName || 'HTML'}}</a>:{{trace.line}}</div>
|
||||
</div>
|
||||
<div ng-if="node.data.parsedBacktrace.length == 0">
|
||||
<div>can't find any backtrace :/</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="node.children.length > 0">
|
||||
<h4>Sub processes</h4>
|
||||
<div class="table">
|
||||
<div class="headers">
|
||||
<div><!-- index --></div>
|
||||
<div>Type</div>
|
||||
<div>Params</div>
|
||||
<div>Duration</div>
|
||||
</div>
|
||||
<div ng-repeat="node in node.children">
|
||||
<div class="index">{{$index}}</div>
|
||||
<div class="type">{{node.data.type}}</div>
|
||||
<div class="value">{{node.data.callDetails.arguments[0]}}</div>
|
||||
<div class="duration" ng-if="node.data.time != undefined">{{node.data.time}} ms <div ng-if="node.data.time > slowRequestsLimit" class="warningIcon"></div></div>
|
||||
<div class="duration" ng-if="node.data.time == undefined"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duration" ng-if="node.data.time != undefined">{{node.data.time}} ms <div ng-if="node.data.time > slowRequestsLimit" class="warningIcon"></div></div>
|
||||
<div class="duration" ng-if="node.data.time == undefined"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>var _phantomas_results = %%RESULTS%%;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var debug = require('debug')('ylt:cli');
|
||||
var meow = require('meow');
|
||||
var path = require('path');
|
||||
var EasyXml = require('easyxml');
|
||||
|
||||
var ylt = require('../lib/index');
|
||||
|
||||
var cli = meow({
|
||||
help: [
|
||||
'Usage',
|
||||
' yellowlabtools <url> <options>',
|
||||
'',
|
||||
'Options:',
|
||||
' --device Simulates a device. Choose between phone (default), tablet, desktop and desktop-hd.',
|
||||
' --screenshot Will take a screenshot and use this value as the output path. It needs to end with ".png".',
|
||||
//' --wait-for-selector Once the page is loaded, Phantomas will wait until the given CSS selector matches some elements.',
|
||||
' --proxy Sets an HTTP proxy to pass through. Syntax is "host:port".',
|
||||
' --cookie Adds a cookie on the main domain.',
|
||||
' --auth-user Basic HTTP authentication username.',
|
||||
' --auth-pass Basic HTTP authentication password.',
|
||||
' --block-domain Disallow requests to given (comma-separated) domains.',
|
||||
' --allow-domain Only allow requests to given (comma-separated) domains.',
|
||||
' --no-externals Block all domains except the main one.',
|
||||
' --reporter The output format: "json" or "xml". Default is "json".',
|
||||
''
|
||||
].join('\n'),
|
||||
pkg: require('../package.json')
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Check parameters
|
||||
if (cli.input.length < 1) {
|
||||
cli.showHelp();
|
||||
}
|
||||
|
||||
var url = cli.input[0];
|
||||
|
||||
var options = {};
|
||||
|
||||
// Screenshot option
|
||||
var screenshot = cli.flags.screenshot;
|
||||
if (screenshot && (typeof screenshot !== 'string' || screenshot.toLowerCase().indexOf('.png', screenshot.length - 4) === -1)) {
|
||||
console.error('Incorrect parameters: screenshot must be a path that ends with ".png"');
|
||||
process.exit(1);
|
||||
}
|
||||
if (screenshot) {
|
||||
if (path.resolve(screenshot) !== path.normalize(screenshot)) {
|
||||
// It is not an absolute path, so it is relative to the current command-line directory
|
||||
screenshot = path.join(process.cwd(), screenshot);
|
||||
}
|
||||
options.screenshot = cli.flags.screenshot;
|
||||
}
|
||||
|
||||
// Device simulation
|
||||
options.device = cli.flags.device || 'mobile';
|
||||
|
||||
// Wait for CSS selector
|
||||
options.waitForSelector = cli.flags.waitForSelector || null;
|
||||
|
||||
// Proxy
|
||||
options.proxy = cli.flags.proxy || null;
|
||||
|
||||
// Cookie
|
||||
options.cookie = cli.flags.cookie || null;
|
||||
|
||||
// HTTP basic auth
|
||||
options.authUser = cli.flags.authUser || null;
|
||||
options.authPass = cli.flags.authPass || null;
|
||||
|
||||
// Domain blocking
|
||||
options.blockDomain = cli.flags.blockDomain || null;
|
||||
options.allowDomain = cli.flags.allowDomain || null;
|
||||
options.noExternals = cli.flags.noExternals || null;
|
||||
|
||||
// Output format
|
||||
if (cli.flags.reporter && cli.flags.reporter !== 'json' && cli.flags.reporter !== 'xml') {
|
||||
console.error('Incorrect parameters: reporter has to be "json" or "xml"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
(function execute(url, options) {
|
||||
'use strict';
|
||||
|
||||
ylt(url, options).
|
||||
|
||||
then(function(data) {
|
||||
|
||||
debug('Success');
|
||||
switch(cli.flags.reporter) {
|
||||
case 'xml':
|
||||
var serializer = new EasyXml({
|
||||
manifest: true
|
||||
});
|
||||
|
||||
// Remove some heavy parts of the results object
|
||||
delete data.toolsResults;
|
||||
delete data.javascriptExecutionTree;
|
||||
|
||||
var xmlOutput = serializer.render(data);
|
||||
|
||||
// Remove special chars from XML tags: # [ ]
|
||||
xmlOutput = xmlOutput.replace(/<([^>]*)#([^>]*)>/g, '<$1>');
|
||||
xmlOutput = xmlOutput.replace(/<([^>]*)\[([^>]*)>/g, '<$1>');
|
||||
xmlOutput = xmlOutput.replace(/<([^>]*)\]([^>]*)>/g, '<$1>');
|
||||
|
||||
// Remove special chars from text content: \n \0
|
||||
xmlOutput = xmlOutput.replace(/(<[a-zA-Z]*>[^<]*)\n([^<]*<\/[a-zA-Z]*>)/g, '$1$2');
|
||||
xmlOutput = xmlOutput.replace(/\0/g, '');
|
||||
xmlOutput = xmlOutput.replace(/\uFFFF/g, '');
|
||||
xmlOutput = xmlOutput.replace(/\u0002/g, '');
|
||||
|
||||
console.log(xmlOutput);
|
||||
break;
|
||||
default:
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
}).fail(function(err) {
|
||||
|
||||
debug('Test failed for %s', url);
|
||||
console.error(err);
|
||||
|
||||
});
|
||||
|
||||
debug('Test launched...');
|
||||
|
||||
})(url, options);
|
||||
@@ -0,0 +1,49 @@
|
||||
var express = require('express');
|
||||
var app = express();
|
||||
var server = require('http').createServer(app);
|
||||
var bodyParser = require('body-parser');
|
||||
var compress = require('compression');
|
||||
var cors = require('cors');
|
||||
|
||||
var authMiddleware = require('../lib/server/middlewares/authMiddleware');
|
||||
var apiLimitsMiddleware = require('../lib/server/middlewares/apiLimitsMiddleware');
|
||||
var wwwRedirectMiddleware = require('../lib/server/middlewares/wwwRedirectMiddleware');
|
||||
|
||||
|
||||
// Middlewares
|
||||
app.use(compress());
|
||||
app.use(bodyParser.json());
|
||||
app.use(cors());
|
||||
app.use(wwwRedirectMiddleware);
|
||||
app.use(authMiddleware);
|
||||
app.use(apiLimitsMiddleware);
|
||||
app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
|
||||
|
||||
|
||||
// EJS HTML engine
|
||||
app.engine('.html', require('ejs').__express);
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
|
||||
// Initialize the controllers
|
||||
var apiController = require('../lib/server/controllers/apiController')(app);
|
||||
var frontController = require('../lib/server/controllers/frontController')(app);
|
||||
|
||||
|
||||
// Let's start the server!
|
||||
if (!process.env.GRUNTED) {
|
||||
var settings = require('../server_config/settings.json');
|
||||
|
||||
app.locals.baseUrl = settings.baseUrl;
|
||||
|
||||
server.listen(settings.serverPort, function() {
|
||||
console.log('Listening on port %d', server.address().port);
|
||||
|
||||
// For the tests
|
||||
if (server.startTests) {
|
||||
server.startTests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "js-spaghetti-profiler",
|
||||
"dependencies": {
|
||||
"angular": "1.3.0-beta.16"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,18 @@
|
||||
.about {
|
||||
margin: 3em auto;
|
||||
width: 80%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.about {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
.about p {
|
||||
margin: 2em;
|
||||
}
|
||||
.about a {
|
||||
color: #fff;
|
||||
}
|
||||
.sponsor {
|
||||
color: #ffa319;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
.testedUrl {
|
||||
color: inherit;
|
||||
}
|
||||
.summary {
|
||||
text-align: center;
|
||||
}
|
||||
.summary .globalScore {
|
||||
margin: 3em auto;
|
||||
}
|
||||
.summary .globalScore .globalGrade {
|
||||
margin: 0.5 auto;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
line-height: 2.5em;
|
||||
border-radius: 0.5em;
|
||||
font-size: 3em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summary .globalScore .on100 {
|
||||
font-size: 1.2em;
|
||||
margin: 0.5em 0 1em;
|
||||
}
|
||||
.summary .globalScore .screenshotWrapper:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.summary .globalScore .screenshotWrapper:hover:after {
|
||||
position: absolute;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
top: 0.7em;
|
||||
left: 1.55em;
|
||||
font-size: 3em;
|
||||
color: #FFF;
|
||||
background: #000;
|
||||
border-radius: 0.2em;
|
||||
text-align: center;
|
||||
content: "+";
|
||||
opacity: 0.85;
|
||||
}
|
||||
.summary .globalScore .screenshotWrapper.phone:hover:after {
|
||||
top: 1.7em;
|
||||
left: 0.64em;
|
||||
}
|
||||
.summary .globalScore .screenshotWrapper.tablet:hover:after {
|
||||
top: 1.5em;
|
||||
left: 0.9em;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .globalScore {
|
||||
width: 65%;
|
||||
display: table;
|
||||
}
|
||||
.summary .globalScore > div {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.summary .notations {
|
||||
width: 100%;
|
||||
display: table;
|
||||
margin: 0 0 1.5em;
|
||||
border-spacing: 0 1em;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations {
|
||||
width: 80%;
|
||||
margin: 0 10% 1.5em;
|
||||
border-spacing: 1em;
|
||||
}
|
||||
}
|
||||
.summary .notations > div {
|
||||
display: table-row;
|
||||
}
|
||||
.summary .notations > div > div {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations > div > div {
|
||||
display: table-cell;
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
.summary .notations .category {
|
||||
font-size: 1.2em;
|
||||
width: 50%;
|
||||
float: left;
|
||||
text-align: left;
|
||||
margin: 0.5em 0.25em;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations .category {
|
||||
width: 20%;
|
||||
text-align: center;
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
.summary .notations .criteria {
|
||||
font-weight: normal;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations .criteria {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
.summary .notations .A.categoryScore,
|
||||
.summary .notations .B.categoryScore,
|
||||
.summary .notations .C.categoryScore,
|
||||
.summary .notations .D.categoryScore,
|
||||
.summary .notations .E.categoryScore,
|
||||
.summary .notations .F.categoryScore,
|
||||
.summary .notations .NA.categoryScore {
|
||||
width: 2.5em;
|
||||
max-width: 2.5em;
|
||||
min-width: 2.5em;
|
||||
margin: 0.2em;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
border-radius: 0.5em;
|
||||
float: right;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations .A.categoryScore,
|
||||
.summary .notations .B.categoryScore,
|
||||
.summary .notations .C.categoryScore,
|
||||
.summary .notations .D.categoryScore,
|
||||
.summary .notations .E.categoryScore,
|
||||
.summary .notations .F.categoryScore,
|
||||
.summary .notations .NA.categoryScore {
|
||||
float: none;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
.summary .notations .grade .A,
|
||||
.summary .notations .grade .B,
|
||||
.summary .notations .grade .C,
|
||||
.summary .notations .grade .D,
|
||||
.summary .notations .grade .E,
|
||||
.summary .notations .grade .F,
|
||||
.summary .notations .grade .NA {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
font-size: 1em;
|
||||
color: transparent;
|
||||
margin: 0 auto;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
.summary .notations .criteria .table {
|
||||
width: 100%;
|
||||
}
|
||||
.summary .notations .criteria .table > a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.summary .notations .criteria .table > a:hover > div {
|
||||
background: #d8ebe0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.summary .notations .criteria .table > a:hover > div.info {
|
||||
background: #FFF;
|
||||
}
|
||||
.summary .notations .criteria .table > a:hover > div.info svg {
|
||||
fill: #d8ebe0;
|
||||
}
|
||||
.summary .notations .criteria .grade {
|
||||
width: 10%;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summary .notations .criteria .label {
|
||||
width: 70%;
|
||||
}
|
||||
.summary .notations .criteria .result {
|
||||
width: 18%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summary .notations .warning .label,
|
||||
.summary .notations .warning .result {
|
||||
color: #FF1919;
|
||||
}
|
||||
.summary .notations .icon-warning svg {
|
||||
fill: #FF1919;
|
||||
margin: -2px 0;
|
||||
}
|
||||
.summary .notations .criteria .info {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.summary .notations .criteria .info {
|
||||
display: table-cell;
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
background: #FFF;
|
||||
padding-left: 0.1em;
|
||||
padding-right: 0.1em;
|
||||
}
|
||||
}
|
||||
.summary .notations .criteria .info svg {
|
||||
fill: transparent;
|
||||
}
|
||||
.summary .sponsor {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 4em;
|
||||
color: #ffa319;
|
||||
}
|
||||
.summary .sponsor a {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
.promess {
|
||||
padding: 0em 2em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: normal;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.price {
|
||||
padding: 0em 2em 3em;
|
||||
margin-top: 0em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.url {
|
||||
width: 50%;
|
||||
}
|
||||
.launchBtn {
|
||||
background: #ffa319;
|
||||
color: #fff;
|
||||
}
|
||||
.launchBtn:focus {
|
||||
background: #e74c3c;
|
||||
}
|
||||
.launchBtn.disabled {
|
||||
background: #f1bd70;
|
||||
}
|
||||
.launchBtn.disabled:focus {
|
||||
color: #ddd;
|
||||
}
|
||||
.settings {
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.settings input,
|
||||
.settings select {
|
||||
font-size: 1em;
|
||||
}
|
||||
.settings input[type=text],
|
||||
.settings input[type=password],
|
||||
.settings textarea {
|
||||
width: 100%;
|
||||
min-width: 4em;
|
||||
}
|
||||
.device {
|
||||
margin-top: 3em;
|
||||
}
|
||||
.device .item {
|
||||
display: inline-block;
|
||||
margin: 1em 0.75em;
|
||||
width: 5.5em;
|
||||
height: 5.5em;
|
||||
color: #FFF;
|
||||
border: 1px solid #FFF;
|
||||
padding: 1px;
|
||||
border-radius: 0.5em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.device .item > svg {
|
||||
display: block;
|
||||
margin: 0.6em auto 0.3em;
|
||||
fill: #fff;
|
||||
}
|
||||
.device .item.active {
|
||||
color: #ffa319;
|
||||
border: 2px solid #ffa319;
|
||||
padding: 0;
|
||||
}
|
||||
.device .item.active > svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
.device .item:hover {
|
||||
color: #ffa319;
|
||||
}
|
||||
.device .item:hover > svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
.settingsTooltip {
|
||||
position: relative;
|
||||
}
|
||||
.settingsTooltip svg {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
.settingsTooltip div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
padding: 0.5em;
|
||||
width: 25em;
|
||||
background: #FFF;
|
||||
color: #000;
|
||||
font-size: 0.8em;
|
||||
border-radius: 1em;
|
||||
border: 2px solid #ffa319;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
z-index: 2;
|
||||
}
|
||||
.settingsTooltip:hover div {
|
||||
display: block;
|
||||
}
|
||||
.showAdvanced {
|
||||
display: inline-block;
|
||||
margin-top: 2em;
|
||||
color: #FFF;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.showAdvanced:hover {
|
||||
color: #ffa319;
|
||||
}
|
||||
.currentSettings {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.currentSettings span {
|
||||
color: #ffa319;
|
||||
}
|
||||
.currentSettings span:after {
|
||||
color: #FFF;
|
||||
content: ",";
|
||||
}
|
||||
.currentSettings span:last-child:after {
|
||||
content: "";
|
||||
}
|
||||
.advanced {
|
||||
margin: 1em 0 0;
|
||||
display: table;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-spacing: 0.75em;
|
||||
}
|
||||
.advanced > div {
|
||||
display: table-row;
|
||||
}
|
||||
.advanced > div > div {
|
||||
display: table-cell;
|
||||
width: 75%;
|
||||
}
|
||||
.advanced > div > div.label {
|
||||
width: 25%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.advanced .subTable {
|
||||
display: table;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.advanced .subTable > div {
|
||||
display: table-row;
|
||||
}
|
||||
.advanced .subTable > div > div {
|
||||
display: table-cell;
|
||||
padding: 0 0 0.75em;
|
||||
}
|
||||
.features {
|
||||
display: table;
|
||||
width: 50%;
|
||||
margin: 6em auto 0;
|
||||
font-size: 0.9em;
|
||||
color: #8abfaf;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.features > div {
|
||||
width: 33.3%;
|
||||
display: table-cell;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
}
|
||||
.features h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
color: #fff;
|
||||
}
|
||||
input[type=submit],
|
||||
input.url {
|
||||
padding: 0 0.5em;
|
||||
margin: 0.5em;
|
||||
font-size: 1.2em;
|
||||
height: 2em;
|
||||
border: 0 solid;
|
||||
border-radius: 0.5em;
|
||||
outline: none;
|
||||
}
|
||||
input[type=submit]:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
input[type=submit].clicked {
|
||||
color: #ddd;
|
||||
position: relative;
|
||||
left: 0.1em;
|
||||
top: 0.2em;
|
||||
box-shadow: none;
|
||||
}
|
||||
.homeSponsor {
|
||||
margin-top: 3em;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
html {
|
||||
margin: 35px 5px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
html {
|
||||
margin: 100px 50px;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 1280px;
|
||||
background: #212240;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
body,
|
||||
input[type=submit],
|
||||
input[type=text],
|
||||
input[type=url],
|
||||
input[type=number],
|
||||
button {
|
||||
font-family: 'Century Gothic', helvetica, arial, sans-serif;
|
||||
}
|
||||
input[type=submit] {
|
||||
cursor: pointer;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 200;
|
||||
}
|
||||
.resultsMenu {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.resultsMenu .menuItem {
|
||||
font-size: 0.8em;
|
||||
display: inline-block;
|
||||
width: 7em;
|
||||
height: 7em;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.resultsMenu .menuItem {
|
||||
font-size: 1em;
|
||||
margin: 1em;
|
||||
width: 8em;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
}
|
||||
.resultsMenu .menuItem svg {
|
||||
fill: #fff;
|
||||
}
|
||||
.resultsMenu .menuItem.back,
|
||||
.resultsMenu .menuItem.restart {
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.resultsMenu .menuItem div {
|
||||
padding-top: 0.5em;
|
||||
font-size: 3em;
|
||||
}
|
||||
.resultsMenu svg {
|
||||
display: block;
|
||||
margin: 1.2em auto 0.2em;
|
||||
}
|
||||
.resultsMenu .active,
|
||||
.resultsMenu .menuItem.active:hover {
|
||||
color: #ffa319;
|
||||
border-color: #ffa319;
|
||||
}
|
||||
.resultsMenu .active svg,
|
||||
.resultsMenu .menuItem.active:hover svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
.resultsMenu .menuItem:hover {
|
||||
color: #ffa319;
|
||||
}
|
||||
.resultsMenu .menuItem:hover svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
.resultsMenu span {
|
||||
position: relative;
|
||||
top: 0.5em;
|
||||
}
|
||||
/* Grade colors */
|
||||
.A {
|
||||
/* green */
|
||||
background: #0C4;
|
||||
}
|
||||
.B {
|
||||
/* green */
|
||||
background: #CD0;
|
||||
}
|
||||
.C {
|
||||
/* yellow */
|
||||
background: #FD2;
|
||||
}
|
||||
.D {
|
||||
/* orange */
|
||||
background: #FA2;
|
||||
}
|
||||
.E {
|
||||
/* red */
|
||||
background: #F60;
|
||||
}
|
||||
.F {
|
||||
/* red */
|
||||
background: #F22;
|
||||
}
|
||||
.NA {
|
||||
/* Non applicable */
|
||||
background: #CCC;
|
||||
}
|
||||
.board {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
.backToDashboard {
|
||||
text-align: center;
|
||||
}
|
||||
.backToDashboard a {
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
margin-top: 4em;
|
||||
color: black;
|
||||
}
|
||||
a.linkButton {
|
||||
font-size: 1em;
|
||||
padding: 0.3em 0.5em;
|
||||
margin: 0.5em;
|
||||
line-height: 2em;
|
||||
border: 0 solid;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0.1em 0.2em 0 0 #5e2846;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.screenshotWrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background: #000;
|
||||
}
|
||||
.screenshotWrapper > div {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screenshotWrapper .screenshotImage {
|
||||
width: 100%;
|
||||
}
|
||||
.screenshotWrapper .screenshotError {
|
||||
color: #fff;
|
||||
}
|
||||
.screenshotWrapper.desktop,
|
||||
.screenshotWrapper.desktop-hd {
|
||||
border: 0.2em solid #AAA;
|
||||
padding: 0.5em;
|
||||
border-top-left-radius: 0.4em;
|
||||
border-top-right-radius: 0.4em;
|
||||
}
|
||||
.screenshotWrapper.desktop:before,
|
||||
.screenshotWrapper.desktop-hd:before {
|
||||
position: absolute;
|
||||
width: 15em;
|
||||
height: 0.6em;
|
||||
bottom: -0.75em;
|
||||
left: -1em;
|
||||
background: #CCC;
|
||||
border-bottom-left-radius: 0.2em;
|
||||
border-bottom-right-radius: 0.2em;
|
||||
content: " ";
|
||||
}
|
||||
.screenshotWrapper.desktop:after,
|
||||
.screenshotWrapper.desktop-hd:after {
|
||||
position: absolute;
|
||||
width: 0.4em;
|
||||
height: 0.2em;
|
||||
bottom: -0.55em;
|
||||
left: 12.5em;
|
||||
background: lime;
|
||||
content: " ";
|
||||
}
|
||||
.screenshotWrapper.desktop > div,
|
||||
.screenshotWrapper.desktop-hd > div {
|
||||
width: 12em;
|
||||
height: 7.5em;
|
||||
}
|
||||
.screenshotWrapper.phone {
|
||||
border: 0.07em solid #CCC;
|
||||
padding: 1em 0.3em 1.5em;
|
||||
border-radius: 0.6em;
|
||||
}
|
||||
.screenshotWrapper.phone:before {
|
||||
position: absolute;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
bottom: 0.3em;
|
||||
left: 3.3em;
|
||||
border: 0.07em solid #CCC;
|
||||
border-radius: 0.5em;
|
||||
content: " ";
|
||||
}
|
||||
.screenshotWrapper.phone:after {
|
||||
position: absolute;
|
||||
width: 1em;
|
||||
height: 0.1em;
|
||||
bottom: 13.9em;
|
||||
left: 3.2em;
|
||||
background: #555;
|
||||
border-radius: 0.05em;
|
||||
content: " ";
|
||||
}
|
||||
.screenshotWrapper.phone > div {
|
||||
width: 6.75em;
|
||||
height: 12em;
|
||||
}
|
||||
.screenshotWrapper.tablet {
|
||||
border: 0.07em solid #CCC;
|
||||
padding: 0.8em 0.5em 0.9em;
|
||||
border-radius: 0.6em;
|
||||
}
|
||||
.screenshotWrapper.tablet:before {
|
||||
position: absolute;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
bottom: 0.15em;
|
||||
left: 4.35em;
|
||||
border: 0.07em solid #CCC;
|
||||
border-radius: 0.4em;
|
||||
content: " ";
|
||||
}
|
||||
.screenshotWrapper.tablet > div {
|
||||
width: 8em;
|
||||
height: 12.8em;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-spacing: 0.25em;
|
||||
}
|
||||
.table > div,
|
||||
.table > a {
|
||||
display: table-row;
|
||||
}
|
||||
.table > .headers > div {
|
||||
font-weight: bold;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
.table > div > div,
|
||||
.table > a > div {
|
||||
padding: 0.1em 1em;
|
||||
background: #f2f2f2;
|
||||
display: table-cell;
|
||||
text-align: left;
|
||||
}
|
||||
.footer {
|
||||
padding: 3em;
|
||||
color: #fff;
|
||||
}
|
||||
.footer a {
|
||||
color: inherit;
|
||||
}
|
||||
.footer .version {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.footer .github {
|
||||
margin: 1em 0 0 0.5em;
|
||||
}
|
||||
.footer .sponsor {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.homeSponsor {
|
||||
color: #ffa319;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.status {
|
||||
margin-top: 2em;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.statusSubMessage {
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 6em;
|
||||
}
|
||||
.progressBarEmpty {
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
margin: 1em auto;
|
||||
padding: 0.05em;
|
||||
border: 1px solid #ffa319;
|
||||
}
|
||||
.progressBarFilled {
|
||||
width: 5%;
|
||||
height: 0.5em;
|
||||
background: #ffa319;
|
||||
transition: width 3s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
.rule.board {
|
||||
text-align: center;
|
||||
}
|
||||
.rule .ruleTable {
|
||||
border-spacing: 1em;
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
background: #f2f2f2;
|
||||
border: 1px dashed #666;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.rule .ruleTable {
|
||||
display: table;
|
||||
}
|
||||
.rule .ruleTable > div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rule .ruleTable .left {
|
||||
width: 33%;
|
||||
}
|
||||
.rule .ruleTable .right {
|
||||
width: 67%;
|
||||
}
|
||||
}
|
||||
.rule .score {
|
||||
font-size: 2.5em;
|
||||
line-height: 2em;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
border-radius: 0.5em;
|
||||
margin: 0 auto 0.25em;
|
||||
}
|
||||
.rule h3 {
|
||||
margin-bottom: 0em;
|
||||
}
|
||||
.rule .okThreshold {
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.rule .message {
|
||||
width: 80%;
|
||||
margin: 1.5em auto;
|
||||
}
|
||||
.rule .message p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
.rule .message ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.rule .message li:before {
|
||||
content: '\25e6';
|
||||
margin-right: 0.3em;
|
||||
font-size: 1.2em;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
}
|
||||
.rule .warning {
|
||||
width: 90%;
|
||||
margin: -1em auto 2em;
|
||||
background: #FEE;
|
||||
border: 1px dashed #e74c3c;
|
||||
color: #e74c3c;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
.rule .offendersTable {
|
||||
display: table;
|
||||
border-spacing: 0 0.25em;
|
||||
margin: 0 auto;
|
||||
min-width: 10%;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.rule .offendersTable {
|
||||
max-width: 90%;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
.rule .offendersTable > div {
|
||||
display: table-row;
|
||||
}
|
||||
.rule .offendersTable > div > div {
|
||||
display: table-cell;
|
||||
background: #f2f2f2;
|
||||
padding: 0 0.25em;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
.rule .offendersTable > div > div:hover {
|
||||
background: #d8ebe0;
|
||||
}
|
||||
.rule .notFound {
|
||||
font-size: 1em;
|
||||
}
|
||||
.rule .notFound h2 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.rule .startTime {
|
||||
display: none;
|
||||
}
|
||||
.offendersTable .offenderButton,
|
||||
.value .offenderButton {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background: #efe;
|
||||
padding: 0 0.5em;
|
||||
margin: 0.2em 0;
|
||||
border-radius: 0.4em;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.offendersTable .offenderButton.opens,
|
||||
.value .offenderButton.opens {
|
||||
padding-right: 0.75em;
|
||||
}
|
||||
.offendersTable .offenderButton.opens:after,
|
||||
.value .offenderButton.opens:after {
|
||||
position: relative;
|
||||
left: 0.5em;
|
||||
content: '\25BC';
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.offendersTable .offenderButton > div,
|
||||
.value .offenderButton > div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
min-width: 100%;
|
||||
background: inherit;
|
||||
border-bottom-left-radius: 0.4em;
|
||||
border-bottom-right-radius: 0.4em;
|
||||
border-top: 1px solid #999;
|
||||
z-index: 2;
|
||||
}
|
||||
.offendersTable .offenderButton .domTree,
|
||||
.value .offenderButton .domTree {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.offendersTable .offenderButton .domTree > div,
|
||||
.value .offenderButton .domTree > div {
|
||||
margin: 0.5em;
|
||||
}
|
||||
.offendersTable .offenderButton .domTree > div div,
|
||||
.value .offenderButton .domTree > div div {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.offendersTable .offenderButton .backtrace,
|
||||
.value .offenderButton .backtrace,
|
||||
.offendersTable .offenderButton .cssFileAndLine,
|
||||
.value .offenderButton .cssFileAndLine {
|
||||
white-space: nowrap;
|
||||
padding: 0.5em;
|
||||
}
|
||||
.offendersTable .offenderButton.opens:hover,
|
||||
.value .offenderButton.opens:hover {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background: #ffe0cc;
|
||||
z-index: 2;
|
||||
}
|
||||
.offendersTable .offenderButton.opens:hover > div,
|
||||
.value .offenderButton.opens:hover > div {
|
||||
display: block;
|
||||
background: #ffe0cc;
|
||||
}
|
||||
.offendersTable .smallerOffenders,
|
||||
.value .smallerOffenders {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.offendersHtml {
|
||||
display: inline-block;
|
||||
}
|
||||
.domTree div {
|
||||
text-align: left;
|
||||
margin-left: 1em;
|
||||
}
|
||||
.domTree div span:only-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
.domTree div span:only-child span {
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
.checker {
|
||||
/* Checkerboard background */
|
||||
background-color: #ddd;
|
||||
background-image: linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA), linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA);
|
||||
background-size: 1em 1em;
|
||||
background-position: 0 0, 0.5em 0.5em;
|
||||
}
|
||||
.colorPalette {
|
||||
width: 30em;
|
||||
border: 2px solid #000;
|
||||
text-align: left;
|
||||
}
|
||||
.colorPalette > div {
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
}
|
||||
.colorPalette > div div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 100%;
|
||||
background: #FFF;
|
||||
padding: 0.5em;
|
||||
border: 2px solid #f1c40f;
|
||||
border-radius: 0.5em;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
font-weight: bold;
|
||||
}
|
||||
.colorPalette > div:hover div {
|
||||
display: block;
|
||||
}
|
||||
.colorPalette > div:hover:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: -0.2em;
|
||||
top: -0.2em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
border: 0.2em solid #f1c40f;
|
||||
}
|
||||
.similarColors {
|
||||
margin: 1em;
|
||||
width: 20em;
|
||||
height: 6em;
|
||||
}
|
||||
.similarColors > div {
|
||||
display: inline-block;
|
||||
width: 10em;
|
||||
height: 3.5em;
|
||||
padding-top: 2.5em;
|
||||
}
|
||||
.totalWeightPie {
|
||||
max-width: 20em;
|
||||
margin: 2em auto 4em;
|
||||
}
|
||||
.totalWeightPie canvas {
|
||||
max-width: inherit;
|
||||
}
|
||||
.offenderProblem {
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.imageOffenders {
|
||||
display: table;
|
||||
border-spacing: 3em;
|
||||
width: 90%;
|
||||
}
|
||||
.imageOffenders > div {
|
||||
display: table-row;
|
||||
}
|
||||
.imageOffenders > div > div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.imageOffenders img {
|
||||
max-height: 10em;
|
||||
max-width: 40em;
|
||||
border: 1px solid #000;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.smallPreview {
|
||||
display: block;
|
||||
max-height: 6em;
|
||||
max-width: 16em;
|
||||
border: 1px solid #000;
|
||||
margin: 1em auto 0.2em;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.screenshot.board {
|
||||
text-align: center;
|
||||
}
|
||||
.screenshot .screenshotWrapper {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@media (min-width: 420px) {
|
||||
.screenshot .screenshotWrapper {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.screenshot .screenshotWrapper {
|
||||
font-size: 2.08333333em;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 614 B |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,85 @@
|
||||
var yltApp = angular.module('YellowLabTools', [
|
||||
'ngRoute',
|
||||
'ngSanitize',
|
||||
'ngAnimate',
|
||||
'indexCtrl',
|
||||
'dashboardCtrl',
|
||||
'queueCtrl',
|
||||
'ruleCtrl',
|
||||
'screenshotCtrl',
|
||||
'runsFactory',
|
||||
'resultsFactory',
|
||||
'apiService',
|
||||
'menuService',
|
||||
'settingsService',
|
||||
'gradeDirective',
|
||||
'offendersDirectives',
|
||||
'LocalStorageModule'
|
||||
]);
|
||||
|
||||
yltApp.run(['$rootScope', '$location', function($rootScope, $location) {
|
||||
$rootScope.isTouchDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(window.navigator.userAgent);
|
||||
$rootScope.loadedRunId = null;
|
||||
|
||||
var oldHash;
|
||||
|
||||
// We don't want the hash to be kept between two pages
|
||||
$rootScope.$on('$locationChangeStart', function(param1, param2, param3, param4){
|
||||
var newHash = $location.hash();
|
||||
if (newHash === oldHash) {
|
||||
$location.hash(null);
|
||||
}
|
||||
oldHash = newHash;
|
||||
});
|
||||
|
||||
// Google Analytics
|
||||
$rootScope.$on('$routeChangeSuccess', function(){
|
||||
if (typeof ga !== "undefined") {
|
||||
ga('send', 'pageview', {'page': $location.path()});
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub star button (asynchronously loaded iframe)
|
||||
window.addEventListener('load', function() {
|
||||
window.document.getElementById('ghbtn').src = 'https://ghbtns.com/github-btn.html?user=YellowLabTools&repo=YellowLabTools&type=star&count=true&size=large';
|
||||
});
|
||||
}]);
|
||||
|
||||
yltApp.config(['$routeProvider', '$locationProvider',
|
||||
function($routeProvider, $locationProvider) {
|
||||
$routeProvider.
|
||||
when('/', {
|
||||
templateUrl: 'views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
}).
|
||||
when('/queue/:runId', {
|
||||
templateUrl: 'views/queue.html',
|
||||
controller: 'QueueCtrl'
|
||||
}).
|
||||
when('/about', {
|
||||
templateUrl: 'views/about.html'
|
||||
}).
|
||||
when('/result/:runId', {
|
||||
templateUrl: 'views/dashboard.html',
|
||||
controller: 'DashboardCtrl'
|
||||
}).
|
||||
when('/result/:runId/screenshot', {
|
||||
templateUrl: 'views/screenshot.html',
|
||||
controller: 'ScreenshotCtrl'
|
||||
}).
|
||||
when('/result/:runId/rule/:policy', {
|
||||
templateUrl: 'views/rule.html',
|
||||
controller: 'RuleCtrl'
|
||||
}).
|
||||
otherwise({
|
||||
redirectTo: '/'
|
||||
});
|
||||
|
||||
$locationProvider.html5Mode(true);
|
||||
}
|
||||
]);
|
||||
|
||||
// Disable debugging https://docs.angularjs.org/guide/production
|
||||
yltApp.config(['$compileProvider', function ($compileProvider) {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
}]);
|
||||
@@ -0,0 +1,37 @@
|
||||
var dashboardCtrl = angular.module('dashboardCtrl', ['resultsFactory', 'menuService']);
|
||||
|
||||
dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, Menu) {
|
||||
$scope.runId = $routeParams.runId;
|
||||
$scope.Menu = Menu.setCurrentPage('dashboard', $scope.runId);
|
||||
|
||||
function loadResults() {
|
||||
// Load result if needed
|
||||
if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
|
||||
Results.get({runId: $routeParams.runId, exclude: 'toolsResults'}, function(result) {
|
||||
$rootScope.loadedResult = result;
|
||||
$scope.result = result;
|
||||
init();
|
||||
}, function(err) {
|
||||
$scope.error = true;
|
||||
});
|
||||
} else {
|
||||
$scope.result = $rootScope.loadedResult;
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
// By default, Angular sorts object's attributes alphabetically. Countering this problem by retrieving the keys order here.
|
||||
$scope.categoriesOrder = Object.keys($scope.result.scoreProfiles.generic.categories);
|
||||
|
||||
$scope.globalScore = Math.max($scope.result.scoreProfiles.generic.globalScore, 0);
|
||||
|
||||
$scope.tweetText = 'I\'ve discovered this cool open-source tool that audits the front-end quality of a web page: ';
|
||||
}
|
||||
|
||||
$scope.testAgain = function() {
|
||||
API.relaunchTest($scope.result);
|
||||
};
|
||||
|
||||
loadResults();
|
||||
}]);
|
||||
@@ -0,0 +1,23 @@
|
||||
var indexCtrl = angular.module('indexCtrl', []);
|
||||
|
||||
indexCtrl.controller('IndexCtrl', ['$scope', '$routeParams', '$location', 'Settings', 'API', function($scope, $routeParams, $location, Settings, API) {
|
||||
|
||||
$scope.settings = Settings.getMergedSettings();
|
||||
|
||||
$scope.launchTest = function() {
|
||||
if ($scope.url) {
|
||||
$location.search('url', null);
|
||||
$location.search('run', null);
|
||||
Settings.saveSettings($scope.settings);
|
||||
API.launchTest($scope.url, $scope.settings);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto fill URL field and auto launch test when the good params are set in the URL
|
||||
if ($routeParams.url) {
|
||||
$scope.url = $routeParams.url;
|
||||
if ($routeParams.run === 'true' || $routeParams.run === 1 || $routeParams.run === '1') {
|
||||
$scope.launchTest();
|
||||
}
|
||||
}
|
||||
}]);
|
||||
@@ -0,0 +1,100 @@
|
||||
var queueCtrl = angular.module('queueCtrl', ['runsFactory']);
|
||||
|
||||
queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', 'API', function($scope, $routeParams, $location, Runs, API) {
|
||||
$scope.runId = $routeParams.runId;
|
||||
|
||||
var numberOfTries = 0;
|
||||
|
||||
var favicon = document.querySelector('link[rel=icon]');
|
||||
var faviconUrl = 'img/favicon.png';
|
||||
var faviconSuccessUrl = 'img/favicon-success.png';
|
||||
var faviconFailUrl = 'img/favicon-fail.png';
|
||||
var faviconInterval = null;
|
||||
var faviconCounter = 0;
|
||||
var faviconCanvas = null;
|
||||
var faviconCanvasContext = null;
|
||||
var faviconImage = null;
|
||||
|
||||
function getRunStatus () {
|
||||
Runs.get({runId: $scope.runId}, function(data) {
|
||||
$scope.url = data.params.url;
|
||||
$scope.status = data.status;
|
||||
$scope.progress = data.progress;
|
||||
$scope.notFound = false;
|
||||
$scope.connectionLost = false;
|
||||
|
||||
if (data.status.statusCode === 'awaiting') {
|
||||
numberOfTries ++;
|
||||
rotateFavicon();
|
||||
|
||||
// Retrying every 2 seconds (and increasing the delay a bit more each time)
|
||||
setTimeout(getRunStatus, 2000 + (numberOfTries * 100));
|
||||
|
||||
} else if (data.status.statusCode === 'running') {
|
||||
numberOfTries ++;
|
||||
rotateFavicon();
|
||||
|
||||
// Retrying every second or so
|
||||
setTimeout(getRunStatus, 1000 + (numberOfTries * 10));
|
||||
|
||||
} else if (data.status.statusCode === 'complete') {
|
||||
stopFavicon(true);
|
||||
|
||||
$location.path('/result/' + $scope.runId).replace();
|
||||
} else {
|
||||
stopFavicon(false);
|
||||
|
||||
// The rest is handled by the view
|
||||
}
|
||||
}, function(response) {
|
||||
if (response.status === 404) {
|
||||
stopFavicon(false);
|
||||
$scope.notFound = true;
|
||||
$scope.connectionLost = false;
|
||||
} else if (response.status === 0) {
|
||||
// Connection lost, retry in 10 seconds
|
||||
setTimeout(getRunStatus, 10000);
|
||||
$scope.connectionLost = true;
|
||||
$scope.notFound = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function rotateFavicon() {
|
||||
if (!faviconInterval) {
|
||||
faviconImage = new Image();
|
||||
faviconImage.onload = function() {
|
||||
faviconCanvas = document.getElementById('faviconRotator');
|
||||
faviconCanvasContext = faviconCanvas.getContext('2d');
|
||||
faviconCanvasContext.fillStyle = '#212240';
|
||||
|
||||
if (!!faviconCanvasContext) {
|
||||
faviconInterval = window.setInterval(faviconTick, 300);
|
||||
}
|
||||
};
|
||||
faviconImage.src = faviconUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconTick() {
|
||||
faviconCounter ++;
|
||||
faviconCanvasContext.save();
|
||||
faviconCanvasContext.fillRect(0, 0, 32, 32);
|
||||
faviconCanvasContext.translate(16, 16);
|
||||
faviconCanvasContext.rotate(22.5 * faviconCounter * Math.PI / 180);
|
||||
faviconCanvasContext.translate(-16, -16);
|
||||
faviconCanvasContext.drawImage(faviconImage, 0, 0, 32, 32);
|
||||
faviconCanvasContext.restore();
|
||||
favicon.href = faviconCanvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function stopFavicon(isSuccess) {
|
||||
window.clearInterval(faviconInterval);
|
||||
faviconInterval = null;
|
||||
favicon.href = isSuccess ? faviconSuccessUrl : faviconFailUrl;
|
||||
}
|
||||
|
||||
getRunStatus();
|
||||
}]);
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
var ruleCtrl = angular.module('ruleCtrl', ['chart.js']);
|
||||
|
||||
ruleCtrl.config(['ChartJsProvider', function (ChartJsProvider) {
|
||||
// Configure all charts
|
||||
ChartJsProvider.setOptions({
|
||||
animation: false,
|
||||
colours: ['#FF5252', '#FF8A80'],
|
||||
responsive: true
|
||||
});
|
||||
}]);
|
||||
|
||||
ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, API) {
|
||||
$scope.runId = $routeParams.runId;
|
||||
$scope.policyName = $routeParams.policy;
|
||||
$scope.Menu = Menu.setCurrentPage(null, $scope.runId);
|
||||
$scope.rule = null;
|
||||
|
||||
function loadResults() {
|
||||
// Load result if needed
|
||||
if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
|
||||
Results.get({runId: $routeParams.runId, exclude: 'toolsResults'}, function(result) {
|
||||
$rootScope.loadedResult = result;
|
||||
$scope.result = result;
|
||||
init();
|
||||
});
|
||||
} else {
|
||||
$scope.result = $rootScope.loadedResult;
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
$scope.rule = $scope.result.rules[$scope.policyName];
|
||||
|
||||
// Init "Total Weight" chart
|
||||
if ($scope.policyName === 'totalWeight') {
|
||||
$scope.weightLabels = [];
|
||||
$scope.weightColours = ['#7ECCCC', '#A7E846', '#FF944D', '#FFE74A', '#C2A3FF', '#5A9AED', '#FF6452', '#C1C1C1'];
|
||||
$scope.weightData = [];
|
||||
|
||||
var types = ['html', 'css', 'js', 'json', 'image', 'video', 'webfont', 'other'];
|
||||
types.forEach(function(type) {
|
||||
$scope.weightLabels.push(type);
|
||||
$scope.weightData.push(Math.round($scope.rule.offendersObj.list.byType[type].totalWeight / 1024));
|
||||
});
|
||||
|
||||
$scope.weightOptions = {
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem, data) {
|
||||
var label = data.labels[tooltipItem.index];
|
||||
var value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
|
||||
return label + ': ' + value + ' KB';
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
fontSize: 14
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$scope.backToDashboard = function() {
|
||||
$location.path('/result/' + $scope.runId);
|
||||
};
|
||||
|
||||
$scope.testAgain = function() {
|
||||
API.relaunchTest($scope.result);
|
||||
};
|
||||
|
||||
loadResults();
|
||||
}]);
|
||||
@@ -0,0 +1,30 @@
|
||||
var screenshotCtrl = angular.module('screenshotCtrl', ['resultsFactory', 'menuService']);
|
||||
|
||||
screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, Menu) {
|
||||
$scope.runId = $routeParams.runId;
|
||||
$scope.Menu = Menu.setCurrentPage(null, $scope.runId);
|
||||
|
||||
function loadResults() {
|
||||
// Load result if needed
|
||||
if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
|
||||
Results.get({runId: $routeParams.runId, exclude: 'toolsResults'}, function(result) {
|
||||
$rootScope.loadedResult = result;
|
||||
$scope.result = result;
|
||||
}, function(err) {
|
||||
$scope.error = true;
|
||||
});
|
||||
} else {
|
||||
$scope.result = $rootScope.loadedResult;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.backToDashboard = function() {
|
||||
$location.path('/result/' + $scope.runId);
|
||||
};
|
||||
|
||||
$scope.testAgain = function() {
|
||||
API.relaunchTest($scope.result);
|
||||
};
|
||||
|
||||
loadResults();
|
||||
}]);
|
||||
@@ -0,0 +1,33 @@
|
||||
var gradeDirective = angular.module('gradeDirective', []);
|
||||
|
||||
gradeDirective.directive('grade', function() {
|
||||
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
score: '=score'
|
||||
},
|
||||
template: '<div ng-class="getGrade(score)">{{getGrade(score)}}</div>',
|
||||
replace: true,
|
||||
controller : ['$scope', function($scope) {
|
||||
$scope.getGrade = function(score) {
|
||||
if (score > 80) {
|
||||
return 'A';
|
||||
}
|
||||
if (score > 60) {
|
||||
return 'B';
|
||||
}
|
||||
if (score > 40) {
|
||||
return 'C';
|
||||
}
|
||||
if (score > 20) {
|
||||
return 'D';
|
||||
}
|
||||
if (score > 0) {
|
||||
return 'E';
|
||||
}
|
||||
return 'F';
|
||||
};
|
||||
}]
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
var offendersDirectives = angular.module('offendersDirectives', []);
|
||||
|
||||
function getdomTreeHTML(tree) {
|
||||
return '<div class="domTree">' + getdomTreeInnerHTML(tree) + '</div>';
|
||||
}
|
||||
|
||||
function getdomTreeInnerHTML(tree) {
|
||||
return recursiveHtmlBuilder(tree);
|
||||
}
|
||||
|
||||
function recursiveHtmlBuilder(tree) {
|
||||
var html = '';
|
||||
var keys = Object.keys(tree);
|
||||
|
||||
keys.forEach(function(key) {
|
||||
if (isNaN(tree[key])) {
|
||||
html += '<div><span>' + key + '</span>' + recursiveHtmlBuilder(tree[key]) + '</div>';
|
||||
} else if (tree[key] > 1) {
|
||||
html += '<div><span>' + key + ' <span>(x' + tree[key] + ')</span></span></div>';
|
||||
} else {
|
||||
html += '<div><span>' + key + '</span></div>';
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
offendersDirectives.directive('domTree', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
tree: '='
|
||||
},
|
||||
template: '<div class="domTree"></div>',
|
||||
replace: true,
|
||||
link: function(scope, element) {
|
||||
element.append(getdomTreeInnerHTML(scope.tree));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function getDomElementButtonHTML(obj, onASingleLine) {
|
||||
if (obj.tree && !onASingleLine) {
|
||||
return '<div class="offenderButton opens">' + getDomElementButtonInnerHTML(obj, onASingleLine) + '</div>';
|
||||
} else {
|
||||
return '<div class="offenderButton">' + getDomElementButtonInnerHTML(obj, onASingleLine) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function getDomElementButtonInnerHTML(obj, onASingleLine) {
|
||||
if (obj.type === 'html' ||
|
||||
obj.type === 'body' ||
|
||||
obj.type === 'head' ||
|
||||
obj.type === 'window' ||
|
||||
obj.type === 'document' ||
|
||||
obj.type === 'fragment') {
|
||||
return obj.type;
|
||||
}
|
||||
|
||||
if (obj.type === 'notAnElement') {
|
||||
return 'Incorrect element';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
if (obj.type === 'domElement') {
|
||||
html = 'DOM element <b>' + obj.element + '</b>';
|
||||
} else if (obj.type === 'fragmentElement') {
|
||||
html = 'Fragment element <b>' + obj.element + '</b>';
|
||||
} else if (obj.type === 'createdElement') {
|
||||
html = 'Created element <b>' + obj.element + '</b>';
|
||||
}
|
||||
|
||||
if (obj.tree && !onASingleLine) {
|
||||
html += getdomTreeHTML(obj.tree);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
offendersDirectives.directive('domElementButton', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
obj: '='
|
||||
},
|
||||
template: '<div class="offenderButton" ng-class="{opens: obj.tree}"></div>',
|
||||
replace: true,
|
||||
link: function(scope, element) {
|
||||
element.append(getDomElementButtonInnerHTML(scope.obj));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.filter('lastDOMNode', function() {
|
||||
return function(str) {
|
||||
var splited = str.split(' > ');
|
||||
return splited[splited.length - 1];
|
||||
};
|
||||
});
|
||||
|
||||
function getBacktraceHTML(backtrace) {
|
||||
var html = '';
|
||||
var parsedBacktrace = parseBacktrace(backtrace);
|
||||
|
||||
if (!parsedBacktrace || parsedBacktrace.length === 0) {
|
||||
html += '<div><div>can\'t find any backtrace :/</div></div>';
|
||||
} else {
|
||||
for (var i = 0 ; i < parsedBacktrace.length ; i++) {
|
||||
html += '<div>';
|
||||
html += '<div>' + (parsedBacktrace[i].fnName || '(anonymous function)') + '</div>';
|
||||
html += '<div class="trace">' + getUrlLink(parsedBacktrace[i].filePath, 40) + '</div>';
|
||||
if (parsedBacktrace[i].column) {
|
||||
html += '<div>' + parsedBacktrace[i].line + ':' + parsedBacktrace[i].column + '</div>';
|
||||
} else {
|
||||
html += '<div>line ' + parsedBacktrace[i].line + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseBacktrace(str) {
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var out = [];
|
||||
var splited = str.split(' / ');
|
||||
|
||||
try {
|
||||
|
||||
splited.forEach(function(trace) {
|
||||
var fnName = null, fileAndLine;
|
||||
|
||||
var withFnResult = /^([^\s\(]+) \((.+:\d+)\)$/.exec(trace);
|
||||
|
||||
if (withFnResult === null) {
|
||||
// Try the PhantomJS 2 format
|
||||
withFnResult = /^([^\s\(]+) \((.+:\d+:\d+)\)$/.exec(trace);
|
||||
}
|
||||
|
||||
if (withFnResult === null) {
|
||||
// Yet another PhantomJS 2 format?
|
||||
withFnResult = /^([^\s\(]+|global code)@(.+:\d+:\d+)$/.exec(trace);
|
||||
}
|
||||
|
||||
if (withFnResult === null) {
|
||||
// Try the PhantomJS 2 ERROR format
|
||||
withFnResult = /^([^\s\(]+) (http.+:\d+)$/.exec(trace);
|
||||
}
|
||||
|
||||
if (withFnResult === null) {
|
||||
fileAndLine = trace;
|
||||
} else {
|
||||
fnName = withFnResult[1];
|
||||
fileAndLine = withFnResult[2];
|
||||
}
|
||||
|
||||
// And now the second part
|
||||
var fileAndLineSplit = /^(.*):(\d+):(\d+)$/.exec(fileAndLine);
|
||||
|
||||
if (fileAndLineSplit === null) {
|
||||
fileAndLineSplit = /^(.*):(\d+)$/.exec(fileAndLine);
|
||||
}
|
||||
|
||||
var filePath = fileAndLineSplit[1];
|
||||
var line = fileAndLineSplit[2];
|
||||
var column = fileAndLineSplit[3];
|
||||
|
||||
// Filter phantomas code
|
||||
if (filePath.indexOf('phantomjs://') === -1) {
|
||||
out.push({
|
||||
fnName: fnName,
|
||||
filePath: filePath,
|
||||
line: line,
|
||||
column: column
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function shortenUrl(url, maxLength) {
|
||||
if (!maxLength) {
|
||||
maxLength = 110;
|
||||
}
|
||||
|
||||
// Why dividing by 2.1? Because it adds a 5% margin.
|
||||
var leftLength = Math.floor((maxLength - 5) / 2.1);
|
||||
var rightLength = Math.ceil((maxLength - 5) / 2.1);
|
||||
|
||||
return (url.length > maxLength) ? url.substr(0, leftLength) + ' ... ' + url.substr(-rightLength) : url;
|
||||
}
|
||||
|
||||
offendersDirectives.filter('shortenUrl', function() {
|
||||
return shortenUrl;
|
||||
});
|
||||
|
||||
function getUrlLink(url, maxLength) {
|
||||
return '<a href="' + url + '" target="_blank" title="' + url + '">' + shortenUrl(url, maxLength) + '</a>';
|
||||
}
|
||||
|
||||
offendersDirectives.directive('urlLink', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
url: '=',
|
||||
maxLength: '='
|
||||
},
|
||||
template: '<a href="{{url}}" target="_blank" title="{{url}}">{{url | shortenUrl:maxLength}}</a>',
|
||||
replace: true
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.filter('encodeURIComponent', function() {
|
||||
return window.encodeURIComponent;
|
||||
});
|
||||
|
||||
offendersDirectives.directive('fileAndLine', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
file: '=',
|
||||
line: '=',
|
||||
column: '='
|
||||
},
|
||||
template: '<span><span ng-if="file"><url-link url="file" max-length="60"></url-link></span><span ng-if="!file"><inline CSS></span><span ng-if="line !== null && column !== null"> @ {{line}}:{{column}}</span></span>',
|
||||
replace: true
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.directive('fileAndLineButton', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
file: '=',
|
||||
line: '=',
|
||||
column: '='
|
||||
},
|
||||
template: '<div class="offenderButton opens">css file<div class="cssFileAndLine"><file-and-line file="file" line="line" column="column" button="true"></file-and-line></div></div>',
|
||||
replace: true
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.filter('bytes', function() {
|
||||
return function(bytes) {
|
||||
if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var kilo = bytes / 1024;
|
||||
|
||||
if (kilo < 1) {
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
|
||||
if (kilo < 100) {
|
||||
return kilo.toFixed(1) + ' KB';
|
||||
}
|
||||
|
||||
if (kilo < 1024) {
|
||||
return kilo.toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
var mega = kilo / 1024;
|
||||
|
||||
if (mega < 10) {
|
||||
return mega.toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
return mega.toFixed(1) + ' MB';
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.filter('addSpaces', function() {
|
||||
return function(str) {
|
||||
return str.split('').join(' ');
|
||||
};
|
||||
});
|
||||
|
||||
// Proxify an HTTP image to HTTPS if hosted on HTTPS
|
||||
// Uses a great free open-source external service: https://images.weserv.nl
|
||||
offendersDirectives.filter('https', function() {
|
||||
return function(url) {
|
||||
if (url && url.indexOf('http://') === 0 && window.location.protocol === 'https:') {
|
||||
return 'https://images.weserv.nl/?url=' + encodeURIComponent(url.substr(7));
|
||||
}
|
||||
return url;
|
||||
};
|
||||
});
|
||||
|
||||
offendersDirectives.filter('roundNbr', function() {
|
||||
return function(nbr) {
|
||||
return Math.round(nbr);
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
var resultsFactory = angular.module('resultsFactory', ['ngResource']);
|
||||
|
||||
resultsFactory.factory('Results', ['$resource', function($resource) {
|
||||
return $resource('api/results/:runId', {
|
||||
|
||||
});
|
||||
}]);
|
||||
@@ -0,0 +1,7 @@
|
||||
var runsFactory = angular.module('runsFactory', ['ngResource']);
|
||||
|
||||
runsFactory.factory('Runs', ['$resource', function($resource) {
|
||||
return $resource('api/runs/:runId', {
|
||||
|
||||
});
|
||||
}]);
|
||||
@@ -0,0 +1,64 @@
|
||||
var apiService = angular.module('apiService', []);
|
||||
|
||||
apiService.factory('API', ['$location', 'Runs', 'Results', function($location, Runs, Results) {
|
||||
|
||||
return {
|
||||
|
||||
launchTest: function(url, settings) {
|
||||
var runObject = {
|
||||
url: url,
|
||||
waitForResponse: false,
|
||||
screenshot: true,
|
||||
device: settings.device,
|
||||
waitForSelector: settings.waitForSelector,
|
||||
proxy: settings.proxy,
|
||||
cookie: settings.cookie,
|
||||
authUser: settings.authUser,
|
||||
authPass: settings.authPass,
|
||||
blockDomain: settings.blockDomain,
|
||||
allowedDomains: settings.allowedDomains,
|
||||
noExternals: settings.noExternals
|
||||
};
|
||||
|
||||
|
||||
if (settings.domainsBlockOrAllow === 'block') {
|
||||
runObject.blockDomain = this.parseDomains(settings.domains);
|
||||
} else if (settings.domainsBlockOrAllow === 'allow') {
|
||||
var allowedDomains = this.parseDomains(settings.domains);
|
||||
if (allowedDomains.length > 0) {
|
||||
runObject.allowDomain = allowedDomains;
|
||||
} else {
|
||||
runObject.noExternals = true;
|
||||
}
|
||||
}
|
||||
|
||||
Runs.save(runObject, function(data) {
|
||||
$location.path('/queue/' + data.runId);
|
||||
}, function(response) {
|
||||
if (response.status === 429) {
|
||||
alert('Too many requests, you reached the max number of requests allowed in 24h');
|
||||
} else if (response.status === 403) {
|
||||
alert('This particular query was blocked due to spamming. If you think it\'s an error, please open an issue on GitHub.');
|
||||
} else {
|
||||
alert('An error occured...');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
relaunchTest: function(result) {
|
||||
this.launchTest(result.params.url, result.params.options);
|
||||
},
|
||||
|
||||
parseDomains: function(textareaContent) {
|
||||
var lines = textareaContent.split('\n');
|
||||
|
||||
function removeEmptyLines (line) {
|
||||
return line.trim() !== '';
|
||||
}
|
||||
|
||||
// Remove empty lines
|
||||
return lines.filter(removeEmptyLines).join(',');
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
@@ -0,0 +1,31 @@
|
||||
var menuService = angular.module('menuService', []);
|
||||
|
||||
menuService.factory('Menu', ['$location', function($location) {
|
||||
|
||||
var currentPage, currentRunId;
|
||||
|
||||
return {
|
||||
getCurrentPage: function() {
|
||||
return currentPage;
|
||||
},
|
||||
setCurrentPage: function(page, runId) {
|
||||
currentPage = page;
|
||||
currentRunId = runId;
|
||||
|
||||
return this;
|
||||
},
|
||||
changePage: function(page) {
|
||||
switch (page) {
|
||||
case 'index':
|
||||
$location.path('/');
|
||||
break;
|
||||
case 'dashboard':
|
||||
$location.path('/result/' + currentRunId);
|
||||
break;
|
||||
default:
|
||||
console.err('Undefined Menu.changePage() destination');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
@@ -0,0 +1,24 @@
|
||||
var settingsService = angular.module('settingsService', []);
|
||||
|
||||
settingsService.factory('Settings', ['localStorageService', function(localStorageService) {
|
||||
|
||||
return {
|
||||
|
||||
getMergedSettings: function() {
|
||||
var defaultSettings = {
|
||||
device: 'phone',
|
||||
showAdvanced: false
|
||||
};
|
||||
|
||||
var savedValues = localStorageService.get('settings');
|
||||
|
||||
return angular.extend(defaultSettings, savedValues);
|
||||
},
|
||||
|
||||
saveSettings: function(settings) {
|
||||
localStorageService.set('settings', settings);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
}]);
|
||||
@@ -0,0 +1,20 @@
|
||||
.about {
|
||||
margin: 3em auto;
|
||||
width: 80%;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.about p {
|
||||
margin: 2em;
|
||||
}
|
||||
|
||||
.about a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sponsor {
|
||||
color: #ffa319;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
.testedUrl {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.summary {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary .globalScore {
|
||||
margin: 3em auto;
|
||||
|
||||
.globalGrade {
|
||||
margin: 0.5 auto;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
line-height: 2.5em;
|
||||
border-radius: 0.5em;
|
||||
font-size: 3em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.on100 {
|
||||
font-size: 1.2em;
|
||||
margin: 0.5em 0 1em;
|
||||
}
|
||||
|
||||
.screenshotWrapper:hover {
|
||||
opacity: 0.75;
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
top: 0.7em;
|
||||
left: 1.55em;
|
||||
font-size: 3em;
|
||||
color: #FFF;
|
||||
background: #000;
|
||||
border-radius: 0.2em;
|
||||
text-align: center;
|
||||
content: "+";
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshotWrapper.phone:hover:after {
|
||||
top: 1.7em;
|
||||
left: 0.64em;
|
||||
}
|
||||
|
||||
.screenshotWrapper.tablet:hover:after {
|
||||
top: 1.5em;
|
||||
left: 0.9em;
|
||||
}
|
||||
|
||||
@media (min-width: 820px) {
|
||||
width: 65%;
|
||||
display: table;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary .notations {
|
||||
width: 100%;
|
||||
display: table;
|
||||
margin: 0 0 1.5em;
|
||||
border-spacing: 0 1em;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
width: 80%;
|
||||
margin: 0 10% 1.5em;
|
||||
border-spacing: 1em;
|
||||
}
|
||||
}
|
||||
.summary .notations > div {
|
||||
display: table-row;
|
||||
}
|
||||
.summary .notations > div > div {
|
||||
vertical-align: middle;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
display: table-cell;
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
.summary .notations .category {
|
||||
font-size: 1.2em;
|
||||
width: 50%;
|
||||
float: left;
|
||||
text-align: left;
|
||||
margin: 0.5em 0.25em;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
width: 20%;
|
||||
text-align: center;
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
.summary .notations .criteria {
|
||||
font-weight: normal;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
.A, .B, .C, .D, .E, .F, .NA {
|
||||
.summary .notations &.categoryScore {
|
||||
width: 2.5em;
|
||||
max-width: 2.5em;
|
||||
min-width: 2.5em;
|
||||
margin: 0.2em;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
border-radius: 0.5em;
|
||||
float: right;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
float: none;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
.summary .notations .grade & {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
font-size: 1em;
|
||||
color: transparent;
|
||||
margin: 0 auto;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.summary .notations .criteria .table {
|
||||
width: 100%;
|
||||
> a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
> a:hover > div {
|
||||
background: #d8ebe0;
|
||||
cursor: pointer;
|
||||
&.info {
|
||||
background: #FFF;
|
||||
svg {
|
||||
fill: #d8ebe0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.summary .notations .criteria .grade {
|
||||
width: 10%;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summary .notations .criteria .label {
|
||||
width: 70%;
|
||||
}
|
||||
.summary .notations .criteria .result {
|
||||
width: 18%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summary .notations .warning .label, .summary .notations .warning .result {
|
||||
color: #FF1919;
|
||||
}
|
||||
.summary .notations .icon-warning svg {
|
||||
fill: #FF1919;
|
||||
margin: -2px 0;
|
||||
}
|
||||
.summary .notations .criteria .info {
|
||||
display: none;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
display: table-cell;
|
||||
width: 2%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
background: #FFF;
|
||||
padding-left: 0.1em;
|
||||
padding-right: 0.1em;
|
||||
}
|
||||
}
|
||||
.summary .notations .criteria .info svg {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.summary .sponsor {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 4em;
|
||||
color: #ffa319;
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
.promess {
|
||||
padding: 0em 2em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: normal;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.price {
|
||||
padding: 0em 2em 3em;
|
||||
margin-top: 0em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.url {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.launchBtn {
|
||||
background: #ffa319;
|
||||
color: #fff;
|
||||
&:focus {
|
||||
background: #e74c3c;
|
||||
}
|
||||
&.disabled {
|
||||
background: #f1bd70;
|
||||
&:focus {
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
|
||||
input, select {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input[type=text], input[type=password], textarea {
|
||||
width: 100%;
|
||||
min-width: 4em;
|
||||
}
|
||||
}
|
||||
|
||||
.device {
|
||||
margin-top: 3em;
|
||||
.item {
|
||||
display: inline-block;
|
||||
margin: 1em 0.75em;
|
||||
width: 5.5em;
|
||||
height: 5.5em;
|
||||
color: #FFF;
|
||||
border: 1px solid #FFF;
|
||||
padding: 1px;
|
||||
border-radius: 0.5em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8em;
|
||||
|
||||
> svg {
|
||||
display: block;
|
||||
margin: 0.6em auto 0.3em;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffa319;
|
||||
border: 2px solid #ffa319;
|
||||
padding: 0;
|
||||
|
||||
> svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #ffa319;
|
||||
|
||||
> svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settingsTooltip {
|
||||
position: relative;
|
||||
svg {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
padding: 0.5em;
|
||||
width: 25em;
|
||||
background: #FFF;
|
||||
color: #000;
|
||||
font-size: 0.8em;
|
||||
border-radius: 1em;
|
||||
border: 2px solid #ffa319;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.showAdvanced {
|
||||
display: inline-block;
|
||||
margin-top: 2em;
|
||||
color: #FFF;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
|
||||
&:hover {
|
||||
color: #ffa319;
|
||||
}
|
||||
}
|
||||
|
||||
.currentSettings {
|
||||
font-size: 0.9em;
|
||||
|
||||
span {
|
||||
color: #ffa319;
|
||||
&:after {
|
||||
color: #FFF;
|
||||
content: ",";
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced {
|
||||
margin: 1em 0 0;
|
||||
display: table;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-spacing: 0.75em;
|
||||
|
||||
> div {
|
||||
display: table-row;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
width: 75%;
|
||||
|
||||
&.label {
|
||||
width: 25%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subTable {
|
||||
display: table;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
> div {
|
||||
display: table-row;
|
||||
> div {
|
||||
display: table-cell;
|
||||
padding: 0 0 0.75em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
display: table;
|
||||
width: 50%;
|
||||
margin: 6em auto 0;
|
||||
font-size: 0.9em;
|
||||
color: #8abfaf;
|
||||
|
||||
> div {
|
||||
@media (min-width: 640px) {
|
||||
width: 33.3%;
|
||||
display: table-cell;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=submit], input.url {
|
||||
padding: 0 0.5em;
|
||||
margin: 0.5em;
|
||||
font-size: 1.2em;
|
||||
height: 2em;
|
||||
border: 0 solid;
|
||||
border-radius: 0.5em;
|
||||
outline: none;
|
||||
}
|
||||
input[type=submit]:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
input[type=submit].clicked {
|
||||
color: #ddd;
|
||||
position: relative;
|
||||
left: 0.1em;
|
||||
top: 0.2em;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.homeSponsor {
|
||||
margin-top: 3em;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
html {
|
||||
margin: 35px 5px;
|
||||
@media (min-width: 640px) {
|
||||
margin: 100px 50px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 1280px;
|
||||
background: #212240;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body, input[type=submit], input[type=text], input[type=url], input[type=number], button {
|
||||
font-family: 'Century Gothic', helvetica, arial, sans-serif;
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.resultsMenu {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.resultsMenu .menuItem {
|
||||
font-size: 0.8em;
|
||||
display: inline-block;
|
||||
width: 7em;
|
||||
height: 7em;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1em;
|
||||
margin: 1em;
|
||||
width: 8em;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
&.back, &.restart {
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
}
|
||||
.resultsMenu .menuItem div {
|
||||
padding-top: 0.5em;
|
||||
font-size: 3em;
|
||||
}
|
||||
.resultsMenu svg {
|
||||
display: block;
|
||||
margin: 1.2em auto 0.2em;
|
||||
}
|
||||
.resultsMenu .active, .resultsMenu .menuItem.active:hover {
|
||||
color: #ffa319;
|
||||
border-color: #ffa319;
|
||||
|
||||
svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
}
|
||||
.resultsMenu .menuItem:hover {
|
||||
color: #ffa319;
|
||||
|
||||
svg {
|
||||
fill: #ffa319;
|
||||
}
|
||||
}
|
||||
.resultsMenu span {
|
||||
position: relative;
|
||||
top: 0.5em;
|
||||
}
|
||||
|
||||
/* Grade colors */
|
||||
.A {
|
||||
/* green */
|
||||
background: #0C4;
|
||||
}
|
||||
.B {
|
||||
/* green */
|
||||
background: #CD0;
|
||||
}
|
||||
.C {
|
||||
/* yellow */
|
||||
background: #FD2;
|
||||
}
|
||||
.D {
|
||||
/* orange */
|
||||
background: #FA2;
|
||||
}
|
||||
.E {
|
||||
/* red */
|
||||
background: #F60;
|
||||
}
|
||||
.F {
|
||||
/* red */
|
||||
background: #F22;
|
||||
}
|
||||
.NA {
|
||||
/* Non applicable */
|
||||
background: #CCC;
|
||||
}
|
||||
|
||||
.board {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backToDashboard {
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
margin-top: 4em;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
a.linkButton {
|
||||
font-size: 1em;
|
||||
padding: 0.3em 0.5em;
|
||||
margin: 0.5em;
|
||||
line-height: 2em;
|
||||
border: 0 solid;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0.1em 0.2em 0 0 #5e2846;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.screenshotWrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background: #000;
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshotImage {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screenshotError {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshotWrapper.desktop, .screenshotWrapper.desktop-hd {
|
||||
border: 0.2em solid #AAA;
|
||||
padding: 0.5em;
|
||||
border-top-left-radius: 0.4em;
|
||||
border-top-right-radius: 0.4em;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
width: 15em;
|
||||
height: 0.6em;
|
||||
bottom: -0.75em;
|
||||
left: -1em;
|
||||
background: #CCC;
|
||||
border-bottom-left-radius: 0.2em;
|
||||
border-bottom-right-radius: 0.2em;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
width: 0.4em;
|
||||
height: 0.2em;
|
||||
bottom: -0.55em;
|
||||
left: 12.5em;
|
||||
background: lime;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
> div {
|
||||
width: 12em;
|
||||
height: 7.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshotWrapper.phone {
|
||||
border: 0.07em solid #CCC;
|
||||
padding: 1em 0.3em 1.5em;
|
||||
border-radius: 0.6em;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
bottom: 0.3em;
|
||||
left: 3.3em;
|
||||
border: 0.07em solid #CCC;
|
||||
border-radius: 0.5em;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
width: 1em;
|
||||
height: 0.1em;
|
||||
bottom: 13.9em;
|
||||
left: 3.2em;
|
||||
background: #555;
|
||||
border-radius: 0.05em;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
> div {
|
||||
width: 6.75em;
|
||||
height: 12em;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshotWrapper.tablet {
|
||||
border: 0.07em solid #CCC;
|
||||
padding: 0.8em 0.5em 0.9em;
|
||||
border-radius: 0.6em;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
bottom: 0.15em;
|
||||
left: 4.35em;
|
||||
border: 0.07em solid #CCC;
|
||||
border-radius: 0.4em;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
> div {
|
||||
width: 8em;
|
||||
height: 12.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-spacing: 0.25em;
|
||||
}
|
||||
.table > div,
|
||||
.table > a {
|
||||
display: table-row;
|
||||
}
|
||||
.table > .headers > div {
|
||||
font-weight: bold;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
.table > div > div,
|
||||
.table > a > div {
|
||||
padding: 0.1em 1em;
|
||||
background: #f2f2f2;
|
||||
display: table-cell;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 3em;
|
||||
color: #fff;
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.version {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.github {
|
||||
margin: 1em 0 0 0.5em;
|
||||
}
|
||||
.sponsor {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.homeSponsor {
|
||||
color: #ffa319;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.status {
|
||||
margin-top: 2em;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.statusSubMessage {
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 6em;
|
||||
}
|
||||
|
||||
.progressBarEmpty {
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
margin: 1em auto;
|
||||
padding: 0.05em;
|
||||
border: 1px solid #ffa319;
|
||||
}
|
||||
|
||||
.progressBarFilled {
|
||||
width: 5%;
|
||||
height: 0.5em;
|
||||
background: #ffa319;
|
||||
transition: width 3s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.rule.board {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rule .ruleTable {
|
||||
border-spacing: 1em;
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
background: #f2f2f2;
|
||||
border: 1px dashed #666;
|
||||
border-radius: 0.5em;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
display: table;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 33%;
|
||||
}
|
||||
.right {
|
||||
width: 67%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rule .score {
|
||||
font-size: 2.5em;
|
||||
line-height: 2em;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
border-radius: 0.5em;
|
||||
margin: 0 auto 0.25em;
|
||||
}
|
||||
|
||||
.rule h3 {
|
||||
margin-bottom: 0em;
|
||||
}
|
||||
|
||||
.rule .okThreshold {
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rule .message {
|
||||
width: 80%;
|
||||
margin: 1.5em auto;
|
||||
p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.rule .message ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.rule .message li:before {
|
||||
content:'\25e6';
|
||||
margin-right: 0.3em;
|
||||
font-size: 1.2em;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
.rule .warning {
|
||||
width: 90%;
|
||||
margin: -1em auto 2em;
|
||||
background: #FEE;
|
||||
border: 1px dashed #e74c3c;
|
||||
color: #e74c3c;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.rule .offendersTable {
|
||||
display: table;
|
||||
border-spacing: 0 0.25em;
|
||||
margin: 0 auto;
|
||||
min-width: 10%;
|
||||
font-size: 0.875em;
|
||||
|
||||
@media (min-width: 820px) {
|
||||
max-width: 90%;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: table-row;
|
||||
> div {
|
||||
display: table-cell;
|
||||
background: #f2f2f2;
|
||||
padding: 0 0.25em;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
&:hover {
|
||||
background: #d8ebe0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rule .notFound {
|
||||
font-size: 1em;
|
||||
h2 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.rule .startTime {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.offendersTable, .value {
|
||||
.offenderButton {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background: #efe;
|
||||
padding: 0 0.5em;
|
||||
margin: 0.2em 0;
|
||||
border-radius: 0.4em;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&.opens {
|
||||
padding-right: 0.75em;
|
||||
|
||||
&:after {
|
||||
position: relative;
|
||||
left: 0.5em;
|
||||
content: '\25BC';
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
min-width: 100%;
|
||||
background: inherit;
|
||||
border-bottom-left-radius: 0.4em;
|
||||
border-bottom-right-radius: 0.4em;
|
||||
border-top: 1px solid #999;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.domTree {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
> div {
|
||||
margin: 0.5em;
|
||||
|
||||
div {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.backtrace, .cssFileAndLine {
|
||||
white-space: nowrap;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
&.opens:hover {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background: #ffe0cc;
|
||||
z-index: 2;
|
||||
|
||||
> div {
|
||||
display: block;
|
||||
background: #ffe0cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.smallerOffenders {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.offendersHtml {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.domTree div {
|
||||
text-align: left;
|
||||
margin-left: 1em;
|
||||
|
||||
span:only-child {
|
||||
font-weight: bold;
|
||||
span {
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checker {
|
||||
/* Checkerboard background */
|
||||
background-color: #ddd;
|
||||
background-image: linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA), linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA);
|
||||
background-size:1em 1em;
|
||||
background-position:0 0, 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.colorPalette {
|
||||
width: 30em;
|
||||
border: 2px solid #000;
|
||||
text-align: left;
|
||||
|
||||
> div {
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
|
||||
div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 100%;
|
||||
background: #FFF;
|
||||
padding: 0.5em;
|
||||
border: 2px solid #f1c40f;
|
||||
border-radius: 0.5em;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: -0.2em;
|
||||
top: -0.2em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
border: 0.2em solid #f1c40f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.similarColors {
|
||||
margin: 1em;
|
||||
width: 20em;
|
||||
height: 6em;
|
||||
|
||||
> div {
|
||||
display: inline-block;
|
||||
width: 10em;
|
||||
height: 3.5em;
|
||||
padding-top: 2.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.totalWeightPie {
|
||||
max-width: 20em;
|
||||
margin: 2em auto 4em;
|
||||
|
||||
canvas {
|
||||
max-width: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.offenderProblem {
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.imageOffenders {
|
||||
display: table;
|
||||
border-spacing: 3em;
|
||||
width: 90%;
|
||||
|
||||
> div {
|
||||
display: table-row;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 10em;
|
||||
max-width: 40em;
|
||||
border: 1px solid #000;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.smallPreview {
|
||||
display: block;
|
||||
max-height: 6em;
|
||||
max-width: 16em;
|
||||
border: 1px solid #000;
|
||||
margin: 1em auto 0.2em;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.screenshot.board {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.screenshot .screenshotWrapper {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@media (min-width: 420px) {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 2.08333333333333em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Yellow Lab Tools - Page Speed audit</title>
|
||||
<base href="<%= baseUrl %>">
|
||||
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<meta property="og:image" content="img/logo-large.png" />
|
||||
<meta name="description" content="Yellow Lab Tools is a free online web performance analyzer. It audits a webpage for performance and front-end quality issues. And it's open-source!" />
|
||||
|
||||
<!-- build:css css/styles.css-->
|
||||
<link rel="stylesheet" type="text/css" href="css/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/index.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/dashboard.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/queue.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/rule.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/screenshot.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/about.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="preconnect" href="https://www.google-analytics.com">
|
||||
<link rel="preconnect" href="https://ghbtns.com">
|
||||
<link rel="preconnect" href="https://api.github.com">
|
||||
|
||||
</head>
|
||||
|
||||
<body ng-app="YellowLabTools">
|
||||
<div id="header"><h1>Yellow Lab <svg width="32" height="32" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="#ffa319"><path d="M478 402L320 139V32h16c9 0 16-7 16-16s-7-16-16-16H176c-9 0-16 7-16 16s7 16 16 16h16v107L34 402c-36 61-8 110 62 110h320c70 0 98-49 62-110zm-357-82l103-172V32h64v116l103 172H121z"/></svg> Tools</h1></div>
|
||||
<div id="body" ng-view autoscroll="true"></div>
|
||||
<div class="footer">
|
||||
<span class="version" id="version">@@version</span>
|
||||
<br><a href="<%= baseUrl %>about">More about Yellow Lab Tools</a><br>
|
||||
<div class="github"><iframe id="ghbtn" frameborder="0" scrolling="0" width="160px" height="30px"></iframe></div>
|
||||
</div>
|
||||
|
||||
<!-- build:js js/all.js -->
|
||||
<script src="node_modules/angular/angular.min.js"></script>
|
||||
<script src="node_modules/chart.js/dist/Chart.min.js"></script>
|
||||
<script src="node_modules/angular-route/angular-route.min.js"></script>
|
||||
<script src="node_modules/angular-resource/angular-resource.min.js"></script>
|
||||
<script src="node_modules/angular-sanitize/angular-sanitize.min.js"></script>
|
||||
<script src="node_modules/angular-animate/angular-animate.min.js"></script>
|
||||
<script src="node_modules/angular-local-storage/dist/angular-local-storage.min.js"></script>
|
||||
<script src="node_modules/angular-chart.js/dist/angular-chart.min.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/controllers/indexCtrl.js"></script>
|
||||
<script src="js/controllers/dashboardCtrl.js"></script>
|
||||
<script src="js/controllers/queueCtrl.js"></script>
|
||||
<script src="js/controllers/ruleCtrl.js"></script>
|
||||
<script src="js/controllers/screenshotCtrl.js"></script>
|
||||
<script src="js/models/resultsFactory.js"></script>
|
||||
<script src="js/models/runsFactory.js"></script>
|
||||
<script src="js/services/apiService.js"></script>
|
||||
<script src="js/services/menuService.js"></script>
|
||||
<script src="js/services/settingsService.js"></script>
|
||||
|
||||
<script src="js/directives/gradeDirective.js"></script>
|
||||
<script src="js/directives/offendersDirectives.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
document.getElementById('version').innerHTML = "<%= version %>";
|
||||
if('<%= googleAnalyticsId %>'.indexOf('UA-')===0){(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');ga('create','<%= googleAnalyticsId %>','auto');}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="about">
|
||||
<p><b>Yellow Lab Tools</b> is an open source project by <a href="https://letstalkaboutwebperf.com/en/" target="_blank">Gaël Métais</a>. It allows you to test a webpage (via an URL) and detects <b>performance</b> and <b>front-end code quality</b> issues.</p>
|
||||
|
||||
<p>It is based on <a href="https://github.com/macbre/phantomas" target="_blank">Phantomas</a>, a tool that instruments Chrome Headless to collect dozens of metrics. These metrics are then categorized and transformed into scores. It also provides in-depth details so that developers can fix the detected issues.</p>
|
||||
|
||||
<p>By the way, <b>it's entirely free</b>. In return, you can add <a href="https://github.com/YellowLabTools/YellowLabTools" target="_blank">a <span>★</span> on GitHub</a> or <a href="https://ko-fi.com/gaelmetais" target="_blank">buy me a coffee</a>. It will boost my motivation to add more awesome features!</p>
|
||||
|
||||
<%if (sponsoring.about) { %>
|
||||
<div class="sponsor"><%- sponsoring.about %></div>
|
||||
<% } %>
|
||||
|
||||
<p><br><a href="<%= baseUrl %>">Back to index</a></p>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div ng-include="'views/resultSubHeader.html'"></div>
|
||||
<div class="summary board">
|
||||
|
||||
<div ng-if="result.blockedRequests">
|
||||
<b><ng-pluralize count="result.blockedRequests.length" when="{'0': 'No blocked request', 'one': '1 blocked request', 'other': '{} blocked requests'}"></ng-pluralize>:</b>
|
||||
<div ng-repeat="request in result.blockedRequests track by $index">
|
||||
{{request}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="globalScore" ng-if="globalScore === 0 || globalScore > 0">
|
||||
<div>
|
||||
<h2>Global score</h2>
|
||||
<div class="globalScoreDisplay">
|
||||
<grade score="result.scoreProfiles.generic.globalScore" class="globalGrade"></grade>
|
||||
<div class="on100">{{globalScore}}/100</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="result/{{result.runId}}/screenshot">
|
||||
<div class="screenshotWrapper" ng-class="result.params.options.device || 'phone'">
|
||||
<div>
|
||||
<img ng-if="result.screenshotUrl" class="screenshotImage" ng-src="{{result.screenshotUrl}}"/>
|
||||
<span ng-if="!result.screenshotUrl" class="screenshotError">Screenshot not available</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 ng-if="!error && !fromSocialShare">Score details</h2>
|
||||
<div ng-if="!error && !fromSocialShare" class="notations">
|
||||
<div ng-repeat="categoryKey in categoriesOrder" ng-init="category = result.scoreProfiles.generic.categories[categoryKey]">
|
||||
<grade score="category.categoryScore" class="categoryScore"></grade>
|
||||
<div class="category">{{category.label}}</div>
|
||||
<div class="criteria">
|
||||
<div class="table" title="Click to see details">
|
||||
<a ng-repeat="ruleName in category.rules" ng-if="result.rules[ruleName]" ng-init="rule = result.rules[ruleName]"
|
||||
ng-class="{'warning': rule.abnormal}" href="result/{{runId}}/rule/{{ruleName}}">
|
||||
<div class="grade">
|
||||
<grade score="rule.score"></grade>
|
||||
</div>
|
||||
<div class="label">{{rule.policy.label}}</div>
|
||||
<div class="result">
|
||||
<span ng-if="rule.policy.unit == 'bytes'">{{rule.value | bytes}}</span>
|
||||
<span ng-if="rule.policy.unit != 'bytes'">{{rule.value}} <span ng-if="rule.policy.unit"> {{rule.policy.unit}}</span></span>
|
||||
<span ng-if="rule.abnormal" class="icon-warning"><svg width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 79L84 448h344L256 79zm0-79c11 0 22 7 30 22l219 436c17 30 2 54-32 54H39c-34 0-49-24-32-54L226 22c8-15 19-22 30-22zm0 192c18 0 32 14 32 32l-10 96h-44l-10-96c0-18 14-32 32-32z"/><circle cx="256" cy="384" r="31" stroke="#000"/></svg></span>
|
||||
<span ng-if="rule.abnormalityScore <= -100" class="icon-warning"><svg width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 79L84 448h344L256 79zm0-79c11 0 22 7 30 22l219 436c17 30 2 54-32 54H39c-34 0-49-24-32-54L226 22c8-15 19-22 30-22zm0 192c18 0 32 14 32 32l-10 96h-44l-10-96c0-18 14-32 32-32z"/><circle cx="256" cy="384" r="31" stroke="#000"/></svg></span>
|
||||
<span ng-if="rule.abnormalityScore <= -300" class="icon-warning"><svg width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 79L84 448h344L256 79zm0-79c11 0 22 7 30 22l219 436c17 30 2 54-32 54H39c-34 0-49-24-32-54L226 22c8-15 19-22 30-22zm0 192c18 0 32 14 32 32l-10 96h-44l-10-96c0-18 14-32 32-32z"/><circle cx="256" cy="384" r="31" stroke="#000"/></svg></span>
|
||||
</div>
|
||||
<div class="info"><svg width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%if (sponsoring.wordpress) { %>
|
||||
<div ng-if="result.frameworks.isWordPress && !error" class="sponsor"><%- sponsoring.wordpress %></div>
|
||||
<% } %>
|
||||
|
||||
<%if (sponsoring.dashboard) { %>
|
||||
<div ng-if="!error" class="sponsor"><%- sponsoring.dashboard %></div>
|
||||
<% } %>
|
||||
|
||||
|
||||
|
||||
|
||||
<div ng-if="error">
|
||||
<h2>Run failed / Run not found</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<h2 class="promess">Online test to help speeding up <b>heavy</b> web pages</h2>
|
||||
<p class="price">Free and open source!</p>
|
||||
|
||||
<form ng-submit="launchTest()" >
|
||||
<input type="text" name="url" ng-model="url" placeholder="https://www.mysite.com" class="url" />
|
||||
<input type="submit" value="Launch test" class="launchBtn" ng-class="{disabled: !url}" />
|
||||
<div class="settings">
|
||||
<div class="device">
|
||||
<div>Choose the simulated device:</div>
|
||||
<a href="" class="item" ng-class="{active: settings.device == 'phone'}" ng-click="settings.device = 'phone'"><svg width="38" height="38" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M368 0H144c-26 0-48 22-48 48v416c0 26 22 48 48 48h224c26 0 48-22 48-48V48c0-26-22-48-48-48zM192 24h128v16H192V24zm64 456a32 32 0 110-64 32 32 0 010 64zm128-96H128V64h256v320z"/></svg>Phone</a>
|
||||
<a href="" class="item" ng-class="{active: settings.device == 'tablet'}" ng-click="settings.device = 'tablet'"><svg width="38" height="38" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M400 0H80C54 0 32 22 32 48v416c0 26 22 48 48 48h320c26 0 48-22 48-48V48c0-26-22-48-48-48zM240 496a16 16 0 110-32 16 16 0 010 32zm144-48H96V64h288v384z"/></svg>Tablet</a>
|
||||
<a href="" class="item" ng-class="{active: settings.device == 'desktop'}" ng-click="settings.device = 'desktop'"><svg width="38" height="38" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M512 416V32H0v384h224v32h-96v32h256v-32h-96v-32h224zM64 96h384v256H64V96z"/></svg>Desktop</a>
|
||||
<a href="" class="item" ng-class="{active: settings.device == 'desktop-hd'}" ng-click="settings.device = 'desktop-hd'"><svg width="38" height="38" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M512 416V32H0v384h224v32h-96v32h256v-32h-96v-32zM64 96h384v256H64z"/><path d="M270 297V161h28c14 0 25 2 33 4 8 3 16 7 23 14 14 13 21 29 21 50s-7 38-22 51c-7 6-15 11-23 13-8 3-18 4-32 4zm20-19h10c9 0 16-1 23-3a47 47 0 0031-46c0-15-5-27-15-36-9-8-22-12-39-12h-10zm-123-64h59v-53h20v136h-20v-63h-59v63h-20V161h20z"/></svg>Desktop</a>
|
||||
</div>
|
||||
[ <a href="" class="showAdvanced" ng-click="settings.showAdvanced = !settings.showAdvanced">
|
||||
<span ng-if="!settings.showAdvanced">Advanced settings ✚</span>
|
||||
<span ng-if="settings.showAdvanced">Hide advanced settings ✖</span>
|
||||
</a> ]
|
||||
<span class="currentSettings" ng-if="!settings.showAdvanced && (settings.waitForSelector || settings.cookie || settings.authUser || settings.authPass || settings.proxy || settings.domains)">
|
||||
Currently set:
|
||||
<span ng-if="settings.waitForSelector">wait for selector</span>
|
||||
<span ng-if="settings.cookie">cookie</span>
|
||||
<span ng-if="settings.authUser || settings.authPass">authentication</span>
|
||||
<span ng-if="settings.proxy">proxy</span>
|
||||
<span ng-if="settings.domains">domain blocking</span>
|
||||
</span>
|
||||
<div class="advanced" ng-show="settings.showAdvanced">
|
||||
<!--<div>
|
||||
<div class="label">
|
||||
Wait selector
|
||||
<span class="settingsTooltip">
|
||||
<svg width="14" height="14" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path fill="#FFF" d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg>
|
||||
<div><b>Wait for a CSS selector</b><br><br>Once the page is considered loaded, PhantomJS will repeatedly try to match the given CSS selector until it is found in the page. A 60 seconds timeout still applies anyway.<br><br>Example: "body.loaded"</div>
|
||||
</span>
|
||||
</div>
|
||||
<div><input type="text" name="waitForSelector" ng-model="settings.waitForSelector" /></div>
|
||||
</div>-->
|
||||
<div>
|
||||
<div class="label">
|
||||
Cookie
|
||||
<span class="settingsTooltip">
|
||||
<svg width="14" height="14" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path fill="#FFF" d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg>
|
||||
<div><b>Cookie</b><br><br>Adds cookies, separated by a pipe character.<br><br>Example: "bar1=foo1;domain=.domain1.com|bar2=foo2;domain=www.domain2.com"</div>
|
||||
</span>
|
||||
</div>
|
||||
<div><input type="text" name="cookie" ng-model="settings.cookie" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">
|
||||
Authent
|
||||
<span class="settingsTooltip">
|
||||
<svg width="14" height="14" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path fill="#FFF" d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg>
|
||||
<div><b>Basic HTTP authentication</b><br><br>Enter your credentials here if you need to bypass a basic authentication.<br><br><i>PS: if your authentication is not basic, you might be able to copy the session cookie from your browser, paste it in the "Cookie" setting and launch a run before your cookie expires.</i></div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="subTable">
|
||||
<div>
|
||||
<div>username</div>
|
||||
<div><input type="text" class="authField" name="authUser" ng-model="settings.authUser" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><span>password</div>
|
||||
<div><input type="password" class="authField" name="authPass" ng-model="settings.authPass" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">
|
||||
HTTP proxy
|
||||
<span class="settingsTooltip">
|
||||
<svg width="14" height="14" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path fill="#FFF" d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg>
|
||||
<div><b>HTTP proxy</b><br><br>Insert here your proxy settings with the format "host:port".<br><br>Example: "192.168.10.0:3333"</div>
|
||||
</span>
|
||||
</div>
|
||||
<div><input type="text" name="proxy" ng-model="settings.proxy" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">
|
||||
Block domains
|
||||
<span class="settingsTooltip">
|
||||
<svg width="14" height="14" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path fill="#FFF" d="M224 352h64v64h-64zm128-224c18 0 32 14 32 32v96l-96 64h-64v-32l96-64v-32H160v-64h192zm-96-80A207 207 0 0048 256a207 207 0 00208 208 207 207 0 00208-208A207 207 0 00256 48zm0-48a256 256 0 110 512 256 256 0 010-512z"/></svg>
|
||||
<div><b>Block some domains</b><br><br>One line per domain or subdomain.<br><br><i><b>Example:</b><br>google-analytics.com<br>ads.yahoo.com<br>ajax.googleapis.com</i><br><br>An empty allow list will block all domains except the main domain.</div>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<input type="radio" name="blockOrAllow" ng-model="settings.domainsBlockOrAllow" value="block" />block list
|
||||
<input type="radio" name="blockOrAllow" ng-model="settings.domainsBlockOrAllow" value="allow" />allow list
|
||||
</div>
|
||||
<textarea name="domains" ng-model="settings.domains" rows="5"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<div class="features">
|
||||
<div>
|
||||
<h3>Page speed audit</h3>
|
||||
<p>Checks if performance good practices are respected</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Front-end analyzis</h3>
|
||||
<p>Detects problems on HTML, CSS, JS, images, fonts and more</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>In-depth details</h3>
|
||||
<p>Provides precise information to fix the detected performance issues</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%if (sponsoring.home) { %>
|
||||
<div class="homeSponsor"><%- sponsoring.home %></div>
|
||||
<% } %>
|
||||
@@ -0,0 +1,46 @@
|
||||
<p ng-if="url">Tested url: <a href="{{url}}" target="_blank" class="testedUrl">{{url}}</a></p>
|
||||
|
||||
<div ng-if="!notFound && !connectionLost">
|
||||
<div ng-if="status.statusCode == 'failed'">
|
||||
<div class="status">Test failed</div>
|
||||
<p class="statusSubMessage">{{status.error}}</p>
|
||||
|
||||
<a class="linkButton" href="https://github.com/YellowLabTools/YellowLabTools/issues" target="_blank">Report the issue on GitHub</a>
|
||||
<a class="linkButton" href="<%= baseUrl %>">New test</a>
|
||||
</div>
|
||||
<div ng-if="status.statusCode == 'awaiting'">
|
||||
<div class="status">
|
||||
<ng-pluralize count="status.position" when="{'one': 'Waiting behind 1 other test', 'other': 'Waiting behind {} other tests'}">
|
||||
</ng-pluralize>
|
||||
</div>
|
||||
<p class="statusSubMessage">(auto-refresh activated)</p>
|
||||
</div>
|
||||
<div ng-if="status.statusCode == 'running'">
|
||||
<div class="status">Test is running...</div>
|
||||
<div class="progress">
|
||||
<div class="progressBarEmpty">
|
||||
<div class="progressBarFilled" ng-style="{'width': (progress.estimatedProgress*100) + '%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="statusSubMessage" ng-if="!progress">(Phantomas launched)</p>
|
||||
<p class="statusSubMessage" ng-if="progress.milestone == 'domReady'">(DOM Ready fired)</p>
|
||||
<p class="statusSubMessage" ng-if="progress.milestone == 'domComplete'">(page loaded, waiting for late requests)</p>
|
||||
<p class="statusSubMessage" ng-if="progress.milestone == 'phantomas'">(now simulating compression, optimization and minification)</p>
|
||||
<p class="statusSubMessage" ng-if="progress.milestone == 'redownload'">(calculating score and retrieving screenshot)</p>
|
||||
</div>
|
||||
<div ng-if="status.statusCode == 'complete'">
|
||||
<div class="status">Test complete</div>
|
||||
<p class="statusSubMessage">Opening results...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="notFound == true">
|
||||
<div class="status">Error 404 (test not found)</div>
|
||||
<p class="statusSubMessage">The server probably just rebooted. We are very sorry about that, please try to launch the test again.</p>
|
||||
|
||||
<a class="linkButton" href="<%= baseUrl %>">New test</a>
|
||||
</div>
|
||||
<div ng-if="connectionLost == true">
|
||||
<div class="status">Connection lost with server</div>
|
||||
<p class="statusSubMessage">Check your wifi cable, or maybe YellowLab.tools is rebooting.</p>
|
||||
</div>
|
||||
<canvas id="faviconRotator" hidden width=32 height=32></canvas>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div>Tested url: <a href="{{result.params.url}}" target="_blank" class="testedUrl">{{result.params.url}}</a></div>
|
||||
|
||||
<div class="resultsMenu">
|
||||
<a class="menuItem back" href="<%= baseUrl %>"><svg width="48" height="48" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 0a256 256 0 110 512 256 256 0 010-512zm0 464a208 208 0 100-416 208 208 0 000 416zM105 233l128-128a32 32 0 1146 46l-74 73h179a32 32 0 010 64H205l74 73a32 32 0 01-46 46L105 279a32 32 0 010-46z"/></svg><span>New test<span></a>
|
||||
<a class="menuItem restart" href="" ng-click="testAgain()"><svg width="48" height="48" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M437 75a255 255 0 00-421 91l60 23a192 192 0 01316-69l-72 72h192V0l-75 75zM256 448c-53 0-101-21-136-56l72-72H0v192l75-75a255 255 0 00421-91l-60-23c-27 73-98 125-180 125z"/></svg><span>Test again<span></a>
|
||||
<div class="menuItem" ng-class="{active: Menu.getCurrentPage() == 'dashboard'}" ng-click="Menu.changePage('dashboard')"><svg width="48" height="48" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h128v128H0zm192 32h320v64H192zM0 192h128v128H0zm192 32h320v64H192zM0 384h128v128H0zm192 32h320v64H192z"/></svg><span>Dashboard</span></div>
|
||||
</div>
|
||||
@@ -0,0 +1,514 @@
|
||||
<div ng-include="'views/resultSubHeader.html'"></div>
|
||||
<div class="rule board">
|
||||
<div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>
|
||||
|
||||
<div ng-if="rule" class="ruleTable">
|
||||
<div class="left">
|
||||
<h2>{{rule.policy.label}}</h2>
|
||||
<grade score="rule.score" class="score"></grade>
|
||||
<div>{{rule.score}}/100</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>
|
||||
Value:
|
||||
<span ng-if="rule.policy.unit == 'bytes'">{{rule.value | bytes}}</span>
|
||||
<span ng-if="rule.policy.unit != 'bytes'">{{rule.value}}<span ng-if="rule.policy.unit"> {{rule.policy.unit}}</span></span>
|
||||
</h3>
|
||||
<div class="okThreshold" ng-if="rule.score < 100 && rule.policy.isOkThreshold !== undefined">
|
||||
Have
|
||||
<span ng-if="rule.policy.unit == 'bytes'">{{rule.policy.isOkThreshold | bytes}}</span>
|
||||
<span ng-if="rule.policy.unit != 'bytes'">{{rule.policy.isOkThreshold}}<span ng-if="rule.policy.unit"> {{rule.policy.unit}}</span></span>
|
||||
<span ng-if="rule.policy.isOkThreshold > 0 && rule.policy.isOkThreshold < rule.policy.isBadThreshold">or less</span>
|
||||
<span ng-if="rule.policy.isOkThreshold > rule.policy.isBadThreshold">or more</span>
|
||||
to get the 100/100 score on this issue.
|
||||
</div>
|
||||
<div class="okThreshold" ng-if="rule.globalScoreIfFixed > result.scoreProfiles.generic.globalScore && rule.globalScoreIfFixed > 0 && result.scoreProfiles.generic.globalScore >= 0">
|
||||
Your new global score would increase by {{rule.globalScoreIfFixed - result.scoreProfiles.generic.globalScore}} points ({{rule.globalScoreIfFixed}}/100).
|
||||
</div>
|
||||
<div class="okThreshold" ng-if="rule.globalScoreIfFixed > result.scoreProfiles.generic.globalScore && rule.globalScoreIfFixed > 0 && result.scoreProfiles.generic.globalScore < 0">
|
||||
Your new global score would increase by {{rule.globalScoreIfFixed}} points ({{rule.globalScoreIfFixed}}/100).
|
||||
</div>
|
||||
<div class="okThreshold" ng-if="rule.globalScoreIfFixed > result.scoreProfiles.generic.globalScore && rule.globalScoreIfFixed <= 0">
|
||||
Your new global score would increase, but still not enough to reach 0/100. That's embarassing...
|
||||
</div>
|
||||
<div class="okThreshold" ng-if="rule.globalScoreIfFixed == result.scoreProfiles.generic.globalScore && rule.score < 100">
|
||||
Your new global score would slightly increase, but not enough to gain a single point.
|
||||
</div>
|
||||
<div ng-bind-html="rule.policy.message" class="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="rule.abnormal" class="warning">
|
||||
<h3>Warning</h3>
|
||||
<p>This rule reached the abnormality threshold, which means there is a real problem you should care about.</p>
|
||||
</div>
|
||||
<div class="offenders" ng-if="rule.policy.hasOffenders">
|
||||
<h3 ng-if="rule.offendersObj.count >= 0"><ng-pluralize count="rule.offendersObj.count" when="{'0': 'No offender', 'one': '1 offender', 'other': '{} offenders'}"></ng-pluralize></h3>
|
||||
|
||||
<div ng-if="rule.offendersObj.list" class="offendersTable">
|
||||
<div ng-repeat="offender in rule.offendersObj.list track by $index">
|
||||
<div ng-if="offender.parseError">
|
||||
{{offender.parseError}}
|
||||
</div>
|
||||
<div ng-if="!offender.parseError">
|
||||
|
||||
<div ng-if="policyName === 'iframesCount'">
|
||||
<span ng-if="offender.url">{{offender.url}}</span>
|
||||
<span ng-if="!offender.url">an iframe without URL</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'DOMidDuplicated'">
|
||||
<b>{{offender.id}}</b>: {{offender.count}} occurrences
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'DOMqueriesAvoidable'">
|
||||
<b>{{offender.query}}</b> (in <dom-element-button obj="offender.context"></dom-element-button>) using {{offender.fn}}: <b>{{offender.count}} queries</b>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'eventsScrollBound'">
|
||||
<span ng-if="offender.target == 'window'">Scroll event bound on <b>window</b></span>
|
||||
<span ng-if="offender.target == '#document'">Scroll event bound on <b>document</b></span>
|
||||
<span ng-if="offender.target == 'window.onscroll'"><b>window.onscroll</b> function declared</span>
|
||||
<div class="offenderButton" ng-if="offender.backtrace.length == 0">no backtrace</div>
|
||||
<div class="offenderButton opens" ng-if="offender.backtrace.length > 0">
|
||||
backtrace
|
||||
<div class="backtrace">
|
||||
<div ng-repeat="obj in offender.backtrace track by $index">
|
||||
<span ng-if="obj.functionName">{{obj.functionName}}()</span>
|
||||
<url-link url="obj.file" max-length="60"></url-link>
|
||||
line {{obj.line}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'jsErrors'">
|
||||
<b>{{offender.error}}</b>
|
||||
<div class="offenderButton" ng-if="offender.backtrace.length == 0">no backtrace</div>
|
||||
<div class="offenderButton opens" ng-if="offender.backtrace.length > 0">
|
||||
backtrace
|
||||
<div class="backtrace">
|
||||
<div ng-repeat="obj in offender.backtrace track by $index">
|
||||
<span ng-if="obj.functionName">{{obj.functionName}}()</span>
|
||||
<url-link url="obj.file" max-length="60"></url-link>
|
||||
line {{obj.line}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'jQueryFunctionsUsed'">
|
||||
function <b>{{offender.functionName}}</b> used {{offender.count}} times
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'documentWriteCalls'">
|
||||
<b>{{offender.writeFn}}</b>
|
||||
<span ng-if="offender.from">
|
||||
called from
|
||||
<span ng-if="offender.from.functionName">{{offender.from.functionName}}()</span>
|
||||
<url-link url="offender.from.file" max-length="50"></url-link>
|
||||
line {{offender.from.line}}
|
||||
</span>
|
||||
<span ng-if="!offender.from">
|
||||
called from (no backtrace available)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssRules'">
|
||||
<span ng-if="offender.url === '[inline CSS]'">inline CSS</span>
|
||||
<span ng-if="offender.url !== '[inline CSS]'"><url-link url="offender.url" max-length="80"></url-link></span>
|
||||
: <ng-pluralize count="offender.value" when="{'0': '0 rule', 'one':'1 rule','other':'{} rules'}"></ng-pluralize>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'similarColors'">
|
||||
<div class="similarColors checker"><div ng-style="{'background-color': offender.color1, 'color': offender.isDark ? '#FFF' : '#000'}">{{offender.color1}}</div><div ng-style="{'background-color': offender.color2, 'color': offender.isDark ? '#FFF' : '#000'}">{{offender.color2}}</div></div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssParsingErrors'">
|
||||
<b>{{offender.error}}</b>
|
||||
<file-and-line file="offender.file" line="offender.line" column="offender.column"></file-and-line>
|
||||
<span ng-if="offender.file">(<a href="http://jigsaw.w3.org/css-validator/validator?profile=css3&usermedium=all&warning=no&uri={{offender.file | encodeURIComponent}}" target="_blank">Check on the W3C validator</a>)</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssImports'">
|
||||
{{offender.css}}
|
||||
<file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssOldPropertyPrefixes'">
|
||||
<b>{{offender.property}}</b> {{offender.message}}
|
||||
<div ng-if="offender.rules.length" ng-click="offender.showMore = !offender.showMore" class="offenderButton">
|
||||
<span ng-if="!offender.showMore">show</span>
|
||||
<span ng-if="offender.showMore">hide</span>
|
||||
<ng-pluralize count="offender.rules.length" when="{'one':'1 offender','other':'{} offenders'}"></ng-pluralize>
|
||||
</div>
|
||||
<div ng-if="offender.showMore" class="smallerOffenders">
|
||||
<div ng-repeat="cssRule in offender.rules">
|
||||
{{cssRule.rule}} {{'{' + offender.property}}: {{cssRule.value + '}' }}
|
||||
<file-and-line-button file="cssRule.file" line="cssRule.line" column="cssRule.column"></file-and-line-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'lazyLoadableImagesBelowTheFold'">
|
||||
<img ng-src="{{offender.url | https}}" class="smallPreview checker"></img>
|
||||
<url-link url="offender.url" max-length="70"></url-link> (offset: {{offender.offset | roundNbr}}px)
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'hiddenImages'">
|
||||
<img ng-src="{{offender | https}}" class="smallPreview checker"></img>
|
||||
<url-link url="offender" max-length="100"></url-link>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'imagesTooLarge'">
|
||||
<img ng-src="{{offender.url | https}}" class="smallPreview checker"></img>
|
||||
<div>{{offender.width}}x{{offender.height}}</div>
|
||||
<url-link url="offender.url" max-length="100"></url-link>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'notFound' || policyName === 'emptyRequests' || policyName === 'closedConnections' || policyName === 'multipleRequests' || policyName === 'cachingDisabled' || policyName === 'cachingNotSpecified'">
|
||||
<url-link url="offender" max-length="100"></url-link>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cachingTooShort'">
|
||||
<url-link url="offender.url" max-length="100"></url-link>
|
||||
cached for <b>{{offender.ttlWithUnit}} {{offender.unit}}</b>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'domains'">
|
||||
<b>{{offender.domain}}</b>
|
||||
(<ng-pluralize count="offender.requests" when="{'one':'1 request','other':'{} requests'}"></ng-pluralize>)
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'globalVariables'">
|
||||
{{offender}}
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'jQueryVersionsLoaded'">
|
||||
{{offender.version}}
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'synchronousXHR'">
|
||||
{{offender.url}}
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'fontsCount'">
|
||||
<url-link url="offender.url" max-length="70"></url-link>
|
||||
({{offender.size | bytes}})
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'oldHttpProtocol'">
|
||||
<b>{{offender.domain}}</b> sends <span ng-class="offender.requests > 4 ? 'offenderProblem' : ''"><b><ng-pluralize count="offender.requests" when="{'one':'1 request','other':'{} requests'}"></ng-pluralize></b></span> over {{offender.httpVersion}}
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'oldTlsProtocol'">
|
||||
<b>{{offender.domain}}</b> uses {{offender.tlsVersion}} <span ng-if="offender.beforeDomReady === true" class="offenderProblem">and seems to be on the critical path</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="fileDetails in rule.offendersObj.byFile track by $index">
|
||||
<h3>
|
||||
<ng-pluralize count="fileDetails.count" when="{'one': '1 offender', 'other': '{} offenders'}"></ng-pluralize>
|
||||
in
|
||||
<url-link ng-if="fileDetails.url !== 'Inline CSS' && fileDetails.url !== '[inline CSS]'" url="fileDetails.url" max-length="80"></url-link>
|
||||
<span ng-if="fileDetails.url === 'Inline CSS' || fileDetails.url === '[inline CSS]'">inline CSS</span>
|
||||
</h3>
|
||||
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="offender in fileDetails.offenders track by $index">
|
||||
<div ng-if="policyName === 'cssComplexSelectors' || policyName === 'cssComplexSelectorsByAttribute' || policyName === 'cssUniversalSelectors' || policyName === 'cssRedundantBodySelectors' || policyName === 'cssRedundantChildNodesSelectors'">
|
||||
<span ng-if="offender.bolded" ng-bind-html="offender.bolded"></span>
|
||||
<b ng-if="!offender.bolded">{{offender.css}}</b>
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssMobileFirst'">
|
||||
<b>{{offender.query}}</b> for <ng-pluralize count="offender.rules" when="{'one':'1 rule','other':'{} rules'}"></ng-pluralize>
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssDuplicatedSelectors'">
|
||||
{{offender.rule}} (<b>x{{offender.occurrences}}</b>)
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssDuplicatedProperties'">
|
||||
Property <b>{{offender.property}}</b> duplicated in <b>{{offender.rule}} { }</b>
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssEmptyRules'">
|
||||
<b>{{offender.css}} { }</b>
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssImportants'">
|
||||
{{offender.rule}} {{ '{' + offender.property}}: {{offender.value}} <b>!important</b>}
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssOldIEFixes'">
|
||||
<span ng-if="offender.browser"><b>{{offender.browser}} fix:</b></span>
|
||||
<span ng-bind-html="offender.bolded"></span>
|
||||
<span ng-if="offender.line !== null && offender.column !== null"> @ {{offender.line}}:{{offender.column}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="!rule.offendersObj.list && !rule.offendersObj.byFile" class="offendersHtml">
|
||||
|
||||
<div ng-if="policyName === 'DOMelementMaxDepth'">
|
||||
<dom-tree tree="rule.offendersObj.tree"></dom-tree>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssColors' && rule.offendersObj.count > 0">
|
||||
<p>This is the colors palette, sized by total occurrences:</p>
|
||||
<div class="colorPalette checker">
|
||||
<div ng-repeat="offender in rule.offendersObj.palette" style="background-color: {{offender.color}}; width: {{offender.occurrences * 100 / rule.offendersObj.palette[0].occurrences}}%"><div>{{offender.color}} ({{offender.occurrences}} times)</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'totalWeight'">
|
||||
<h3>Weight by MIME type</h3>
|
||||
<div class="totalWeightPie">
|
||||
<canvas class="chart chart-doughnut" chart-data="weightData" chart-labels="weightLabels" chart-options="weightOptions" chart-colors="weightColours"></canvas>
|
||||
</div>
|
||||
<div ng-repeat="type in weightLabels">
|
||||
<h3>{{rule.offendersObj.list.byType[type].totalWeight | bytes}} of {{type}}</h3>
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="request in rule.offendersObj.list.byType[type].requests | orderBy:'-weight'" ng-if="request.weight > 0">
|
||||
<div><url-link url="request.url" max-length="60"></url-link></div>
|
||||
<div ng-class="{offenderProblem: request.weight > 102400}">{{request.weight | bytes}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'DOMaccesses'">
|
||||
<div ng-repeat="(type, list) in rule.offendersObj.list.byType">
|
||||
<h3>
|
||||
<ng-pluralize count="list.length" when="{'0': 'No offender', 'one': '1 offender', 'other': '{} offenders'}"></ng-pluralize> from
|
||||
<span ng-if="type === 'DOMqueriesById'">getElementById()</span>
|
||||
<span ng-if="type === 'DOMqueriesByTagName'">getElementsByTagName()</span>
|
||||
<span ng-if="type === 'DOMqueriesByClassName'">getElementsByClassName()</span>
|
||||
<span ng-if="type === 'DOMqueriesByQuerySelectorAll'">querySelector() or querySelectorAll()</span>
|
||||
<span ng-if="type === 'DOMinserts'">appendChild() or insertBefore()</span>
|
||||
<span ng-if="type === 'DOMmutationsInserts'">added nodes</span>
|
||||
<span ng-if="type === 'DOMmutationsRemoves'">removed nodes</span>
|
||||
<span ng-if="type === 'DOMmutationsAttributes'">attribute changes</span>
|
||||
<span ng-if="type === 'eventsBound'">addEventListener()</span>
|
||||
</h3>
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="access in list">
|
||||
<div ng-if="type === 'DOMqueriesById'">#{{access.id}}</div>
|
||||
<div ng-if="type === 'DOMqueriesByTagName'">{{access.tag}} <b>on</b> <span title="{{access.node}}">{{access.node | lastDOMNode}}</span></div>
|
||||
<div ng-if="type === 'DOMqueriesByClassName'">.{{access.class}} <b>on</b> <span title="{{access.node}}">{{access.node | lastDOMNode}}</span></div>
|
||||
<div ng-if="type === 'DOMqueriesByQuerySelectorAll'">{{access.selector}} <b>on</b> <span title="{{access.node}}">{{access.node | lastDOMNode}}</span></div>
|
||||
<div ng-if="type === 'DOMinserts'"><span title="{{access.append}}">{{access.append | lastDOMNode}}</span> <b>added to</b> <span title="{{access.node}}">{{access.node | lastDOMNode}}</span></div>
|
||||
<div ng-if="type === 'DOMmutationsInserts'">{{access.node}} <b>added to</b> {{access.target}}</div>
|
||||
<div ng-if="type === 'DOMmutationsRemoves'">{{access.node}} <b>removed from</b> {{access.target}}</div>
|
||||
<div ng-if="type === 'DOMmutationsAttributes'">{{access.attribute}} <b>changed on</b> {{access.node}}</div>
|
||||
<div ng-if="type === 'eventsBound'">{{access.eventType}} <b>on</b> <span title="{{access.path}}">{{access.path | lastDOMNode}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'imageOptimization'">
|
||||
<h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.images.length" when="{'one': '1 image', 'other': '{} images'}"></ng-pluralize></h3>
|
||||
<div class="imageOffenders">
|
||||
<div ng-repeat="image in rule.offendersObj.list.images | orderBy:'-gain'">
|
||||
<div>
|
||||
Current file: <url-link url="image.url" max-length="50"></url-link>
|
||||
<div><a href="{{image.url}}" target="_blank"><img ng-src="{{image.url | https}}" class="checker" /></a></div>
|
||||
</div>
|
||||
<div>
|
||||
<p ng-if="!image.isCompressible || image.isCompressed">Current weight: {{image.originalWeigth | bytes}}</p>
|
||||
<p ng-if="image.isCompressible && !image.isCompressed">Current weight: {{image.originalWeigth | bytes}} ({{image.originalCompressedWeight | bytes}} compressed)</p>
|
||||
|
||||
<p ng-if="image.lossless && image.isCompressible">With a lossless optimization:<br/>{{image.afterOptimizationAndCompression | bytes}} compressed (<b>-{{image.gain | bytes}}</b> compressed)</p>
|
||||
<p ng-if="image.lossless && !image.isCompressible">With a lossless optimization:<br/>{{image.lossless | bytes}} <span ng-if="!image.lossy">(<b>-{{image.gain | bytes}}</b>)</span></p>
|
||||
|
||||
<p ng-if="image.lossy && image.isCompressible">With a lossy optimization:<br/>{{image.afterOptimizationAndCompression | bytes}} compressed (<b>-{{image.gain | bytes}} compressed</b>)</p>
|
||||
<p ng-if="image.lossy && !image.isCompressible">With a lossy optimization:<br/>{{image.lossy | bytes}} (<b>-{{image.gain | bytes}}</b>)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'compression'">
|
||||
<h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.files.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
|
||||
<div class="table">
|
||||
<div class="headers">
|
||||
<div>File</div>
|
||||
<div>Current weight</div>
|
||||
<div>Gzip weight</div>
|
||||
<div>Brotli</div>
|
||||
<div>Gain</div>
|
||||
</div>
|
||||
<div ng-repeat="file in rule.offendersObj.list.files | orderBy:'-gain'">
|
||||
<div>
|
||||
<url-link url="file.url" max-length="60"></url-link>
|
||||
</div>
|
||||
<div>{{file.originalSize | bytes}}</div>
|
||||
|
||||
<div ng-if="file.wasCompressed"><i>already gzipped</i></div>
|
||||
<div ng-if="!file.wasCompressed">{{file.gzipped | bytes}}</div>
|
||||
|
||||
<div>{{file.brotlified | bytes}}</div>
|
||||
|
||||
<div><b>-{{file.gain | bytes}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'fileMinification'">
|
||||
<h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.files.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
|
||||
<div class="table">
|
||||
<div class="headers">
|
||||
<div>File</div>
|
||||
<div>Current weight</div>
|
||||
<div>Minified</div>
|
||||
<div>Gain</div>
|
||||
</div>
|
||||
<div ng-repeat="file in rule.offendersObj.list.files | orderBy:'-gain'">
|
||||
<div>
|
||||
<url-link url="file.url" max-length="60"></url-link>
|
||||
</div>
|
||||
<div ng-if="file.isCompressed">{{file.originalWeigth | bytes}} (compressed)</div>
|
||||
<div ng-if="!file.isCompressed">{{file.originalWeigth | bytes}} ({{file.originalCompressedWeight | bytes}} compressed)</div>
|
||||
<div ng-if="file.isCompressed">{{file.afterOptimizationAndCompression | bytes}} (compressed)</div>
|
||||
<div ng-if="!file.isCompressed">{{file.optimized | bytes}} ({{file.afterOptimizationAndCompression | bytes}} compressed)</div>
|
||||
<div><b>-{{file.gain | bytes}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'totalRequests'">
|
||||
<h3>Requests by MIME type</h3>
|
||||
<div ng-repeat="(type, requests) in rule.offendersObj.list.byType">
|
||||
<h3><ng-pluralize count="requests.length" when="{'0': 'No ' + type + ' request', 'one': '1 ' + type + ' request', 'other': '{} ' + type + ' requests'}"></ng-pluralize></h3>
|
||||
<p ng-if="type == 'css' && requests.length > 2">Reduce the number of stylesheets by concatenating them.</p>
|
||||
<p ng-if="type == 'js' && requests.length > 3">Reduce the number of scripts by concatenating them.</p>
|
||||
<p ng-if="type == 'image' && requests.length > 5">Reduce the number of images by lazyloading them or by spriting them.</p>
|
||||
<p ng-if="type == 'webfont' && requests.length > 1">Fonts are generally loaded on the critical path of the head. Load as few as possible.</p>
|
||||
<p ng-if="type == 'other' && requests.length > 0">They can be Flash, XML, music or any undetected format.</p>
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="request in requests track by $index">
|
||||
<div><url-link url="request" max-length="100"></url-link></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'identicalFiles'">
|
||||
<div ng-repeat="offender in rule.offendersObj.list track by $index">
|
||||
<h4>A file of {{offender.weight | bytes}} is loaded {{offender.urls.length}} times:</h4>
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="url in offender.urls">
|
||||
<div><url-link url="url" max-length="100"></url-link></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'cssBreakpoints'">
|
||||
<div ng-if="rule.value > 0">
|
||||
<h3>Breakpoints list</h3>
|
||||
<div class="offendersTable">
|
||||
<div ng-repeat="offender in rule.offendersObj | orderBy:'pixels'">
|
||||
<div>Breakpoint <b>{{offender.breakpoint}}</b> involves <ng-pluralize count="offender.count" when="{'one': '1 rule', 'other': '{} rules'}"></ng-pluralize></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="rule.value === 0">
|
||||
No breakpoint
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'heavyFonts'">
|
||||
<div ng-repeat="font in rule.offendersObj.fonts | orderBy:'-weight' track by $index">
|
||||
<h3><url-link url="font.url" max-length="80"></url-link></h3>
|
||||
<div class="offendersTable">
|
||||
<div>
|
||||
<div>Weight</div>
|
||||
<div ng-if="font.weight <= 40960">{{font.weight | bytes}}</div>
|
||||
<div ng-if="font.weight > 40960" class="offenderProblem">{{font.weight | bytes}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Number of glyphs</div>
|
||||
<div ng-if="font.numGlyphs <= 500">{{font.numGlyphs}}</div>
|
||||
<div ng-if="font.numGlyphs > 500" class="offenderProblem">{{font.numGlyphs}} (better < 500)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Average glyph complexity</div>
|
||||
<div ng-if="font.averageGlyphComplexity <= 35">{{font.averageGlyphComplexity}}</div>
|
||||
<div ng-if="font.averageGlyphComplexity > 35" class="offenderProblem">{{font.averageGlyphComplexity}} (better < 35)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'unusedUnicodeRanges'">
|
||||
<div ng-repeat="font in rule.offendersObj.fonts | orderBy:'-compressedWeigth' track by $index">
|
||||
<h3><url-link url="font.url" max-length="60"></url-link> ({{font.weight | bytes}})</h3>
|
||||
<div ng-if="font.isIconFont" class="offendersTable">
|
||||
<div>
|
||||
<div>
|
||||
This font seems to be an icon font
|
||||
<span ng-if="font.numGlyphsInCommonWithPageContent / font.glyphs <= 0.05" class="offenderProblem">but only {{font.numGlyphsInCommonWithPageContent}} of its {{font.glyphs}} glyphs <ng-pluralize count="font.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used!</span>
|
||||
<span ng-if="font.numGlyphsInCommonWithPageContent / font.glyphs > 0.05">and {{font.numGlyphsInCommonWithPageContent}} of its {{font.glyphs}} glyphs <ng-pluralize count="font.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="!font.isIconFont" class="offendersTable">
|
||||
<div ng-repeat="range in font.unicodeRanges track by $index">
|
||||
<div><b>{{range.name}}</b></div>
|
||||
<div ng-if="!range.underused">{{range.numGlyphsInCommonWithPageContent}} of its {{range.charset.length}} glyphs <ng-pluralize count="range.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used</div>
|
||||
<div ng-if="range.underused" class="offenderProblem">{{range.numGlyphsInCommonWithPageContent}} of its {{range.charset.length}} glyphs are used</div>
|
||||
<div>
|
||||
<div class="offenderButton opens">
|
||||
glyphes list
|
||||
<div>{{range.charset | addSpaces}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="font.ligaturesOrHiddenChars > 0">
|
||||
<div><b>Ligatures or hidden chars</b></div>
|
||||
<div ng-class="{offenderProblem: (font.ligaturesOrHiddenChars > 25)}">{{font.ligaturesOrHiddenChars}} glyphs</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="policyName === 'nonWoff2Fonts'">
|
||||
<h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.fonts.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
|
||||
<div class="table">
|
||||
<div class="headers">
|
||||
<div>File</div>
|
||||
<div>Current weight</div>
|
||||
<div>WOFF 2 weight</div>
|
||||
<div>Gain</div>
|
||||
</div>
|
||||
<div ng-repeat="file in rule.offendersObj.list.fonts | orderBy:'-gain'">
|
||||
<div>
|
||||
<url-link url="file.url" max-length="70"></url-link>
|
||||
</div>
|
||||
<div>{{file.originalSize | bytes}}</div>
|
||||
<div>{{file.woff2Size | bytes}}</div>
|
||||
<div><b>-{{file.gain | bytes}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="!rule && rule !== null" class="notFound">
|
||||
<h2>404</h2>
|
||||
Rule "{{policyName}}"" not found
|
||||
</div>
|
||||
|
||||
<div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div ng-include="'views/resultSubHeader.html'"></div>
|
||||
<div class="screenshot board">
|
||||
<h2>Screenshot</h2>
|
||||
|
||||
<div class="screenshotWrapper" ng-class="result.params.options.device || 'phone'">
|
||||
<div>
|
||||
<img ng-if="result.screenshotUrl" class="screenshotImage" ng-src="{{result.screenshotUrl}}"/>
|
||||
<span ng-if="!result.screenshotUrl" class="screenshotError">Screenshot not available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
var Q = require('q');
|
||||
|
||||
var Runner = require('./runner');
|
||||
|
||||
|
||||
var yellowLabTools = function(url, options) {
|
||||
'use strict';
|
||||
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (!url) {
|
||||
|
||||
deferred.reject('URL missing');
|
||||
|
||||
} else {
|
||||
|
||||
if (url.toLowerCase().indexOf('http://') !== 0 && url.toLowerCase().indexOf('https://') !== 0) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
|
||||
var params = {
|
||||
url: url,
|
||||
options: options || {}
|
||||
};
|
||||
|
||||
var runner = new Runner(params)
|
||||
|
||||
.progress(deferred.notify)
|
||||
|
||||
.then(function(data) {
|
||||
deferred.resolve(data);
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
deferred.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
module.exports = yellowLabTools;
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"categories": {
|
||||
"pageWeight": {
|
||||
"label": "Page weight",
|
||||
"policies": {
|
||||
"totalWeight": 5,
|
||||
"imageOptimization": 2,
|
||||
"imagesTooLarge": 1,
|
||||
"compression": 2,
|
||||
"fileMinification": 2
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"label": "Requests",
|
||||
"policies": {
|
||||
"totalRequests": 2,
|
||||
"domains": 3,
|
||||
"notFound": 2,
|
||||
"identicalFiles": 2,
|
||||
"emptyRequests": 3,
|
||||
"lazyLoadableImagesBelowTheFold": 2,
|
||||
"hiddenImages": 1
|
||||
}
|
||||
},
|
||||
"domComplexity": {
|
||||
"label": "DOM complexity",
|
||||
"policies": {
|
||||
"DOMelementsCount": 3,
|
||||
"DOMelementMaxDepth": 1,
|
||||
"iframesCount": 1,
|
||||
"DOMidDuplicated": 1
|
||||
}
|
||||
},
|
||||
"javascriptComplexity": {
|
||||
"label": "JS complexity",
|
||||
"policies": {
|
||||
"scriptDuration": 4,
|
||||
"DOMaccesses": 2,
|
||||
"eventsScrollBound": 1
|
||||
}
|
||||
},
|
||||
"badJavascript": {
|
||||
"label": "Bad JS",
|
||||
"policies": {
|
||||
"jsErrors": 1,
|
||||
"documentWriteCalls": 2,
|
||||
"synchronousXHR": 5,
|
||||
"globalVariables": 0.5
|
||||
}
|
||||
},
|
||||
"jQuery": {
|
||||
"label": "jQuery",
|
||||
"policies": {
|
||||
"jQueryVersion": 2,
|
||||
"jQueryVersionsLoaded": 2
|
||||
}
|
||||
},
|
||||
"cssComplexity": {
|
||||
"label": "CSS complexity",
|
||||
"policies": {
|
||||
"cssRules": 2,
|
||||
"cssComplexSelectors": 2,
|
||||
"cssColors": 0.5,
|
||||
"similarColors": 0.5,
|
||||
"cssBreakpoints": 0.5,
|
||||
"cssMobileFirst": 1
|
||||
}
|
||||
},
|
||||
"badCSS": {
|
||||
"label": "Bad CSS",
|
||||
"policies": {
|
||||
"cssParsingErrors": 3,
|
||||
"cssImports": 4,
|
||||
"cssDuplicatedSelectors": 2,
|
||||
"cssDuplicatedProperties": 1,
|
||||
"cssEmptyRules": 2,
|
||||
"cssImportants": 2,
|
||||
"cssOldIEFixes": 1,
|
||||
"cssOldPropertyPrefixes": 1,
|
||||
"cssRedundantBodySelectors": 1,
|
||||
"cssRedundantChildNodesSelectors": 1
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"label": "Web fonts",
|
||||
"policies": {
|
||||
"fontsCount": 1,
|
||||
"heavyFonts": 0.5,
|
||||
"nonWoff2Fonts": 0.5
|
||||
}
|
||||
},
|
||||
"serverConfig": {
|
||||
"label": "Server config",
|
||||
"policies": {
|
||||
"oldHttpProtocol": 2,
|
||||
"oldTlsProtocol": 2,
|
||||
"closedConnections": 2,
|
||||
"cachingNotSpecified": 1,
|
||||
"cachingDisabled": 1,
|
||||
"cachingTooShort": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"globalScore": {
|
||||
"pageWeight": 3,
|
||||
"requests": 2,
|
||||
"domComplexity": 2,
|
||||
"javascriptComplexity": 2,
|
||||
"badJavascript": 2,
|
||||
"jQuery": 0.5,
|
||||
"cssComplexity": 0.5,
|
||||
"badCSS": 1,
|
||||
"fonts": 1,
|
||||
"serverConfig": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
|
||||
|
||||
var OffendersHelpers = function() {
|
||||
|
||||
this.domPathToArray = function(str) {
|
||||
return str.split(/\s?>\s?/);
|
||||
};
|
||||
|
||||
this.listOfDomArraysToTree = function(listOfDomArrays) {
|
||||
var result = {};
|
||||
|
||||
function recursiveTreeBuilder(tree, domArray) {
|
||||
if (domArray.length > 0) {
|
||||
var currentDomElement = domArray.shift();
|
||||
if (tree === null) {
|
||||
tree = {};
|
||||
}
|
||||
tree[currentDomElement] = recursiveTreeBuilder(tree[currentDomElement] || null, domArray);
|
||||
return tree;
|
||||
} else if (tree === null) {
|
||||
return 1;
|
||||
} else {
|
||||
return tree + 1;
|
||||
}
|
||||
}
|
||||
|
||||
listOfDomArrays.forEach(function(domArray) {
|
||||
result = recursiveTreeBuilder(result, domArray);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
this.domPathToDomElementObj = function(domPath) {
|
||||
|
||||
if (typeof domPath === 'boolean') {
|
||||
return {
|
||||
// Not a normal element path
|
||||
type: 'notAnElement',
|
||||
element: domPath
|
||||
};
|
||||
}
|
||||
|
||||
var domArray = this.domPathToArray(domPath);
|
||||
var domTree = this.listOfDomArraysToTree([this.domPathToArray(domPath)]);
|
||||
|
||||
if (domArray[0] === 'html') {
|
||||
return {
|
||||
type: 'html'
|
||||
};
|
||||
}
|
||||
if (domArray[0] === 'body') {
|
||||
if (domArray.length === 1) {
|
||||
return {
|
||||
type: 'body'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'domElement',
|
||||
element: domArray[domArray.length - 1],
|
||||
tree: domTree
|
||||
};
|
||||
}
|
||||
}
|
||||
if (domArray[0] === 'head') {
|
||||
return {
|
||||
type: 'head'
|
||||
};
|
||||
}
|
||||
if (domArray[0] === '#document') {
|
||||
return {
|
||||
type: 'document'
|
||||
};
|
||||
}
|
||||
if (domArray[0] === 'window') {
|
||||
return {
|
||||
type: 'window'
|
||||
};
|
||||
}
|
||||
if (domArray[0] === 'DocumentFragment') {
|
||||
if (domArray.length === 1) {
|
||||
return {
|
||||
type: 'fragment'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'fragmentElement',
|
||||
element: domArray[domArray.length - 1],
|
||||
tree: domTree
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Not attached element, such as just created with document.createElement()
|
||||
if (domArray.length === 1) {
|
||||
return {
|
||||
type: 'createdElement',
|
||||
element: domPath
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'createdElement',
|
||||
element: domArray[domArray.length - 1],
|
||||
tree: domTree
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.backtraceToArray = function(str) {
|
||||
var traceArray = str.split(/ \/ /);
|
||||
|
||||
if (traceArray) {
|
||||
var results = [];
|
||||
var parts = null;
|
||||
var obj;
|
||||
|
||||
for (var i=0 ; i<traceArray.length ; i++) {
|
||||
// Handle the new PhantomJS 2.x syntax
|
||||
parts = /^\s*at( (\w+))? \(?([^ ]+):(\d+):(\d+)\)?$$/.exec(traceArray[i]);
|
||||
|
||||
if (parts) {
|
||||
obj = {
|
||||
file: parts[3],
|
||||
line: parseInt(parts[4], 10),
|
||||
column: parseInt(parts[5], 10)
|
||||
};
|
||||
|
||||
if (parts[2]) {
|
||||
obj.functionName = parts[2];
|
||||
}
|
||||
|
||||
results.push(obj);
|
||||
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.sortVarsLikeChromeDevTools = function(vars) {
|
||||
return vars.sort(function(a, b) {
|
||||
return (a < b) ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
this.urlToLink = function(url) {
|
||||
var shortUrl = (url.length > 110) ? url.substr(0, 47) + ' ... ' + url.substr(-48) : url;
|
||||
return '<a href="' + url + '" target="_blank" title="' + url + '">' + shortUrl + '</a>';
|
||||
};
|
||||
|
||||
this.cssOffenderPattern = function(offender) {
|
||||
// Used to work with strings
|
||||
// As of Phantomas v2, offender is now in JSON format.
|
||||
// Let's just adapt this for now and we'll see later if we remove completely this function
|
||||
return {
|
||||
css: offender.value.message,
|
||||
file: offender.url,
|
||||
line: offender.value.position.start.line,
|
||||
column: offender.value.position.start.column
|
||||
};
|
||||
};
|
||||
|
||||
this.fileWithSizePattern = function(fileWithSize) {
|
||||
var parts = /^([^ ]*) \((\d+\.\d{2}|NaN) kB\)$/.exec(fileWithSize);
|
||||
|
||||
if (!parts) {
|
||||
return {
|
||||
file: fileWithSize
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
file: parts[1],
|
||||
size: parseFloat(parts[2])
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
this.orderByFile = function(offenders) {
|
||||
var byFileObj = {};
|
||||
|
||||
offenders.forEach(function(offender) {
|
||||
var file = offender.file || 'Inline CSS';
|
||||
delete offender.file;
|
||||
|
||||
if (!byFileObj[file]) {
|
||||
byFileObj[file] = {
|
||||
url: file,
|
||||
count: 0,
|
||||
offenders: []
|
||||
};
|
||||
}
|
||||
|
||||
byFileObj[file].count ++;
|
||||
byFileObj[file].offenders.push(offender);
|
||||
});
|
||||
|
||||
// Transform object into array
|
||||
var byFileArray = [];
|
||||
for (var file in byFileObj) {
|
||||
byFileArray.push(byFileObj[file]);
|
||||
}
|
||||
|
||||
return {byFile: byFileArray};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
module.exports = new OffendersHelpers();
|
||||
@@ -0,0 +1,137 @@
|
||||
var extend = require('util')._extend;
|
||||
|
||||
var debug = require('debug')('ylt:ruleschecker');
|
||||
|
||||
var RulesChecker = function() {
|
||||
'use strict';
|
||||
|
||||
this.check = function(data, policies) {
|
||||
/*jshint loopfunc:true */
|
||||
|
||||
var results = {};
|
||||
|
||||
debug('Starting checking rules');
|
||||
|
||||
for (var metricName in policies) {
|
||||
var policy = policies[metricName];
|
||||
var rule;
|
||||
|
||||
if (policy.tool &&
|
||||
data.toolsResults[policy.tool] &&
|
||||
data.toolsResults[policy.tool].metrics &&
|
||||
(data.toolsResults[policy.tool].metrics[metricName] || data.toolsResults[policy.tool].metrics[metricName] === 0)) {
|
||||
|
||||
rule = {
|
||||
value: data.toolsResults[policy.tool].metrics[metricName],
|
||||
policy: extend({}, policy) // Clone object policy instead of reference
|
||||
};
|
||||
|
||||
|
||||
// Deal with offenders
|
||||
if (policy.hasOffenders) {
|
||||
|
||||
var offenders = [];
|
||||
|
||||
// Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example.
|
||||
if (policy.takeOffendersFrom) {
|
||||
|
||||
var fromList = policy.takeOffendersFrom;
|
||||
|
||||
// takeOffendersFrom option can be a string or an array of strings.
|
||||
if (typeof fromList === 'string') {
|
||||
fromList = [fromList];
|
||||
}
|
||||
|
||||
fromList.forEach(function(from) {
|
||||
if (data.toolsResults[policy.tool] &&
|
||||
data.toolsResults[policy.tool].offenders &&
|
||||
data.toolsResults[policy.tool].offenders[from]) {
|
||||
offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]);
|
||||
}
|
||||
});
|
||||
|
||||
data.toolsResults[policy.tool].offenders[metricName] = offenders;
|
||||
|
||||
} else if (data.toolsResults[policy.tool] &&
|
||||
data.toolsResults[policy.tool].offenders &&
|
||||
data.toolsResults[policy.tool].offenders[metricName]) {
|
||||
offenders = data.toolsResults[policy.tool].offenders[metricName];
|
||||
}
|
||||
|
||||
var offendersObj = {};
|
||||
|
||||
// It is possible to declare a transformation function for the offenders.
|
||||
// The function should take an array of strings as single parameter and return a string.
|
||||
if (policy.offendersTransformFn) {
|
||||
|
||||
try {
|
||||
offendersObj = policy.offendersTransformFn(offenders, rule);
|
||||
} catch(err) {
|
||||
debug('Error while transforming offenders for %s', metricName);
|
||||
debug(err);
|
||||
}
|
||||
|
||||
delete rule.policy.offendersTransformFn;
|
||||
|
||||
} else {
|
||||
|
||||
offendersObj = {
|
||||
count: offenders.length,
|
||||
list: offenders
|
||||
};
|
||||
}
|
||||
|
||||
rule.offendersObj = offendersObj;
|
||||
}
|
||||
|
||||
|
||||
rule.bad = rule.value > policy.isOkThreshold;
|
||||
rule.abnormal = policy.isAbnormalThreshold && rule.value >= policy.isAbnormalThreshold;
|
||||
|
||||
// A value between 0 (bad) and 100 (very good).
|
||||
var score = (policy.isBadThreshold - rule.value) * 100 / (policy.isBadThreshold - policy.isOkThreshold);
|
||||
rule.score = Math.min(Math.max(Math.round(score), 0), 100);
|
||||
|
||||
// A value between 0 (abnormal) and negative-infinity (your website is a blackhole)
|
||||
var abnormalityScore = (policy.isAbnormalThreshold - rule.value) * 100 / (policy.isAbnormalThreshold - policy.isOkThreshold);
|
||||
rule.abnormalityScore = Math.min(Math.round(abnormalityScore), 0);
|
||||
|
||||
results[metricName] = rule;
|
||||
debug('Metric %s calculated. Score: %d', metricName, rule.score);
|
||||
|
||||
|
||||
} else if (policy.scoreFn) {
|
||||
|
||||
debug('Custom score function for %s', metricName);
|
||||
|
||||
// Custom score function
|
||||
rule = policy.scoreFn(data);
|
||||
|
||||
// Check returned values (if the result is null, just don't save)
|
||||
if (rule) {
|
||||
rule.policy = {
|
||||
label: policy.label,
|
||||
message: policy.message,
|
||||
hasOffenders: policy.hasOffenders || false
|
||||
};
|
||||
|
||||
results[metricName] = rule;
|
||||
debug('Metric %s calculated. Score: %d', metricName, rule.score);
|
||||
} else {
|
||||
debug('Metric %s is null. Ignored.', metricName);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
debug('Metric %s not found for tool %s', metricName, policy.tool);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
debug('Rules checking finished');
|
||||
|
||||
return results;
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new RulesChecker();
|
||||
@@ -0,0 +1,135 @@
|
||||
var Q = require('q');
|
||||
var debug = require('debug')('ylt:runner');
|
||||
|
||||
var phantomasWrapper = require('./tools/phantomas/phantomasWrapper');
|
||||
var colorDiff = require('./tools/colorDiff');
|
||||
var domAccessAgregator = require('./tools/domAccessAgregator');
|
||||
var mediaQueriesChecker = require('./tools/mediaQueriesChecker');
|
||||
var redownload = require('./tools/redownload/redownload');
|
||||
var rulesChecker = require('./rulesChecker');
|
||||
var scoreCalculator = require('./scoreCalculator');
|
||||
|
||||
|
||||
var Runner = function(params) {
|
||||
'use strict';
|
||||
|
||||
var deferred = Q.defer();
|
||||
|
||||
// The pivot format
|
||||
var data = {
|
||||
params: params,
|
||||
toolsResults: {}
|
||||
};
|
||||
|
||||
// Execute Phantomas first
|
||||
phantomasWrapper.execute(data)
|
||||
|
||||
// For the progress bar
|
||||
.progress(function(event) {
|
||||
if (event === 'domReady') {
|
||||
deferred.notify({
|
||||
estimatedProgress: 0.15,
|
||||
milestone: 'domReady'
|
||||
});
|
||||
}
|
||||
if (event === 'domComplete') {
|
||||
deferred.notify({
|
||||
estimatedProgress: 0.25,
|
||||
milestone: 'domComplete'
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
.then(function(phantomasResults) {
|
||||
// For the progress bar
|
||||
deferred.notify({
|
||||
estimatedProgress: 0.4,
|
||||
milestone: 'phantomas'
|
||||
});
|
||||
|
||||
data.toolsResults.phantomas = phantomasResults;
|
||||
|
||||
// Mix all DOM Access metrics together
|
||||
data = domAccessAgregator.agregate(data);
|
||||
|
||||
// Compare colors
|
||||
data = colorDiff.compareAllColors(data);
|
||||
|
||||
// Check media queries
|
||||
data = mediaQueriesChecker.analyzeMediaQueries(data);
|
||||
|
||||
// Redownload every file
|
||||
return redownload.recheckAllFiles(data)
|
||||
|
||||
.progress(function(redownloadedProgress) {
|
||||
deferred.notify({
|
||||
estimatedProgress: 0.4 + redownloadedProgress * 0.55,
|
||||
milestone: 'phantomas'
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
.then(function(data) {
|
||||
|
||||
// For the progress bar
|
||||
deferred.notify({
|
||||
estimatedProgress: 0.99,
|
||||
milestone: 'redownload'
|
||||
});
|
||||
|
||||
// Rules checker
|
||||
var policies = require('./metadata/policies');
|
||||
data.rules = rulesChecker.check(data, policies);
|
||||
|
||||
|
||||
// Scores calculator
|
||||
var scoreProfileGeneric = require('./metadata/scoreProfileGeneric.json');
|
||||
data.scoreProfiles = {
|
||||
generic : scoreCalculator.calculate(data, scoreProfileGeneric, true)
|
||||
};
|
||||
|
||||
|
||||
// Calculate "If you fix this issue, your new score is..." on each rule
|
||||
debug('Calculate "If you fix this issue..." scores');
|
||||
Object.keys(data.rules).forEach(function(ruleName) {
|
||||
// Save current values
|
||||
var oldScore = data.rules[ruleName].score;
|
||||
var oldAbnormalityScore = data.rules[ruleName].abnormalityScore;
|
||||
// Simulate a 100/100 score on a specific rule
|
||||
data.rules[ruleName].score = 100;
|
||||
data.rules[ruleName].abnormalityScore = 0;
|
||||
// Calculate new score
|
||||
data.rules[ruleName].globalScoreIfFixed = scoreCalculator.calculate(data, scoreProfileGeneric, false).globalScore;
|
||||
// Revert values
|
||||
data.rules[ruleName].score = oldScore;
|
||||
data.rules[ruleName].abnormalityScore = oldAbnormalityScore;
|
||||
});
|
||||
debug('Calculating is finished');
|
||||
|
||||
|
||||
if (data.toolsResults.phantomas.offenders.blockedRequests) {
|
||||
data.blockedRequests = data.toolsResults.phantomas.offenders.blockedRequests;
|
||||
}
|
||||
|
||||
// Report WordPress detection
|
||||
if (data.toolsResults.redownload.metrics.isWordPress === true) {
|
||||
data.frameworks = {isWordPress: true};
|
||||
}
|
||||
|
||||
// Finished!
|
||||
deferred.resolve(data);
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
debug('Run failed');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
});
|
||||
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
module.exports = Runner;
|
||||
@@ -0,0 +1,91 @@
|
||||
var Q = require('q');
|
||||
var debug = require('debug')('ylt:scoreCalculator');
|
||||
|
||||
var ScoreCalculator = function() {
|
||||
'use strict';
|
||||
|
||||
this.calculate = function(data, profile, debugFlag) {
|
||||
|
||||
var results = {
|
||||
categories: {}
|
||||
};
|
||||
var categoryScore;
|
||||
var categoryName;
|
||||
var weight;
|
||||
|
||||
if (debugFlag) {
|
||||
debug('Starting calculating scores');
|
||||
}
|
||||
|
||||
// Calculate categories
|
||||
for (categoryName in profile.categories) {
|
||||
var categoryResult = {
|
||||
label: profile.categories[categoryName].label
|
||||
};
|
||||
|
||||
categoryScore = new ScoreMerger();
|
||||
var rules = [];
|
||||
var policyScore;
|
||||
|
||||
for (var policyName in profile.categories[categoryName].policies) {
|
||||
weight = profile.categories[categoryName].policies[policyName];
|
||||
|
||||
if (data.rules[policyName]) {
|
||||
policyScore = data.rules[policyName].score + (data.rules[policyName].abnormalityScore * 2);
|
||||
categoryScore.push(policyScore, weight);
|
||||
} else if (debugFlag) {
|
||||
debug('Warning: could not find rule %s', policyName);
|
||||
}
|
||||
|
||||
rules.push(policyName);
|
||||
}
|
||||
|
||||
categoryResult.categoryScore = categoryScore.getScore();
|
||||
|
||||
categoryResult.rules = rules;
|
||||
results.categories[categoryName] = categoryResult;
|
||||
}
|
||||
|
||||
|
||||
// Calculate general score
|
||||
var globalScore = new ScoreMerger();
|
||||
|
||||
for (categoryName in profile.globalScore) {
|
||||
weight = profile.globalScore[categoryName];
|
||||
|
||||
if (results.categories[categoryName]) {
|
||||
globalScore.push(results.categories[categoryName].categoryScore, weight);
|
||||
}
|
||||
}
|
||||
|
||||
results.globalScore = Math.round(globalScore.getScore());
|
||||
|
||||
|
||||
if (debugFlag) {
|
||||
debug('Score calculation finished:');
|
||||
debug(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
|
||||
var ScoreMerger = function() {
|
||||
var sum = 0;
|
||||
var totalWeight = 0;
|
||||
|
||||
this.push = function(score, weight) {
|
||||
sum += (100 - score) * weight;
|
||||
totalWeight += weight;
|
||||
};
|
||||
|
||||
this.getScore = function() {
|
||||
if (totalWeight === 0) {
|
||||
return 100;
|
||||
}
|
||||
return Math.round(100 - (sum / totalWeight));
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new ScoreCalculator();
|
||||
@@ -0,0 +1,141 @@
|
||||
var debug = require('debug')('ylt:screenshotHandler');
|
||||
var Jimp = require('jimp');
|
||||
var Q = require('q');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
|
||||
var screenshotHandler = function() {
|
||||
|
||||
var tmpFolderPath = 'tmp';
|
||||
var tmpFolderFullPath = path.join(__dirname, '..', tmpFolderPath);
|
||||
var tmpFileName = 'temp-screenshot.png';
|
||||
var tmpFileFullPath = path.join(tmpFolderFullPath, tmpFileName);
|
||||
|
||||
|
||||
this.findAndOptimizeScreenshot = function(width) {
|
||||
var that = this;
|
||||
|
||||
debug('Starting screenshot transformation');
|
||||
|
||||
return this.openImage(tmpFileFullPath)
|
||||
|
||||
.then(function(image) {
|
||||
that.deleteTmpFile(tmpFileFullPath);
|
||||
return that.resizeImage(image, width);
|
||||
})
|
||||
|
||||
.then(this.toBuffer);
|
||||
};
|
||||
|
||||
|
||||
this.openImage = function(imagePath) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
Jimp.read(imagePath, function(err, image){
|
||||
if (err) {
|
||||
debug('Could not open imagePath %s', imagePath);
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
debug('Image correctly open');
|
||||
deferred.resolve(image);
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
|
||||
this.resizeImage = function(image, newWidth) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
var currentWidth = image.bitmap.width;
|
||||
|
||||
if (currentWidth > 0) {
|
||||
var ratio = newWidth / currentWidth;
|
||||
|
||||
image.scale(ratio, function(err, image){
|
||||
if (err) {
|
||||
debug('Could not resize image');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
debug('Image correctly resized');
|
||||
deferred.resolve(image);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
deferred.reject('Could not resize an empty image');
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
|
||||
this.toBuffer = function(image) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
image.quality(85).getBuffer(Jimp.MIME_JPEG, function(err, buffer){
|
||||
if (err) {
|
||||
debug('Could not save image to buffer');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
debug('Image correctly transformed to buffer');
|
||||
deferred.resolve(buffer);
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
|
||||
this.deleteTmpFile = function(tmpFilePath) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
fs.unlink(tmpFilePath, function (err) {
|
||||
if (err) {
|
||||
debug('Screenshot temporary file not found, could not be deleted. But it is not a problem.');
|
||||
} else {
|
||||
debug('Screenshot temporary file deleted.');
|
||||
}
|
||||
|
||||
deferred.resolve();
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
// Create a /tmp folder on the project's root directory
|
||||
this.createTmpScreenshotFolder = function() {
|
||||
var deferred = Q.defer();
|
||||
|
||||
// Create the folder if it doesn't exist
|
||||
fs.exists(tmpFolderFullPath, function(exists) {
|
||||
if (exists) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
debug('Creating the tmp image folder', tmpFolderFullPath);
|
||||
fs.mkdir(tmpFolderFullPath, function(err) {
|
||||
if (err) {
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
this.getTmpFileRelativePath = function() {
|
||||
return tmpFolderPath + '/' + tmpFileName;
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new screenshotHandler();
|
||||
@@ -0,0 +1,349 @@
|
||||
var debug = require('debug')('ylt:server');
|
||||
var Q = require('q');
|
||||
|
||||
var ylt = require('../../index');
|
||||
var ScreenshotHandler = require('../../screenshotHandler');
|
||||
var RunsQueue = require('../datastores/runsQueue');
|
||||
var RunsDatastore = require('../datastores/runsDatastore');
|
||||
var ResultsDatastore = require('../datastores/resultsDatastore');
|
||||
|
||||
var serverSettings = (process.env.IS_TEST) ? require('../../../test/fixtures/settings.json') : require('../../../server_config/settings.json');
|
||||
|
||||
var ApiController = function(app) {
|
||||
'use strict';
|
||||
|
||||
var queue = new RunsQueue();
|
||||
var runsDatastore = new RunsDatastore();
|
||||
var resultsDatastore = new ResultsDatastore();
|
||||
|
||||
// Create a new run
|
||||
app.post('/api/runs', function(req, res) {
|
||||
|
||||
// Add http to the test URL
|
||||
if (req.body.url && req.body.url.toLowerCase().indexOf('http://') !== 0 && req.body.url.toLowerCase().indexOf('https://') !== 0) {
|
||||
req.body.url = 'http://' + req.body.url;
|
||||
}
|
||||
|
||||
// Block requests to unwanted websites (=spam)
|
||||
if (req.body.url && isBlocked(req.body.url)) {
|
||||
console.error('Test blocked for URL: %s', req.body.url);
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the test parameters and generate a random run ID
|
||||
var run = {
|
||||
runId: (Date.now()*1000 + Math.round(Math.random()*1000)).toString(36),
|
||||
params: {
|
||||
url: req.body.url,
|
||||
waitForResponse: req.body.waitForResponse === true || req.body.waitForResponse === 'true' || req.body.waitForResponse === 1,
|
||||
partialResult: req.body.partialResult || null,
|
||||
screenshot: req.body.screenshot || false,
|
||||
device: req.body.device || 'desktop',
|
||||
proxy: req.body.proxy || null,
|
||||
waitForSelector: req.body.waitForSelector || null,
|
||||
cookie: req.body.cookie || null,
|
||||
authUser: req.body.authUser || null,
|
||||
authPass: req.body.authPass || null,
|
||||
blockDomain: req.body.blockDomain || null,
|
||||
allowDomain: req.body.allowDomain || null,
|
||||
noExternals: req.body.noExternals || false
|
||||
}
|
||||
};
|
||||
|
||||
// Create the tmp folder if it doesn't exist
|
||||
ScreenshotHandler.createTmpScreenshotFolder(run.runId);
|
||||
|
||||
// Add test to the testQueue
|
||||
debug('Adding test %s to the queue', run.runId);
|
||||
var queuePromise = queue.push(run.runId);
|
||||
|
||||
// Save the run to the datastore
|
||||
runsDatastore.add(run, queuePromise.startingPosition);
|
||||
|
||||
|
||||
// Listening for position updates
|
||||
queuePromise.progress(function(position) {
|
||||
runsDatastore.updatePosition(run.runId, position);
|
||||
});
|
||||
|
||||
// Let's start the run
|
||||
queuePromise.then(function() {
|
||||
|
||||
runsDatastore.updatePosition(run.runId, 0);
|
||||
|
||||
console.log('Launching test ' + run.runId + ' on ' + run.params.url);
|
||||
|
||||
var runOptions = {
|
||||
screenshot: run.params.screenshot ? ScreenshotHandler.getTmpFileRelativePath() : false,
|
||||
device: run.params.device,
|
||||
proxy: run.params.proxy,
|
||||
waitForSelector: run.params.waitForSelector,
|
||||
cookie: run.params.cookie,
|
||||
authUser: run.params.authUser,
|
||||
authPass: run.params.authPass,
|
||||
blockDomain: run.params.blockDomain,
|
||||
allowDomain: run.params.allowDomain,
|
||||
noExternals: run.params.noExternals
|
||||
};
|
||||
|
||||
return ylt(run.params.url, runOptions)
|
||||
|
||||
// Update the progress bar on each progress
|
||||
.progress(function(progress) {
|
||||
runsDatastore.updateRunProgress(run.runId, progress);
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
// Phantomas completed
|
||||
.then(function(data) {
|
||||
|
||||
debug('Success');
|
||||
data.runId = run.runId;
|
||||
|
||||
|
||||
// Some conditional steps exist if there is a screenshot
|
||||
var screenshotPromise = Q.resolve();
|
||||
|
||||
if (run.params.screenshot) {
|
||||
|
||||
var screenshotSize = serverSettings.screenshotWidth ? serverSettings.screenshotWidth[run.params.device] : 400;
|
||||
|
||||
// Replace the empty promise created earlier with Q.resolve()
|
||||
screenshotPromise = ScreenshotHandler.findAndOptimizeScreenshot(screenshotSize)
|
||||
|
||||
// Read screenshot
|
||||
.then(function(screenshotBuffer) {
|
||||
if (screenshotBuffer) {
|
||||
debug('Image optimized');
|
||||
data.screenshotBuffer = screenshotBuffer;
|
||||
data.screenshotUrl = '/api/results/' + data.runId + '/screenshot.jpg';
|
||||
}
|
||||
})
|
||||
|
||||
// Don't worry if there's an error
|
||||
.fail(function(err) {
|
||||
debug('An error occured while creating the screenshot\'s thumbnail. Ignoring and continuing...');
|
||||
debug(err);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Let's continue
|
||||
return screenshotPromise
|
||||
|
||||
// Save results
|
||||
.then(function() {
|
||||
// Remove uneeded temp screenshot path
|
||||
delete data.params.options.screenshot;
|
||||
|
||||
// Here we can remove tools results if not needed
|
||||
delete data.toolsResults.phantomas.offenders.requests;
|
||||
|
||||
return resultsDatastore.saveResult(data);
|
||||
})
|
||||
|
||||
// Mark as the run as complete and send the response if the request is still waiting
|
||||
.then(function() {
|
||||
|
||||
debug('Result saved in datastore');
|
||||
|
||||
runsDatastore.markAsComplete(run.runId);
|
||||
|
||||
if (run.params.waitForResponse) {
|
||||
|
||||
// If the user only wants a portion of the result (partialResult option)
|
||||
switch(run.params.partialResult) {
|
||||
case 'generalScores':
|
||||
res.redirect(302, '/api/results/' + run.runId + '/generalScores');
|
||||
break;
|
||||
case 'rules':
|
||||
res.redirect(302, '/api/results/' + run.runId + '/rules');
|
||||
break;
|
||||
case 'javascriptExecutionTree':
|
||||
res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree');
|
||||
break;
|
||||
case 'phantomas':
|
||||
res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas');
|
||||
break;
|
||||
default:
|
||||
res.redirect(302, '/api/results/' + run.runId);
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
.fail(function(err) {
|
||||
console.error('Test failed for URL: %s', run.params.url);
|
||||
console.error(err.toString());
|
||||
|
||||
runsDatastore.markAsFailed(run.runId, err.toString());
|
||||
|
||||
res.status(500).send('An error occured');
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
|
||||
console.error('Test failed for URL: %s', run.params.url);
|
||||
console.error(err.toString());
|
||||
|
||||
runsDatastore.markAsFailed(run.runId, err.toString());
|
||||
|
||||
res.status(400).send('Bad request');
|
||||
|
||||
})
|
||||
|
||||
.finally(function() {
|
||||
queue.remove(run.runId);
|
||||
});
|
||||
|
||||
|
||||
// The user doesn't want to wait for the response, sending the run ID only
|
||||
if (!run.params.waitForResponse) {
|
||||
debug('Sending response without waiting.');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify({runId: run.runId}));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Retrive one run by id
|
||||
app.get('/api/runs/:id', function(req, res) {
|
||||
var runId = req.params.id;
|
||||
|
||||
var run = runsDatastore.get(runId);
|
||||
|
||||
if (run) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify(run, null, 2));
|
||||
} else {
|
||||
res.status(404).send('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Counts all pending runs
|
||||
app.get('/api/runs', function(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify({pendingRuns: queue.length()}, null, 2));
|
||||
});
|
||||
|
||||
// Delete one run by id
|
||||
/*app.delete('/api/runs/:id', function(req, res) {
|
||||
deleteRun()
|
||||
});*/
|
||||
|
||||
// Delete all
|
||||
/*app.delete('/api/runs', function(req, res) {
|
||||
purgeRuns()
|
||||
});
|
||||
|
||||
// List all
|
||||
app.get('/api/runs', function(req, res) {
|
||||
listRuns()
|
||||
});
|
||||
|
||||
// Exists
|
||||
app.head('/api/runs/:id', function(req, res) {
|
||||
existsX();
|
||||
// Returns 200 if the result exists or 404 if not
|
||||
});
|
||||
*/
|
||||
|
||||
// Retrive one result by id
|
||||
app.get('/api/results/:id', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
|
||||
// Some fields can be excluded from the response, this way:
|
||||
// /api/results/:id?exclude=field1,field2
|
||||
if (req.query.exclude && typeof req.query.exclude === 'string') {
|
||||
var excludedFields = req.query.exclude.split(',');
|
||||
excludedFields.forEach(function(fieldName) {
|
||||
if (data[fieldName]) {
|
||||
delete data[fieldName];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
});
|
||||
|
||||
// Retrieve one result and return only the generalScores part of the response
|
||||
app.get('/api/results/:id/generalScores', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
return data.scoreProfiles.generic;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/results/:id/generalScores/:scoreProfile', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
return data.scoreProfiles[req.params.scoreProfile];
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/results/:id/rules', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
return data.rules;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/results/:id/javascriptExecutionTree', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
return data.javascriptExecutionTree;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/results/:id/toolsResults/phantomas', function(req, res) {
|
||||
getPartialResults(req.params.id, res, function(data) {
|
||||
return data.toolsResults.phantomas;
|
||||
});
|
||||
});
|
||||
|
||||
function getPartialResults(runId, res, partialGetterFn) {
|
||||
resultsDatastore.getResult(runId)
|
||||
.then(function(data) {
|
||||
var results = partialGetterFn(data);
|
||||
|
||||
if (typeof results === 'undefined') {
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify(results, null, 2));
|
||||
|
||||
}).fail(function() {
|
||||
res.status(404).send('Not found');
|
||||
});
|
||||
}
|
||||
|
||||
// Retrive one result by id
|
||||
app.get('/api/results/:id/screenshot.jpg', function(req, res) {
|
||||
var runId = req.params.id;
|
||||
|
||||
resultsDatastore.getScreenshot(runId)
|
||||
.then(function(screenshotBuffer) {
|
||||
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.send(screenshotBuffer);
|
||||
|
||||
}).fail(function() {
|
||||
res.status(404).send('Not found');
|
||||
});
|
||||
});
|
||||
|
||||
function isBlocked(url) {
|
||||
if (!serverSettings.blockedUrls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverSettings.blockedUrls.some(function(blockedUrl) {
|
||||
return (url.indexOf(blockedUrl) === 0);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ApiController;
|
||||
@@ -0,0 +1,45 @@
|
||||
var path = require('path');
|
||||
var express = require('express');
|
||||
|
||||
var serverSettings = (process.env.IS_TEST) ? require('../../../test/fixtures/settings.json') : require('../../../server_config/settings.json');
|
||||
var packageJson = require('../../../package.json');
|
||||
|
||||
var FrontController = function(app) {
|
||||
'use strict';
|
||||
|
||||
var cacheDuration = 365 * 24 * 60 * 60 * 1000; // One year
|
||||
var assetsPath = (app.get('env') === 'development') ? '../../../front/src' : '../../../front/build';
|
||||
|
||||
// Routes templating
|
||||
var routes = ['/', '/about', '/result/:runId', '/result/:runId/screenshot', '/result/:runId/rule/:policy', '/queue/:runId'];
|
||||
|
||||
routes.forEach(function(route) {
|
||||
app.get(route, function(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=20');
|
||||
res.render(path.join(__dirname, assetsPath, 'main.html'), {
|
||||
version: 'v' + packageJson.version,
|
||||
baseUrl: app.locals.baseUrl || '/',
|
||||
googleAnalyticsId: serverSettings.googleAnalyticsId,
|
||||
sponsoring: serverSettings.sponsoring || {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Views templating
|
||||
app.get('/views/:viewName', function(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=' + cacheDuration);
|
||||
res.render(path.join(__dirname, assetsPath, 'views/' + req.params.viewName), {
|
||||
baseUrl: app.locals.baseUrl || '/',
|
||||
sponsoring: serverSettings.sponsoring || {}
|
||||
});
|
||||
});
|
||||
|
||||
// Static assets
|
||||
app.use('/css', express.static(path.join(__dirname, assetsPath, 'css'), { maxAge: cacheDuration }));
|
||||
app.use('/fonts', express.static(path.join(__dirname, assetsPath, 'fonts'), { maxAge: cacheDuration }));
|
||||
app.use('/img', express.static(path.join(__dirname, assetsPath, 'img'), { maxAge: cacheDuration }));
|
||||
app.use('/js', express.static(path.join(__dirname, assetsPath, 'js'), { maxAge: cacheDuration }));
|
||||
app.use('/node_modules', express.static(path.join(__dirname, '../../../node_modules'), { maxAge: cacheDuration }));
|
||||
};
|
||||
|
||||
module.exports = FrontController;
|
||||
@@ -0,0 +1,136 @@
|
||||
var fs = require('fs');
|
||||
var rimraf = require('rimraf');
|
||||
var path = require('path');
|
||||
var Q = require('q');
|
||||
var debug = require('debug')('ylt:resultsDatastore');
|
||||
|
||||
|
||||
function ResultsDatastore() {
|
||||
'use strict';
|
||||
|
||||
var resultFileName = 'results.json';
|
||||
var resultScreenshotName = 'screenshot.jpg';
|
||||
var resultsFolderName = 'results';
|
||||
var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName);
|
||||
|
||||
|
||||
this.saveResult = function(testResults) {
|
||||
|
||||
var screenshotFilePath = path.join(resultsDir, testResults.runId, resultScreenshotName);
|
||||
var screenshotAPIPath = '/';
|
||||
|
||||
return createResultFolder(testResults.runId)
|
||||
|
||||
.then(function() {
|
||||
return saveScreenshotIfExists(testResults, screenshotFilePath);
|
||||
})
|
||||
|
||||
.then(function() {
|
||||
|
||||
debug('Saving results to disk...');
|
||||
|
||||
var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName);
|
||||
debug('Destination file is %s', resultFilePath);
|
||||
|
||||
return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
this.getResult = function(runId) {
|
||||
|
||||
var resultFilePath = path.join(resultsDir, runId, resultFileName);
|
||||
|
||||
debug('Reading results (runID = %s) from disk...', runId);
|
||||
|
||||
return Q.nfcall(fs.readFile, resultFilePath, {encoding: 'utf8'}).then(function(data) {
|
||||
return JSON.parse(data);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
this.deleteResult = function(runId) {
|
||||
var folder = path.join(resultsDir, runId);
|
||||
|
||||
debug('Deleting results (runID = %s) from disk...', runId);
|
||||
|
||||
return Q.nfcall(rimraf, folder);
|
||||
};
|
||||
|
||||
|
||||
// The folder /results/folderName/
|
||||
function createResultFolder(runId) {
|
||||
var folder = path.join(resultsDir, runId);
|
||||
|
||||
debug('Creating the folder %s', runId);
|
||||
|
||||
return createGlobalFolder().then(function() {
|
||||
return Q.nfcall(fs.mkdir, folder);
|
||||
});
|
||||
}
|
||||
|
||||
// The folder /results/
|
||||
function createGlobalFolder() {
|
||||
var deferred = Q.defer();
|
||||
|
||||
// Create the results folder if it doesn't exist
|
||||
fs.exists(resultsDir, function(exists) {
|
||||
if (exists) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
debug('Creating the global results folder', resultsDir);
|
||||
fs.mkdir(resultsDir, function(err) {
|
||||
if (err) {
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
this.getResultFolder = function(runId) {
|
||||
return path.join(resultsDir, runId);
|
||||
};
|
||||
|
||||
// If there is a screenshot, save it as screenshot.jpg in the same folder as the results
|
||||
function saveScreenshotIfExists(testResults, path) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (testResults.screenshotBuffer) {
|
||||
|
||||
fs.writeFile(path, testResults.screenshotBuffer, function(err) {
|
||||
if (err) {
|
||||
debug('Could not save final screenshot');
|
||||
debug(err);
|
||||
// But it is OK, we don't need to fail the run
|
||||
deferred.resolve();
|
||||
} else {
|
||||
debug('Final screenshot saved: ' + path);
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
delete testResults.screenshotBuffer;
|
||||
|
||||
} else {
|
||||
debug('Screenshot not found');
|
||||
deferred.resolve();
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
this.getScreenshot = function(runId) {
|
||||
|
||||
var screenshotFilePath = path.join(resultsDir, runId, resultScreenshotName);
|
||||
|
||||
debug('Getting screenshot (runID = %s) from disk...', runId);
|
||||
|
||||
return Q.nfcall(fs.readFile, screenshotFilePath);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = ResultsDatastore;
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
|
||||
function RunsDatastore() {
|
||||
'use strict';
|
||||
|
||||
// NOT PERSISTING RUNS
|
||||
// For the moment, maybe one day
|
||||
var runs = {};
|
||||
|
||||
var STATUS_AWAITING = 'awaiting';
|
||||
var STATUS_RUNNING = 'running';
|
||||
var STATUS_COMPLETE = 'complete';
|
||||
var STATUS_FAILED = 'failed';
|
||||
|
||||
|
||||
this.add = function(run, position) {
|
||||
runs[run.runId] = run;
|
||||
this.updatePosition(run.runId, position);
|
||||
};
|
||||
|
||||
|
||||
this.get = function(runId) {
|
||||
return runs[runId];
|
||||
};
|
||||
|
||||
|
||||
this.updatePosition = function(runId, position) {
|
||||
var run = runs[runId];
|
||||
|
||||
if (position > 0) {
|
||||
run.status = {
|
||||
statusCode: STATUS_AWAITING,
|
||||
position: position
|
||||
};
|
||||
} else {
|
||||
run.status = {
|
||||
statusCode: STATUS_RUNNING
|
||||
};
|
||||
}
|
||||
|
||||
runs[runId] = run;
|
||||
};
|
||||
|
||||
|
||||
// When the test is launched, set the progress bar
|
||||
this.updateRunProgress = function(runId, progress) {
|
||||
var run = runs[runId];
|
||||
|
||||
run.progress = progress;
|
||||
|
||||
runs[runId] = run;
|
||||
};
|
||||
|
||||
|
||||
this.markAsComplete = function(runId) {
|
||||
var run = runs[runId];
|
||||
|
||||
run.status = {
|
||||
statusCode: STATUS_COMPLETE
|
||||
};
|
||||
|
||||
runs[runId] = run;
|
||||
};
|
||||
|
||||
|
||||
this.markAsFailed = function(runId, err) {
|
||||
var run = runs[runId];
|
||||
|
||||
var errorMessage;
|
||||
switch(err) {
|
||||
case '1':
|
||||
errorMessage = "Error 1: unknown error";
|
||||
break;
|
||||
case '252':
|
||||
errorMessage = "Error 252: page timeout in Phantomas";
|
||||
break;
|
||||
case '253':
|
||||
errorMessage = "Error 253: Phantomas config error";
|
||||
break;
|
||||
case '254':
|
||||
errorMessage = "Error 254: page loading failed in PhantomJS";
|
||||
break;
|
||||
case '255':
|
||||
errorMessage = "Error 255: Phantomas error";
|
||||
break;
|
||||
case '1001':
|
||||
errorMessage = "Error 1001: JavaScript profiling failed";
|
||||
break;
|
||||
case '1002':
|
||||
errorMessage = "Error 1002: missing Phantomas metrics";
|
||||
break;
|
||||
case '1003':
|
||||
errorMessage = "Error 1003: Phantomas not returning";
|
||||
break;
|
||||
default:
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
run.status = {
|
||||
statusCode: STATUS_FAILED,
|
||||
error: errorMessage
|
||||
};
|
||||
|
||||
runs[runId] = run;
|
||||
};
|
||||
|
||||
|
||||
this.delete = function(runId) {
|
||||
delete runs[runId];
|
||||
};
|
||||
|
||||
|
||||
this.list = function() {
|
||||
var runsArray = [];
|
||||
Object.keys(runs).forEach(function(key) {
|
||||
runsArray.push(runs[key]);
|
||||
});
|
||||
return runsArray;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = RunsDatastore;
|
||||
@@ -0,0 +1,83 @@
|
||||
var Q = require('q');
|
||||
var debug = require('debug')('ylt:runsQueue');
|
||||
|
||||
|
||||
function RunsQueue() {
|
||||
'use strict';
|
||||
|
||||
var queue = [];
|
||||
|
||||
|
||||
this.push = function(runId) {
|
||||
var deferred = Q.defer();
|
||||
var startingPosition = queue.length;
|
||||
|
||||
debug('Adding run %s to the queue, position is %d', runId, startingPosition);
|
||||
|
||||
if (startingPosition === 0) {
|
||||
|
||||
// The queue is empty, let's run immediatly
|
||||
queue.push({
|
||||
runId: runId
|
||||
});
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
} else {
|
||||
|
||||
queue.push({
|
||||
runId: runId,
|
||||
positionChangedCallback: function(position) {
|
||||
deferred.notify(position);
|
||||
},
|
||||
itIsTimeCallback: function() {
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var promise = deferred.promise;
|
||||
promise.startingPosition = startingPosition;
|
||||
return promise;
|
||||
};
|
||||
|
||||
|
||||
this.getPosition = function(runId) {
|
||||
// Position 0 means it's a work in progress (a run is removed AFTER it is finished, not before)
|
||||
var position = -1;
|
||||
|
||||
queue.some(function(run, index) {
|
||||
if (run.runId === runId) {
|
||||
position = index;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
|
||||
this.remove = function(runId) {
|
||||
var position = this.getPosition(runId);
|
||||
if (position >= 0) {
|
||||
queue.splice(position, 1);
|
||||
}
|
||||
|
||||
// Update other runs' positions
|
||||
queue.forEach(function(run, index) {
|
||||
if (index === 0 && run.itIsTimeCallback) {
|
||||
run.itIsTimeCallback();
|
||||
} else if (index > 0 && run.positionChangedCallback) {
|
||||
run.positionChangedCallback(index);
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
this.length = function() {
|
||||
return queue.length;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = RunsQueue;
|
||||
@@ -0,0 +1,87 @@
|
||||
var config = (process.env.IS_TEST) ? require('../../../test/fixtures/settings.json') : require('../../../server_config/settings.json');
|
||||
|
||||
var debug = require('debug')('apiLimitsMiddleware');
|
||||
|
||||
|
||||
var apiLimitsMiddleware = function(req, res, next) {
|
||||
'use strict';
|
||||
|
||||
var ipAddress = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
|
||||
debug('Entering API Limits Middleware with IP address %s', ipAddress);
|
||||
|
||||
if (req.path.indexOf('/api/') === 0 && !res.locals.hasApiKey) {
|
||||
|
||||
|
||||
if (req.path === '/api/runs') {
|
||||
|
||||
if (!runsTable.accepts(ipAddress)) {
|
||||
// Sorry :/
|
||||
debug('Too many tests launched from IP address %s', ipAddress);
|
||||
res.status(429).send('Too many requests');
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!callsTable.accepts(ipAddress)) {
|
||||
// Sorry :/
|
||||
debug('Too many API requests from IP address %s', ipAddress);
|
||||
res.status(429).send('Too many requests');
|
||||
return;
|
||||
}
|
||||
|
||||
debug('Not blocked by the API limits');
|
||||
// It's ok for the moment
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
var RecordTable = function(maxPerDay) {
|
||||
var table = {};
|
||||
|
||||
// Check if the user overpassed the limit and save its visit
|
||||
this.accepts = function(ipAddress) {
|
||||
if (table[ipAddress]) {
|
||||
|
||||
this.cleanEntry(ipAddress);
|
||||
|
||||
debug('%d visits in the last 24 hours', table[ipAddress].length);
|
||||
|
||||
if (table[ipAddress].length >= maxPerDay) {
|
||||
return false;
|
||||
} else {
|
||||
table[ipAddress].push(Date.now());
|
||||
}
|
||||
|
||||
} else {
|
||||
table[ipAddress] = [];
|
||||
table[ipAddress].push(Date.now());
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Clean the table for this guy
|
||||
this.cleanEntry = function(ipAddress) {
|
||||
table[ipAddress] = table[ipAddress].filter(function(date) {
|
||||
return date > Date.now() - 1000*60*60*24;
|
||||
});
|
||||
};
|
||||
|
||||
// Clean the entire table once in a while
|
||||
this.removeOld = function() {
|
||||
for (var ipAddress in table) {
|
||||
this.cleanEntry(ipAddress);
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
// Init the records tables
|
||||
var runsTable = new RecordTable(config.maxAnonymousRunsPerDay);
|
||||
var callsTable = new RecordTable(config.maxAnonymousCallsPerDay);
|
||||
|
||||
module.exports = apiLimitsMiddleware;
|
||||
@@ -0,0 +1,42 @@
|
||||
var config = (process.env.IS_TEST) ? require('../../../test/fixtures/settings.json') : require('../../../server_config/settings.json');
|
||||
|
||||
var debug = require('debug')('authMiddleware');
|
||||
|
||||
|
||||
var authMiddleware = function(req, res, next) {
|
||||
'use strict';
|
||||
|
||||
if (req.path.indexOf('/api/') === 0) {
|
||||
|
||||
|
||||
if (req.headers && req.headers['x-api-key']) {
|
||||
|
||||
// Test if it's an authorized key
|
||||
if (isApiKeyValid(req.headers['x-api-key'])) {
|
||||
|
||||
// Come in!
|
||||
debug('Authorized key: %s', req.headers['x-api-key']);
|
||||
res.locals.hasApiKey = true;
|
||||
|
||||
} else {
|
||||
|
||||
// Sorry :/
|
||||
debug('Unauthorized key %s', req.headers['x-api-key']);
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debug('No authorization key');
|
||||
// It's ok for the moment but you might be blocked by the apiLimitsMiddleware, dude
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
function isApiKeyValid(apiKey) {
|
||||
return (config.authorizedKeys[apiKey]) ? true : false;
|
||||
}
|
||||
|
||||
module.exports = authMiddleware;
|
||||
@@ -0,0 +1,12 @@
|
||||
var wwwRedirectMiddleware = function(req, res, next) {
|
||||
'use strict';
|
||||
|
||||
// Redirect www.yellowlab.tools to yellowlab.tools without "www" (for SEO purpose)
|
||||
if(/^www\.yellowlab\.tools/.test(req.headers.host)) {
|
||||
res.redirect(301, req.protocol + '://' + req.headers.host.replace(/^www\./, '') + req.url);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = wwwRedirectMiddleware;
|
||||
@@ -0,0 +1,92 @@
|
||||
var debug = require('debug')('ylt:colorDiff');
|
||||
var diff = require('color-diff');
|
||||
var parse = require('parse-color');
|
||||
|
||||
|
||||
var colorDiff = function() {
|
||||
|
||||
var COLOR_DIFF_THRESHOLD = 0.7;
|
||||
|
||||
this.compareAllColors = function(data) {
|
||||
debug('Starting to compare all colors...');
|
||||
|
||||
var offenders = data.toolsResults.phantomas.offenders.cssColors;
|
||||
var colors = (offenders) ? this.parseAllColors(offenders) : [];
|
||||
var result = [];
|
||||
|
||||
// Compare colors to each other
|
||||
for (var i = 0; i < colors.length - 1 ; i++) {
|
||||
for (var j = i + 1; j < colors.length; j++) {
|
||||
debug('Comparing color %s with color %s', colors[i].original, colors[j].original);
|
||||
if (this.compareTwoColors(colors[i], colors[j])) {
|
||||
debug('Colors are similar!');
|
||||
result.push({
|
||||
color1: colors[i].original,
|
||||
color2: colors[j].original,
|
||||
isDark: (colors[i].Lab.L < 60)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.toolsResults.colorDiff = {
|
||||
metrics: {
|
||||
similarColors: result.length
|
||||
},
|
||||
offenders: {
|
||||
similarColors: result
|
||||
}
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
this.parseAllColors = function(offenders) {
|
||||
var parsedOffenders = offenders.map(this.parseOffender);
|
||||
var deduplicatedColors = {};
|
||||
|
||||
parsedOffenders.forEach(function(color) {
|
||||
if (color !== null) {
|
||||
deduplicatedColors[color] = color;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(deduplicatedColors).map(this.parseColor);
|
||||
};
|
||||
|
||||
this.parseOffender = function(offender) {
|
||||
var regexResult = /^(.*) \(\d+ times\)$/.exec(offender.value.message);
|
||||
return regexResult ? regexResult[1] : null;
|
||||
};
|
||||
|
||||
this.parseColor = function(color) {
|
||||
var colorArray = parse(color).rgba;
|
||||
|
||||
var obj = {
|
||||
R: colorArray[0],
|
||||
G: colorArray[1],
|
||||
B: colorArray[2],
|
||||
A: colorArray[3]
|
||||
};
|
||||
|
||||
obj.Lab = diff.rgb_to_lab(obj);
|
||||
obj.original = color;
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
this.compareTwoColors = function(color1, color2) {
|
||||
if (color1.A !== color2.A) {
|
||||
debug('Comparison not possible, because the alpha channel is different');
|
||||
return false;
|
||||
}
|
||||
|
||||
var delta = diff.diff(color1.Lab, color2.Lab);
|
||||
debug('Delta is %d', delta);
|
||||
|
||||
return delta <= COLOR_DIFF_THRESHOLD;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
module.exports = new colorDiff();
|
||||
@@ -0,0 +1,53 @@
|
||||
var debug = require('debug')('ylt:domAccessAgregator');
|
||||
|
||||
var domAccessAgregator = function() {
|
||||
'use strict';
|
||||
|
||||
this.agregate = function(data) {
|
||||
debug('Starting to agregate DOM Accesses...');
|
||||
|
||||
let count = 0;
|
||||
let offenders = {
|
||||
byType: {}
|
||||
};
|
||||
|
||||
const metricsToGather = [
|
||||
'DOMqueriesById',
|
||||
'DOMqueriesByTagName',
|
||||
'DOMqueriesByClassName',
|
||||
'DOMqueriesByQuerySelectorAll',
|
||||
'DOMinserts',
|
||||
'DOMmutationsInserts',
|
||||
'DOMmutationsRemoves',
|
||||
'DOMmutationsAttributes',
|
||||
'eventsBound'
|
||||
];
|
||||
|
||||
metricsToGather.forEach(key => {
|
||||
|
||||
if (data.toolsResults.phantomas.metrics[key]) {
|
||||
count += data.toolsResults.phantomas.metrics[key];
|
||||
}
|
||||
|
||||
offenders.byType[key] = [];
|
||||
if (data.toolsResults.phantomas.offenders[key]) {
|
||||
offenders.byType[key] = data.toolsResults.phantomas.offenders[key];
|
||||
}
|
||||
});
|
||||
|
||||
data.toolsResults.domAccessAgregator = {
|
||||
metrics: {
|
||||
DOMaccesses: count
|
||||
},
|
||||
offenders: {
|
||||
DOMaccesses: offenders
|
||||
}
|
||||
};
|
||||
|
||||
debug('Done agregating DOM Accesses.');
|
||||
|
||||
return data;
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new domAccessAgregator();
|
||||
@@ -0,0 +1,168 @@
|
||||
var debug = require('debug')('ylt:mediaQueriesChecker');
|
||||
var parseMediaQuery = require('css-mq-parser');
|
||||
var offendersHelpers = require('../offendersHelpers');
|
||||
|
||||
|
||||
var mediaQueriesChecker = function() {
|
||||
'use strict';
|
||||
|
||||
var MOBILE_MIN_BREAKPOINT = 200;
|
||||
var MOBILE_MAX_BREAKPOINT = 300;
|
||||
|
||||
this.analyzeMediaQueries = function(data) {
|
||||
debug('Starting to check all media queries...');
|
||||
|
||||
var offenders = data.toolsResults.phantomas.offenders.cssMediaQueries;
|
||||
var mediaQueries = (offenders) ? this.parseAllMediaQueries(offenders) : [];
|
||||
|
||||
var notMobileFirstCount = 0;
|
||||
var notMobileFirstOffenders = [];
|
||||
|
||||
var breakpointsOffenders = {};
|
||||
|
||||
for (var i = 0; i < mediaQueries.length; i++) {
|
||||
var item = mediaQueries[i];
|
||||
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.isForMobile) {
|
||||
notMobileFirstCount += item.mediaQuery.rules;
|
||||
notMobileFirstOffenders.push(item.mediaQuery);
|
||||
}
|
||||
|
||||
for (var j = 0; j < item.breakpoints.length; j++) {
|
||||
var breakpointString = item.breakpoints[j].string;
|
||||
if (!breakpointsOffenders[breakpointString]) {
|
||||
breakpointsOffenders[breakpointString] = {
|
||||
count: 1,
|
||||
pixels: item.breakpoints[j].pixels
|
||||
};
|
||||
} else {
|
||||
breakpointsOffenders[breakpointString].count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.toolsResults.mediaQueriesChecker = {
|
||||
metrics: {
|
||||
cssMobileFirst: notMobileFirstCount,
|
||||
cssBreakpoints: Object.keys(breakpointsOffenders).length
|
||||
},
|
||||
offenders: {
|
||||
cssMobileFirst: notMobileFirstOffenders,
|
||||
cssBreakpoints: breakpointsOffenders
|
||||
}
|
||||
};
|
||||
|
||||
debug('End of media queries check');
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
this.parseAllMediaQueries = function(offenders) {
|
||||
return offenders.map(this.parseOneMediaQuery);
|
||||
};
|
||||
|
||||
this.parseOneMediaQuery = function(offender) {
|
||||
var splittedOffender = offendersHelpers.cssOffenderPattern(offender);
|
||||
var parts = /^@media (.*) \((\d+ rules)\)$/.exec(splittedOffender.css);
|
||||
|
||||
if (!parts) {
|
||||
debug('Failed to parse media query ' + offender);
|
||||
return false;
|
||||
}
|
||||
|
||||
var rulesCount = parseInt(parts[2], 10);
|
||||
var query = parts[1];
|
||||
|
||||
var isForMobile = false;
|
||||
var breakpoints = [];
|
||||
|
||||
try {
|
||||
|
||||
var ast = parseMediaQuery(query);
|
||||
|
||||
var min = 0;
|
||||
var max = Infinity;
|
||||
var pixels;
|
||||
|
||||
ast.forEach(function(astItem) {
|
||||
astItem.expressions.forEach(function(expression) {
|
||||
if (expression.feature === 'width' || expression.feature === 'device-width') {
|
||||
if (astItem.inverse === false) {
|
||||
if (expression.modifier === 'max') {
|
||||
pixels = toPixels(expression.value);
|
||||
max = Math.min(max, pixels);
|
||||
breakpoints.push({
|
||||
string: expression.value,
|
||||
pixels: pixels
|
||||
});
|
||||
} else if (expression.modifier === 'min') {
|
||||
pixels = toPixels(expression.value);
|
||||
min = Math.max(min, pixels);
|
||||
breakpoints.push({
|
||||
string: expression.value,
|
||||
pixels: pixels
|
||||
});
|
||||
}
|
||||
} else if (astItem.inverse === true) {
|
||||
if (expression.modifier === 'max') {
|
||||
pixels = toPixels(expression.value);
|
||||
min = Math.max(min, pixels);
|
||||
breakpoints.push({
|
||||
string: expression.value,
|
||||
pixels: pixels
|
||||
});
|
||||
} else if (expression.modifier === 'min') {
|
||||
pixels = toPixels(expression.value);
|
||||
max = Math.min(max, pixels);
|
||||
breakpoints.push({
|
||||
string: expression.value,
|
||||
pixels: pixels
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
isForMobile = (min <= MOBILE_MIN_BREAKPOINT && max >= MOBILE_MAX_BREAKPOINT && max !== Infinity);
|
||||
|
||||
} catch(error) {
|
||||
debug('Failed to parse media query ' + offender);
|
||||
}
|
||||
|
||||
return {
|
||||
mediaQuery: {
|
||||
query: query,
|
||||
rules: rulesCount,
|
||||
file: splittedOffender.file,
|
||||
line: splittedOffender.line,
|
||||
column: splittedOffender.column
|
||||
},
|
||||
isForMobile: isForMobile,
|
||||
breakpoints: breakpoints
|
||||
};
|
||||
};
|
||||
|
||||
// Parses a size in em, pt (or px) and returns it in px
|
||||
function toPixels(size) {
|
||||
var splittedSize = /^([\d\.]+)(.*)/.exec(size);
|
||||
var value = parseFloat(splittedSize[1]);
|
||||
var unit = splittedSize[2];
|
||||
|
||||
if (unit === 'em') {
|
||||
return value * 16;
|
||||
}
|
||||
|
||||
if (unit === 'pt') {
|
||||
return value / 12 * 16;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = new mediaQueriesChecker();
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Lists the number of different characters
|
||||
*/
|
||||
/* global document: true, Node: true, window: true */
|
||||
|
||||
exports.version = '1.0';
|
||||
|
||||
exports.module = function(phantomas) {
|
||||
'use strict';
|
||||
|
||||
//phantomas.setMetric('differentCharacters'); // @desc the number of different characters in the body @offenders
|
||||
|
||||
phantomas.on('report', function() {
|
||||
phantomas.log("charactersCount: starting to analyze characters on page...");
|
||||
|
||||
phantomas.evaluate(function() {
|
||||
(function(phantomas) {
|
||||
|
||||
phantomas.spyEnabled(false, 'analyzing characters on page');
|
||||
|
||||
function getLetterCount(arr){
|
||||
return arr.reduce(function(prev, next){
|
||||
prev[next] = 1;
|
||||
return prev;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if (document.body && document.body.textContent) {
|
||||
var allText = '';
|
||||
|
||||
// Traverse all DOM Tree to read text
|
||||
var runner = new phantomas.nodeRunner();
|
||||
runner.walk(document.body, function(node, depth) {
|
||||
switch (node.nodeType) {
|
||||
|
||||
// Grabing text nodes
|
||||
case Node.TEXT_NODE:
|
||||
if (node.parentNode.tagName !== 'SCRIPT' && node.parentNode.tagName !== 'STYLE') {
|
||||
allText += node.nodeValue;
|
||||
}
|
||||
break;
|
||||
|
||||
// Grabing CSS content properties
|
||||
case Node.ELEMENT_NODE:
|
||||
if (node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') {
|
||||
allText += window.getComputedStyle(node).getPropertyValue('content');
|
||||
allText += window.getComputedStyle(node, ':before').getPropertyValue('content');
|
||||
allText += window.getComputedStyle(node, ':after').getPropertyValue('content');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Reduce all text found in page into a string of unique chars
|
||||
var charactersList = getLetterCount(allText.split(''));
|
||||
var charsetString = Object.keys(charactersList).sort().join('');
|
||||
|
||||
// Remove blank characters
|
||||
charsetString = charsetString.replace(/\s/g, '');
|
||||
|
||||
phantomas.setMetric('differentCharacters', charsetString.length);
|
||||
phantomas.addOffender('differentCharacters', charsetString);
|
||||
}
|
||||
|
||||
phantomas.spyEnabled(true);
|
||||
}(window.__phantomas));
|
||||
});
|
||||
|
||||
phantomas.log("charactersCount: analyzing characters done.");
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Push items and count them
|
||||
*/
|
||||
|
||||
function collection() {
|
||||
/* jshint validthis: true */
|
||||
this.items = {};
|
||||
}
|
||||
|
||||
collection.prototype = {
|
||||
push: function(item) {
|
||||
if (typeof this.items[item] === 'undefined') {
|
||||
this.items[item] = {
|
||||
cnt: 1
|
||||
};
|
||||
} else {
|
||||
this.items[item].cnt++;
|
||||
}
|
||||
},
|
||||
|
||||
sort: function() {
|
||||
var newItems = {},
|
||||
sortedKeys;
|
||||
|
||||
// sort in descending order (by cnt)
|
||||
sortedKeys = Object.keys(this.items).sort((function(a, b) {
|
||||
return this.items[b].cnt - this.items[a].cnt;
|
||||
}).bind(this));
|
||||
|
||||
// build new items dictionary
|
||||
sortedKeys.forEach(function(key) {
|
||||
newItems[key] = this.items[key];
|
||||
}, this);
|
||||
|
||||
this.items = newItems;
|
||||
return this;
|
||||
},
|
||||
|
||||
forEach: function(callback) {
|
||||
Object.keys(this.items).forEach(function(key) {
|
||||
callback(key, this.items[key].cnt);
|
||||
}, this);
|
||||
},
|
||||
|
||||
has: function(item) {
|
||||
return (typeof this.items[item] !== 'undefined');
|
||||
},
|
||||
|
||||
getLength: function() {
|
||||
return Object.keys(this.items).length;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = collection;
|
||||
@@ -0,0 +1,99 @@
|
||||
var async = require('async');
|
||||
var Q = require('q');
|
||||
var ps = require('ps-node');
|
||||
var path = require('path');
|
||||
var debug = require('debug')('ylt:phantomaswrapper');
|
||||
var phantomas = require('phantomas');
|
||||
|
||||
|
||||
var PhantomasWrapper = function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* This is the phantomas launcher. It merges user chosen options into the default options
|
||||
*/
|
||||
this.execute = function(data) {
|
||||
|
||||
var deferred = Q.defer();
|
||||
var task = data.params;
|
||||
|
||||
var viewportOption = null;
|
||||
// Setting screen dimensions for desktop devices only.
|
||||
// Phone and tablet dimensions are dealt by Phantomas.
|
||||
if (task.options.device === 'desktop') {
|
||||
// Similar to an old non-retina Macbook Air 13"
|
||||
viewportOption = '1280x800x1';
|
||||
} else if (task.options.device === 'desktop-hd') {
|
||||
// Similar to a retina Macbook Pro 16"
|
||||
viewportOption = '1536x960x2';
|
||||
}
|
||||
|
||||
var options = {
|
||||
|
||||
// Cusomizable options
|
||||
'timeout': task.options.timeout || 120,
|
||||
'user-agent': (task.options.device === 'desktop') ? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) YLT Chrome/85.0.4183.121 Safari/537.36' : null,
|
||||
'tablet': (task.options.device === 'tablet'),
|
||||
'phone': (task.options.device === 'phone'),
|
||||
'screenshot': task.options.screenshot || false,
|
||||
'viewport': viewportOption,
|
||||
'wait-for-network-idle': true,
|
||||
'cookie': task.options.cookie,
|
||||
'auth-user': task.options.authUser,
|
||||
'auth-pass': task.options.authPass,
|
||||
'block-domain': task.options.blockDomain,
|
||||
'allow-domain': task.options.allowDomain,
|
||||
'no-externals': task.options.noExternals,
|
||||
|
||||
// Mandatory
|
||||
'analyze-css': true,
|
||||
'ignoreSslErrors': true
|
||||
};
|
||||
|
||||
|
||||
// Proxy option can't be set to null or undefined...
|
||||
// this is why it's set now and not in the object above
|
||||
if (task.options.proxy) {
|
||||
options.proxy = task.options.proxy;
|
||||
}
|
||||
|
||||
var debugCmd = 'DEBUG=* node node_modules/phantomas/bin/phantomas.js --url ' + task.url;
|
||||
Object.keys(options).forEach(function(key) {
|
||||
if (key !== 'wait-for-network-idle' && options[key] !== null) {
|
||||
debugCmd += ' --' + key + ' ' + options[key];
|
||||
}
|
||||
});
|
||||
debug('If you want to run Phantomas alone for debugging purpose, this is the command: %s', debugCmd);
|
||||
|
||||
// It's time to launch the test!!!
|
||||
|
||||
const promise = phantomas(task.url, options);
|
||||
|
||||
// handle the promise
|
||||
promise.
|
||||
then(results => {
|
||||
var json = {
|
||||
generator: results.getGenerator(),
|
||||
url: results.getUrl(),
|
||||
metrics: results.getMetrics(),
|
||||
offenders: results.getAllOffenders()
|
||||
};
|
||||
|
||||
deferred.resolve(json);
|
||||
}).
|
||||
catch(res => {
|
||||
console.error(res);
|
||||
deferred.reject('Phantomas failed: ' + res.message);
|
||||
});
|
||||
|
||||
promise.on('milestone', function(event) {
|
||||
if (event === 'domReady' || event === 'domComplete') {
|
||||
deferred.notify(event);
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new PhantomasWrapper();
|
||||
@@ -0,0 +1,107 @@
|
||||
var debug = require('debug')('ylt:brotliCompressor');
|
||||
|
||||
var Q = require('q');
|
||||
var zlib = require('zlib');
|
||||
|
||||
var gzipCompressor = require('./gzipCompressor');
|
||||
|
||||
|
||||
var GzipCompressor = function() {
|
||||
|
||||
var BROTLI_COMPRESSION_LEVEL = 9;
|
||||
|
||||
function compressFile(entry) {
|
||||
debug('Entering brotli compressor');
|
||||
return brotlifyFile(entry)
|
||||
|
||||
.then(brotliOptimizedFile);
|
||||
}
|
||||
|
||||
// Compress with Brotli files that were not compressed or not with brotli (with gzip or deflate)
|
||||
function brotlifyFile(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (gzipCompressor.entryTypeCanBeCompressed(entry) && entry.weightCheck && entry.weightCheck.bodyBuffer) {
|
||||
|
||||
if (!entry.weightCheck.isCompressed) {
|
||||
debug('File %s was not compressed at all, trying Brotli over it.', entry.url);
|
||||
} else if (entry.weightCheck.compressionTool !== 'brotli') {
|
||||
debug('File %s was compressed with %s. Trying with Brotli.', entry.url, entry.weightCheck.compressionTool);
|
||||
}
|
||||
|
||||
zlib.brotliCompress(entry.weightCheck.bodyBuffer, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 9
|
||||
}
|
||||
}, function(err, buffer) {
|
||||
if (err) {
|
||||
debug('Could not compress uncompressed file with brotli');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
var compressedSize = buffer.length;
|
||||
|
||||
if (!entry.weightCheck.isCompressed) {
|
||||
debug('Brotli size is %d, was %d, this is %d% better.', compressedSize, entry.weightCheck.bodySize, Math.round((entry.weightCheck.bodySize - compressedSize) * 100 / entry.weightCheck.bodySize));
|
||||
} else if (entry.weightCheck.compressionTool !== 'brotli') {
|
||||
debug('Brotli size is %d, was %d with %s, this is %d% better.', compressedSize, entry.weightCheck.bodySize, entry.weightCheck.compressionTool, Math.round((entry.weightCheck.bodySize - compressedSize) * 100 / entry.weightCheck.bodySize));
|
||||
}
|
||||
|
||||
entry.weightCheck.afterBrotliCompression = compressedSize;
|
||||
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
debug('Compression not needed');
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Gzip a file after minification or optimization if this step was successful
|
||||
function brotliOptimizedFile(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (gzipCompressor.entryTypeCanBeCompressed(entry) && entry.weightCheck && entry.weightCheck.isOptimized === false) {
|
||||
debug('Trying to brotlify file after minification: %s', entry.url);
|
||||
|
||||
var uncompressedSize = entry.weightCheck.optimized;
|
||||
|
||||
zlib.brotliCompress(Buffer.from(entry.weightCheck.bodyAfterOptimization, 'utf8'), {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 9
|
||||
}
|
||||
}, function(err, buffer) {
|
||||
if (err) {
|
||||
debug('Could not compress minified file with brotli');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
var compressedSize = buffer.length;
|
||||
|
||||
debug('Correctly brotlified the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize);
|
||||
entry.weightCheck.afterOptimizationAndBrotliCompression = compressedSize;
|
||||
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
debug('Compressing optimized file not needed');
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
return {
|
||||
compressFile: compressFile
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new GzipCompressor();
|
||||
@@ -0,0 +1,150 @@
|
||||
var debug = require('debug')('ylt:cdnDetector');
|
||||
var Q = require('q');
|
||||
var url = require('url');
|
||||
var dns = require('dns');
|
||||
var cdnDetector = require('cdn-detector');
|
||||
|
||||
// This module checks my multiple ways if a request was served by a CDN
|
||||
|
||||
var CDNDetector = function() {
|
||||
|
||||
function detectCDN(entry) {
|
||||
var urlObject = url.parse(entry.url);
|
||||
debug('Start checking CDN for %s, host is %s', entry.url, urlObject.host);
|
||||
|
||||
// 1st, the fastest check: the domain is directly recognized as a CDN
|
||||
return checkDomainDirectly(entry)
|
||||
|
||||
.fail(function() {
|
||||
|
||||
// 2nd, check the response headers
|
||||
return checkHeaders(entry)
|
||||
|
||||
.fail(function() {
|
||||
|
||||
// 3rd, check the Name Servers
|
||||
return checkNameServers(entry)
|
||||
|
||||
.fail(function(entry) {
|
||||
|
||||
// None of the above worked, we didn't find any trace of a CDN
|
||||
return entry;
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
detectCDN: detectCDN
|
||||
};
|
||||
|
||||
// Compares the URL's domain to a list of know CDN, just in case
|
||||
function checkDomainDirectly(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
var result = checkHostname(url.parse(entry.url).host);
|
||||
|
||||
if (result === null) {
|
||||
deferred.reject(entry);
|
||||
} else {
|
||||
entry.weightCheck.cdn = result;
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Compares a list of HTTP response headers to a matching list
|
||||
// For example, if there is an X-Akamai-Request-Id header, we know it is Akamai
|
||||
function checkHeaders(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
var result = cdnDetector.detectFromHeaders(entry.weightCheck.headers);
|
||||
debug('Headers of %s were compared to the list. Result: %s', entry.url, JSON.stringify(result));
|
||||
|
||||
if (result === null) {
|
||||
deferred.reject(entry);
|
||||
} else {
|
||||
entry.weightCheck.cdn = result.cdn;
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function checkNameServers(entry) {
|
||||
var deferred = Q.defer();
|
||||
var hostname = url.parse(entry.url).host;
|
||||
|
||||
// Send a request to get the Name Servers list
|
||||
// https://nodejs.org/api/dns.html#dns_dns_resolvens_hostname_callback
|
||||
dns.resolveNs(hostname, function(error, nameServers) {
|
||||
if (error) {
|
||||
debug(error);
|
||||
debug('Could not find Name Servers for %s', hostname);
|
||||
deferred.reject(entry);
|
||||
} else {
|
||||
debug('Name Servers for %s are ["%s"]', hostname, nameServers.join('", "'));
|
||||
|
||||
var cdn = nameServers.map(checkHostname).find(function(entry) {
|
||||
return entry !== null;
|
||||
});
|
||||
|
||||
if (cdn) {
|
||||
entry.weightCheck.cdn = cdn;
|
||||
deferred.resolve(entry);
|
||||
} else {
|
||||
deferred.reject(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Compares the hostname to a matching list of hostnames
|
||||
function checkHostname(hostname) {
|
||||
var result = cdnDetector.detectFromHostname(hostname);
|
||||
debug('Hostname %s was compared to the list. Result: %s', hostname, JSON.stringify(result));
|
||||
return result ? result.cdn : null;
|
||||
}
|
||||
|
||||
// Find an IP address with a DNS lookup
|
||||
function getIP(hostname) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
// https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback
|
||||
dns.lookup(hostname, {family: 4}, function(error, address) {
|
||||
if (error) {
|
||||
debug('Could not find IP address for %s', hostname);
|
||||
} else {
|
||||
debug('The IP address for %s is %s', hostname, address);
|
||||
}
|
||||
deferred.resolve(error ? null : address);
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Compares the IP address agains a list of IP ranges
|
||||
function checkIPAddress(address) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
var result = cdnDetector.detectFromHeaders(headers);
|
||||
debug('Headers were compared to the list. Result: %s', JSON.stringify(result));
|
||||
|
||||
if (result === null) {
|
||||
deferred.reject();
|
||||
} else {
|
||||
deferred.resolve(result.cdn);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function saveCDN(cdnName) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = new CDNDetector();
|
||||
@@ -0,0 +1,71 @@
|
||||
var http = require('https');
|
||||
var zlib = require('zlib');
|
||||
var fs = require('fs');
|
||||
|
||||
var CDN_ASN_LIST = {
|
||||
13335: 'Cloudflare',
|
||||
15133: 'Verizon',
|
||||
16625: 'Akamai',
|
||||
20446: 'StackPath',
|
||||
20940: 'Akamai',
|
||||
22822: 'Limelight',
|
||||
54113: 'Fastly'
|
||||
};
|
||||
|
||||
// Downloads a file and ungzip ip directly
|
||||
function getGzipped(url, callback) {
|
||||
var buffer = [];
|
||||
http.get(url, function(res) {
|
||||
var gunzip = zlib.createGunzip();
|
||||
res.pipe(gunzip);
|
||||
gunzip.on('data', function(data) {
|
||||
buffer.push(data.toString())
|
||||
}).on('end', function() {
|
||||
callback(null, buffer.join(''));
|
||||
}).on('error', function(e) {
|
||||
callback(e);
|
||||
})
|
||||
}).on('error', function(e) {
|
||||
callback(e)
|
||||
});
|
||||
}
|
||||
|
||||
// File provided here: https://iptoasn.com/
|
||||
console.log('Start downloading the file...');
|
||||
getGzipped('https://iptoasn.com/data/ip2asn-v4.tsv.gz', function(err, data) {
|
||||
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Download complete, let\'s start parsing...');
|
||||
parseFile(data);
|
||||
});
|
||||
|
||||
// Parse the CSV formated file and grag only the interesting IP ranges
|
||||
function parseFile(data) {
|
||||
var results = [];
|
||||
|
||||
var allTextLines = data.split(/\r\n|\n/);
|
||||
for (var i = 0; i < allTextLines.length; i++) {
|
||||
var lineData = allTextLines[i].split('\t');
|
||||
|
||||
var cdnName = CDN_ASN_LIST[lineData[2]];
|
||||
if (cdnName) {
|
||||
|
||||
// We found an ASN number from our list. Let's save it!
|
||||
results.push({
|
||||
rangeStart: lineData[0],
|
||||
rangeEnd: lineData[1],
|
||||
cdn: cdnName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('%d IP ranges found', results.length);
|
||||
|
||||
var outputFile = __dirname + '/cdn-ip-list.json';
|
||||
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2));
|
||||
console.log('File saved here: %s', outputFile);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
var debug = require('debug')('ylt:contentTypeChecker');
|
||||
var Q = require('q');
|
||||
var isJpg = require('is-jpg');
|
||||
var isPng = require('is-png');
|
||||
var isSvg = require('is-svg');
|
||||
var isGif = require('is-gif');
|
||||
var isWebp = require('is-webp');
|
||||
var isWoff = require('is-woff');
|
||||
var isWoff2 = require('is-woff2');
|
||||
var isOtf = require('is-otf');
|
||||
var isTtf = require('is-ttf');
|
||||
var isEot = require('is-eot');
|
||||
var isJson = require('is-json');
|
||||
|
||||
var ContentTypeChecker = function() {
|
||||
|
||||
function checkContentType(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
// Setting isSomething values:
|
||||
switch(entry.type) {
|
||||
case 'html':
|
||||
entry.isHTML = true;
|
||||
break;
|
||||
case 'xml':
|
||||
entry.isXML = true;
|
||||
break;
|
||||
case 'css':
|
||||
entry.isCSS = true;
|
||||
break;
|
||||
case 'js':
|
||||
entry.isJS = true;
|
||||
break;
|
||||
case 'json':
|
||||
entry.isJSON = true;
|
||||
break;
|
||||
case 'image':
|
||||
entry.isImage = true;
|
||||
break;
|
||||
case 'webfont':
|
||||
entry.isWebFont = true;
|
||||
break;
|
||||
case 'video':
|
||||
entry.isVideo = true;
|
||||
break;
|
||||
case 'favicon':
|
||||
entry.isFavicon = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Now let's check for mistakes by analysing body content. It happens more often then we think!
|
||||
|
||||
// Ignore very small files as they are generally tracking pixels
|
||||
if (entry.weightCheck && entry.weightCheck.bodyBuffer && entry.weightCheck.bodySize > 100) {
|
||||
var foundType;
|
||||
|
||||
try {
|
||||
foundType = findContentType(entry.weightCheck.bodyBuffer);
|
||||
|
||||
// If it's an image or a font, then rewrite.
|
||||
if (foundType !== null && (foundType.type === 'image' || foundType.type === 'webfont' || foundType.type === 'json')) {
|
||||
if (foundType.type !== entry.type) {
|
||||
debug('Content type %s is wrong for %s. It should be %s.', entry.type, entry.ulr, foundType.type);
|
||||
}
|
||||
rewriteContentType(entry, foundType);
|
||||
}
|
||||
|
||||
} catch(err) {
|
||||
debug('Error while analyzing the contentType of %s', entry.url);
|
||||
debug(err);
|
||||
}
|
||||
}
|
||||
|
||||
deferred.resolve(entry);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function findContentType(bodyBuffer) {
|
||||
var bodyStr = bodyBuffer.toString();
|
||||
|
||||
if (isJpg(bodyBuffer)) {
|
||||
return contentTypes.jpeg;
|
||||
}
|
||||
|
||||
if (isPng(bodyBuffer)) {
|
||||
return contentTypes.png;
|
||||
}
|
||||
|
||||
// https://github.com/sindresorhus/is-svg/issues/7
|
||||
if (/<svg/.test(bodyStr) && isSvg(bodyStr)) {
|
||||
return contentTypes.svg;
|
||||
}
|
||||
|
||||
if (isGif(bodyBuffer)) {
|
||||
return contentTypes.gif;
|
||||
}
|
||||
|
||||
if (isWebp(bodyBuffer)) {
|
||||
return contentTypes.webp;
|
||||
}
|
||||
|
||||
if (isWoff(bodyBuffer)) {
|
||||
return contentTypes.woff;
|
||||
}
|
||||
|
||||
if (isWoff2(bodyBuffer)) {
|
||||
return contentTypes.woff2;
|
||||
}
|
||||
|
||||
if (isOtf(bodyBuffer)) {
|
||||
return contentTypes.otf;
|
||||
}
|
||||
|
||||
if (isTtf(bodyBuffer)) {
|
||||
return contentTypes.ttf;
|
||||
}
|
||||
|
||||
if (isEot(bodyBuffer)) {
|
||||
return contentTypes.eot;
|
||||
}
|
||||
|
||||
if (isJson(bodyStr)) {
|
||||
return contentTypes.json;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function rewriteContentType(entry, contentTypeObj) {
|
||||
delete(entry.isHTML);
|
||||
delete(entry.isXML);
|
||||
delete(entry.isCSS);
|
||||
delete(entry.isJS);
|
||||
delete(entry.isJSON);
|
||||
delete(entry.isImage);
|
||||
delete(entry.isSVG);
|
||||
delete(entry.isVideo);
|
||||
delete(entry.isWebFont);
|
||||
delete(entry.isTTF);
|
||||
delete(entry.isFavicon);
|
||||
|
||||
entry.contentType = contentTypeObj.mimes[0];
|
||||
contentTypeObj.updateFn(entry);
|
||||
}
|
||||
|
||||
var contentTypes = {
|
||||
jpeg: {
|
||||
type: 'image',
|
||||
mimes: ['image/jpeg'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'image';
|
||||
entry.isImage = true;
|
||||
}
|
||||
},
|
||||
png: {
|
||||
type: 'image',
|
||||
mimes: ['image/png'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'image';
|
||||
entry.isImage = true;
|
||||
}
|
||||
},
|
||||
svg: {
|
||||
type: 'image',
|
||||
mimes: ['image/svg+xml'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'image';
|
||||
entry.isImage = true;
|
||||
entry.isSVG = true;
|
||||
}
|
||||
},
|
||||
gif: {
|
||||
type: 'image',
|
||||
mimes: ['image/gif'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'image';
|
||||
entry.isImage = true;
|
||||
}
|
||||
},
|
||||
webp: {
|
||||
type: 'image',
|
||||
mimes: ['image/webp'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'image';
|
||||
entry.isImage = true;
|
||||
}
|
||||
},
|
||||
woff: {
|
||||
type: 'webfont',
|
||||
mimes: ['application/x-font-woff', 'application/font-woff', 'font/woff'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'webfont';
|
||||
entry.isWebFont = true;
|
||||
entry.isWoff = true;
|
||||
}
|
||||
},
|
||||
woff2: {
|
||||
type: 'webfont',
|
||||
mimes: ['font/woff2', 'application/x-font-woff2', 'application/font-woff2'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'webfont';
|
||||
entry.isWebFont = true;
|
||||
entry.isWoff2 = true;
|
||||
}
|
||||
},
|
||||
otf: {
|
||||
type: 'webfont',
|
||||
mimes: ['application/x-font-otf', 'font/otf', 'font/opentype', 'application/x-font-opentype'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'webfont';
|
||||
entry.isWebFont = true;
|
||||
}
|
||||
},
|
||||
ttf: {
|
||||
type: 'webfont',
|
||||
mimes: ['application/x-font-ttf', 'font/ttf', 'application/x-font-truetype'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'webfont';
|
||||
entry.isWebFont = true;
|
||||
entry.isTTF = true;
|
||||
}
|
||||
},
|
||||
eot: {
|
||||
type: 'webfont',
|
||||
mimes: ['application/vnd.ms-fontobject', 'font/eot'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'webfont';
|
||||
entry.isWebFont = true;
|
||||
}
|
||||
},
|
||||
json: {
|
||||
type: 'json',
|
||||
mimes: ['application/json'],
|
||||
updateFn: function(entry) {
|
||||
entry.type = 'json';
|
||||
entry.isJSON = true;
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
return {
|
||||
checkContentType: checkContentType,
|
||||
findContentType: findContentType
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new ContentTypeChecker();
|
||||
@@ -0,0 +1,256 @@
|
||||
var debug = require('debug')('ylt:fileMinifier');
|
||||
|
||||
var Q = require('q');
|
||||
var UglifyJS = require('uglify-js');
|
||||
var CleanCSS = require('clean-css');
|
||||
var htmlMinifier = require('html-minifier');
|
||||
|
||||
|
||||
var FileMinifier = function() {
|
||||
|
||||
function minifyFile(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
|
||||
// No valid file available
|
||||
deferred.resolve(entry);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
var fileSize = entry.weightCheck.uncompressedSize;
|
||||
var bodyString = entry.weightCheck.bodyBuffer.toString();
|
||||
|
||||
debug('Let\'s try to optimize %s', entry.url);
|
||||
debug('Current file size is %d', fileSize);
|
||||
var startTime = Date.now();
|
||||
|
||||
if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(bodyString)) {
|
||||
|
||||
debug('File is a JS');
|
||||
|
||||
return minifyJs(bodyString)
|
||||
|
||||
.then(function(newFile) {
|
||||
if (!newFile) {
|
||||
debug('Optimization didn\'t work');
|
||||
return entry;
|
||||
}
|
||||
|
||||
var endTime = Date.now();
|
||||
|
||||
var newFileSize = newFile.length;
|
||||
|
||||
debug('JS minification complete for %s', entry.url);
|
||||
|
||||
if (gainIsEnough(fileSize, newFileSize)) {
|
||||
entry.weightCheck.bodyAfterOptimization = newFile;
|
||||
entry.weightCheck.optimized = newFileSize;
|
||||
entry.weightCheck.isOptimized = false;
|
||||
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
|
||||
}
|
||||
|
||||
return entry;
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
return entry;
|
||||
});
|
||||
|
||||
} else if (entry.isCSS) {
|
||||
|
||||
debug('File is a CSS');
|
||||
|
||||
return minifyCss(entry.weightCheck.bodyBuffer.toString())
|
||||
|
||||
.then(function(newFile) {
|
||||
if (!newFile) {
|
||||
debug('Optimization didn\'t work');
|
||||
return entry;
|
||||
}
|
||||
|
||||
var endTime = Date.now();
|
||||
debug('CSS minification took %dms', endTime - startTime);
|
||||
|
||||
var newFileSize = newFile.length;
|
||||
|
||||
debug('CSS minification complete for %s', entry.url);
|
||||
|
||||
if (gainIsEnough(fileSize, newFileSize)) {
|
||||
entry.weightCheck.bodyAfterOptimization = newFile;
|
||||
entry.weightCheck.optimized = newFileSize;
|
||||
entry.weightCheck.isOptimized = false;
|
||||
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
|
||||
}
|
||||
|
||||
return entry;
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
return entry;
|
||||
});
|
||||
|
||||
} else if (entry.isHTML) {
|
||||
|
||||
debug('File is an HTML');
|
||||
|
||||
return minifyHtml(entry.weightCheck.bodyBuffer.toString())
|
||||
|
||||
.then(function(newFile) {
|
||||
if (!newFile) {
|
||||
debug('Optimization didn\'t work');
|
||||
return entry;
|
||||
}
|
||||
|
||||
var endTime = Date.now();
|
||||
debug('HTML minification took %dms', endTime - startTime);
|
||||
|
||||
var newFileSize = newFile.length;
|
||||
|
||||
debug('HTML minification complete for %s', entry.url);
|
||||
|
||||
if (gainIsEnough(fileSize, newFileSize)) {
|
||||
entry.weightCheck.bodyAfterOptimization = newFile;
|
||||
entry.weightCheck.optimized = newFileSize;
|
||||
entry.weightCheck.isOptimized = false;
|
||||
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
|
||||
}
|
||||
|
||||
return entry;
|
||||
})
|
||||
|
||||
.fail(function(err) {
|
||||
return entry;
|
||||
});
|
||||
|
||||
} else {
|
||||
debug('Not minifiable type or already minified');
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// The gain is estimated of enough value if it's over 2KB or over 20%,
|
||||
// but it's ignored if is below 400 bytes
|
||||
function gainIsEnough(oldWeight, newWeight) {
|
||||
var gain = oldWeight - newWeight;
|
||||
var ratio = gain / oldWeight;
|
||||
return (gain > 2096 || (ratio > 0.2 && gain > 400));
|
||||
}
|
||||
|
||||
// Uglify
|
||||
function minifyJs(body) {
|
||||
|
||||
var deferred = Q.defer();
|
||||
var startTime = Date.now();
|
||||
|
||||
var result = UglifyJS.minify(body, {
|
||||
// Only do the compression step for smaller files
|
||||
// otherwise it can take a very long time compared to the gain
|
||||
compress: (body.length < 200*1024)
|
||||
});
|
||||
|
||||
var endTime = Date.now();
|
||||
debug('Uglify took %dms', endTime - startTime);
|
||||
deferred.resolve(result.code);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Clear-css
|
||||
function minifyCss(body) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
try {
|
||||
var result = new CleanCSS({compatibility: 'ie8'}).minify(body);
|
||||
deferred.resolve(result.styles);
|
||||
} catch(err) {
|
||||
deferred.reject(err);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// HTMLMinifier
|
||||
function minifyHtml(body) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
try {
|
||||
var result = htmlMinifier.minify(body, {
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
continueOnParseError: true,
|
||||
decodeEntities: true,
|
||||
minifyCSS: true,
|
||||
minifyJS: true,
|
||||
preserveLineBreaks: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
removeScriptTypeAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true
|
||||
});
|
||||
deferred.resolve(result);
|
||||
} catch(err) {
|
||||
deferred.reject(err);
|
||||
}
|
||||
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Avoid losing time trying to compress some JS libraries known as already compressed
|
||||
function isKnownAsMinified(url) {
|
||||
var result = false;
|
||||
|
||||
// Twitter
|
||||
result = result || /^https?:\/\/platform\.twitter\.com\/widgets\.js/.test(url);
|
||||
|
||||
// Facebook
|
||||
result = result || /^https:\/\/connect\.facebook\.net\/[^\/]*\/(sdk|all)\.js/.test(url);
|
||||
|
||||
// Google +1
|
||||
result = result || /^https:\/\/apis\.google\.com\/js\/plusone\.js/.test(url);
|
||||
|
||||
// jQuery CDN
|
||||
result = result || /^https?:\/\/code\.jquery\.com\/.*\.min.js/.test(url);
|
||||
|
||||
// Google Analytics
|
||||
result = result || /^https?:\/\/(www|ssl)\.google-analytics\.com\/(.*)\.js/.test(url);
|
||||
|
||||
if (result === true) {
|
||||
debug('This file is known as already minified. Skipping minification: %s', url);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Avoid losing time trying to compress JS files if they already look minified
|
||||
// by counting the number of lines compared to the total size.
|
||||
// Less than 2KB per line is suspicious
|
||||
function looksAlreadyMinified(code) {
|
||||
var linesCount = code.split(/\r\n|\r|\n/).length;
|
||||
var linesRatio = code.length / linesCount;
|
||||
var looksMinified = linesRatio > 2 * 1024;
|
||||
|
||||
debug('Lines ratio is %d bytes per line', Math.round(linesRatio));
|
||||
debug(looksMinified ? 'It looks already minified' : 'It doesn\'t look minified');
|
||||
|
||||
return looksMinified;
|
||||
}
|
||||
|
||||
function entryTypeCanBeMinified(entry) {
|
||||
return entry.isJS || entry.isCSS || entry.isHTML;
|
||||
}
|
||||
|
||||
return {
|
||||
minifyFile: minifyFile,
|
||||
minifyJs: minifyJs,
|
||||
minifyCss: minifyCss,
|
||||
minifyHtml: minifyHtml,
|
||||
gainIsEnough: gainIsEnough,
|
||||
entryTypeCanBeMinified: entryTypeCanBeMinified,
|
||||
isKnownAsMinified: isKnownAsMinified
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new FileMinifier();
|
||||
@@ -0,0 +1,470 @@
|
||||
var debug = require('debug')('ylt:fontAnalyzer');
|
||||
|
||||
var Q = require('q');
|
||||
var fontkit = require('fontkit');
|
||||
var woffTools = require('woff-tools');
|
||||
var ttf2woff2 = require('ttf2woff2');
|
||||
|
||||
var FontAnalyzer = function() {
|
||||
|
||||
function analyzeFont(entry, charsListOnPage) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
|
||||
// No valid file available
|
||||
deferred.resolve(entry);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
if (entry.isWebFont) {
|
||||
debug('File %s is a font. Let\'s have a look inside!', entry.url);
|
||||
|
||||
convertToWoff2(entry)
|
||||
|
||||
.then(function(entry) {
|
||||
return getMetricsFromFont(entry, charsListOnPage);
|
||||
})
|
||||
|
||||
.then(function(fontMetrics) {
|
||||
entry.fontMetrics = fontMetrics;
|
||||
})
|
||||
|
||||
.fail(function(error) {
|
||||
debug('Could not open the font: %s', error);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
deferred.resolve(entry);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function convertToWoff2(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
debug('Entering font format converter...');
|
||||
|
||||
var fileSize = entry.weightCheck.bodySize;
|
||||
var ttf;
|
||||
var woff2;
|
||||
var newFileSize;
|
||||
|
||||
if (entry.isWoff2) {
|
||||
|
||||
debug('File is already a woff2.');
|
||||
deferred.resolve(entry);
|
||||
|
||||
} else if (fileSize > 1024 * 1024) {
|
||||
|
||||
// Don't try to convert huge font files, it would block the server for several minutes
|
||||
debug('Font is too big, skipping conversion.');
|
||||
deferred.resolve(entry);
|
||||
|
||||
} else if (entry.isWoff) {
|
||||
|
||||
debug('File is a woff. Let\'s convert to woff2');
|
||||
|
||||
try {
|
||||
|
||||
debug('Current file size is %d', fileSize);
|
||||
|
||||
ttf = woffTools.toSfnt(entry.weightCheck.bodyBuffer);
|
||||
woff2 = ttf2woff2(ttf);
|
||||
newFileSize = woff2.length;
|
||||
|
||||
debug('New font size is %d', newFileSize);
|
||||
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
|
||||
entry.weightCheck.sizeAsWoff2 = newFileSize;
|
||||
|
||||
deferred.resolve(entry);
|
||||
|
||||
} catch(error) {
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
} else if (entry.isTTF) {
|
||||
|
||||
debug('File is a TTF. Let\'s convert to woff2');
|
||||
|
||||
try {
|
||||
|
||||
debug('Current file size is %d', fileSize);
|
||||
|
||||
woff2 = ttf2woff2(entry.weightCheck.bodyBuffer);
|
||||
newFileSize = woff2.length;
|
||||
|
||||
debug('New image size is %d', newFileSize);
|
||||
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
|
||||
entry.weightCheck.sizeAsWoff2 = newFileSize;
|
||||
|
||||
deferred.resolve(entry);
|
||||
|
||||
} catch(error) {
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Other font formats are not handled
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// The gain is estimated of enough value if it's over 1KB or over 20%,
|
||||
// but it's ignored if is below 100 bytes
|
||||
function gainIsEnough(oldWeight, newWeight) {
|
||||
var gain = oldWeight - newWeight;
|
||||
var ratio = gain / oldWeight;
|
||||
return (gain > 2048 || (ratio > 0.2 && gain > 100));
|
||||
}
|
||||
|
||||
function getMetricsFromFont(entry, charsListOnPage) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
try {
|
||||
var startTime = Date.now();
|
||||
var font = fontkit.create(entry.weightCheck.bodyBuffer);
|
||||
|
||||
var result = {
|
||||
name: font.fullName || font.postscriptName || font.familyName,
|
||||
numGlyphs: font.numGlyphs,
|
||||
averageGlyphComplexity: getAverageGlyphComplexity(font),
|
||||
compressedWeight: entry.weightCheck.afterGzipCompression || entry.weightCheck.bodySize,
|
||||
unicodeRanges: readUnicodeRanges(font.characterSet, charsListOnPage),
|
||||
numGlyphsInCommonWithPageContent: countPossiblyUsedGlyphs(getCharacterSetAsString(font.characterSet), charsListOnPage)
|
||||
};
|
||||
|
||||
var endTime = Date.now();
|
||||
debug('Font analysis took %dms', endTime - startTime);
|
||||
|
||||
// Mark fonts that are not used on the page (#224)
|
||||
var fontIsUsed = false;
|
||||
for (var range in result.unicodeRanges) {
|
||||
if (result.unicodeRanges[range].numGlyphsInCommonWithPageContent > 0) {
|
||||
fontIsUsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.isUsed = fontIsUsed;
|
||||
|
||||
deferred.resolve(result);
|
||||
} catch(error) {
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Reads the number of vector commands (complexity) needed to render glyphs and
|
||||
// returns the average. Only first 100 glyphes are tested, otherwise it would take tool long;
|
||||
function getAverageGlyphComplexity(font) {
|
||||
var max = Math.min(font.numGlyphs, 100);
|
||||
|
||||
var totalPathsCommands = 0;
|
||||
for (var i = 0; i < max; i++) {
|
||||
totalPathsCommands += font.getGlyph(i).path.commands.length;
|
||||
}
|
||||
return Math.round(totalPathsCommands / max * 10) / 10;
|
||||
}
|
||||
|
||||
function readUnicodeRanges(charsetInFont, charsListOnPage) {
|
||||
var ranges = {};
|
||||
|
||||
// Assign a range to each char found in the font
|
||||
charsetInFont.forEach(function(char) {
|
||||
|
||||
var currentRange = getUnicodeRangeFromChar(char);
|
||||
var currentRangeName = currentRange.name;
|
||||
|
||||
if (!ranges[currentRangeName]) {
|
||||
// Cloning the object
|
||||
ranges[currentRangeName] = Object.assign({}, currentRange);
|
||||
}
|
||||
|
||||
if (!ranges[currentRangeName].charset) {
|
||||
ranges[currentRangeName].charset = '';
|
||||
}
|
||||
|
||||
ranges[currentRangeName].charset += String.fromCharCode(char);
|
||||
});
|
||||
|
||||
var range;
|
||||
var expectedLength;
|
||||
var actualLength;
|
||||
|
||||
for (var rangeName in ranges) {
|
||||
/*jshint loopfunc: true */
|
||||
|
||||
range = ranges[rangeName];
|
||||
|
||||
// Estimate if range is used, based on the characters found in the page
|
||||
range.numGlyphsInCommonWithPageContent = countPossiblyUsedGlyphs(range.charset, charsListOnPage);
|
||||
|
||||
// Calculate coverage
|
||||
if (rangeName !== 'Others') {
|
||||
expectedLength = range.rangeEnd - range.rangeStart + 1;
|
||||
actualLength = range.charset.length;
|
||||
range.coverage = Math.min(actualLength / expectedLength, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function countPossiblyUsedGlyphs(charsetInFont, charsListOnPage) {
|
||||
var count = 0;
|
||||
charsListOnPage.split('').forEach(function(char) {
|
||||
if (charsetInFont.indexOf(char) >= 0) {
|
||||
count ++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
function getCharacterSetAsString(characterSet) {
|
||||
var str = '';
|
||||
characterSet.forEach(function(charCode) {
|
||||
str += String.fromCharCode(charCode);
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
function getUnicodeRangeFromChar(char) {
|
||||
return UNICODE_RANGES.find(function(range) {
|
||||
return (char >= range.rangeStart && char <= range.rangeEnd);
|
||||
}) || {name: 'Others'};
|
||||
}
|
||||
|
||||
|
||||
var UNICODE_RANGES = [
|
||||
{
|
||||
name: 'Basic Latin',
|
||||
rangeStart: 0x0020,
|
||||
rangeEnd: 0x007F
|
||||
},
|
||||
{
|
||||
name: 'Latin-1 Supplement',
|
||||
rangeStart: 0x00A0,
|
||||
rangeEnd: 0x00FF
|
||||
},
|
||||
{
|
||||
name: 'Latin Extended',
|
||||
rangeStart: 0x0100,
|
||||
rangeEnd: 0x024F
|
||||
},
|
||||
{
|
||||
name: 'IPA Extensions',
|
||||
rangeStart: 0x0250,
|
||||
rangeEnd: 0x02AF
|
||||
},
|
||||
{
|
||||
name: 'Greek and Coptic',
|
||||
rangeStart: 0x0370,
|
||||
rangeEnd: 0x03FF
|
||||
},
|
||||
{
|
||||
name: 'Cyrillic',
|
||||
rangeStart: 0x0400,
|
||||
rangeEnd: 0x052F
|
||||
},
|
||||
{
|
||||
name: 'Armenian',
|
||||
rangeStart: 0x0530,
|
||||
rangeEnd: 0x058F
|
||||
},
|
||||
{
|
||||
name: 'Hebrew',
|
||||
rangeStart: 0x0590,
|
||||
rangeEnd: 0x05FF
|
||||
},
|
||||
{
|
||||
name: 'Arabic',
|
||||
rangeStart: 0x0600,
|
||||
rangeEnd: 0x06FF
|
||||
},
|
||||
{
|
||||
name: 'Syriac',
|
||||
rangeStart: 0x0700,
|
||||
rangeEnd: 0x074F
|
||||
},
|
||||
{
|
||||
name: 'Thaana',
|
||||
rangeStart: 0x0780,
|
||||
rangeEnd: 0x07BF
|
||||
},
|
||||
{
|
||||
name: 'Devanagari',
|
||||
rangeStart: 0x0900,
|
||||
rangeEnd: 0x097F
|
||||
},
|
||||
{
|
||||
name: 'Bengali',
|
||||
rangeStart: 0x0980,
|
||||
rangeEnd: 0x09FF
|
||||
},
|
||||
{
|
||||
name: 'Gurmukhi',
|
||||
rangeStart: 0x0A00,
|
||||
rangeEnd: 0x0A7F
|
||||
},
|
||||
{
|
||||
name: 'Gujarati',
|
||||
rangeStart: 0x0A80,
|
||||
rangeEnd: 0x0AFF
|
||||
},
|
||||
{
|
||||
name: 'Oriya',
|
||||
rangeStart: 0x0B00,
|
||||
rangeEnd: 0x0B7F
|
||||
},
|
||||
{
|
||||
name: 'Tamil',
|
||||
rangeStart: 0x0B80,
|
||||
rangeEnd: 0x0BFF
|
||||
},
|
||||
{
|
||||
name: 'Telugu',
|
||||
rangeStart: 0x0C00,
|
||||
rangeEnd: 0x0C7F
|
||||
},
|
||||
{
|
||||
name: 'Kannada',
|
||||
rangeStart: 0x0C80,
|
||||
rangeEnd: 0x0CFF
|
||||
},
|
||||
{
|
||||
name: 'Malayalam',
|
||||
rangeStart: 0x0D00,
|
||||
rangeEnd: 0x0D7F
|
||||
},
|
||||
{
|
||||
name: 'Sinhala',
|
||||
rangeStart: 0x0D80,
|
||||
rangeEnd: 0x0DFF
|
||||
},
|
||||
{
|
||||
name: 'Thai',
|
||||
rangeStart: 0x0E00,
|
||||
rangeEnd: 0x0E7F
|
||||
},
|
||||
{
|
||||
name: 'Lao',
|
||||
rangeStart: 0x0E80,
|
||||
rangeEnd: 0x0EFF
|
||||
},
|
||||
{
|
||||
name: 'Tibetan',
|
||||
rangeStart: 0x0F00,
|
||||
rangeEnd: 0x0FFF
|
||||
},
|
||||
{
|
||||
name: 'Myanmar',
|
||||
rangeStart: 0x1000,
|
||||
rangeEnd: 0x109F
|
||||
},
|
||||
{
|
||||
name: 'Georgian',
|
||||
rangeStart: 0x10A0,
|
||||
rangeEnd: 0x10FF
|
||||
},
|
||||
{
|
||||
name: 'Hangul Jamo',
|
||||
rangeStart: 0x1100,
|
||||
rangeEnd: 0x11FF
|
||||
},
|
||||
{
|
||||
name: 'Ethiopic',
|
||||
rangeStart: 0x1200,
|
||||
rangeEnd: 0x137F
|
||||
},
|
||||
{
|
||||
name: 'Cherokee',
|
||||
rangeStart: 0x13A0,
|
||||
rangeEnd: 0x13FF
|
||||
},
|
||||
{
|
||||
name: 'Unified Canadian Aboriginal Syllabics',
|
||||
rangeStart: 0x1400,
|
||||
rangeEnd: 0x167F
|
||||
},
|
||||
{
|
||||
name: 'Ogham',
|
||||
rangeStart: 0x1680,
|
||||
rangeEnd: 0x169F
|
||||
},
|
||||
{
|
||||
name: 'Runic',
|
||||
rangeStart: 0x16A0,
|
||||
rangeEnd: 0x16FF
|
||||
},
|
||||
{
|
||||
name: 'Tagalog',
|
||||
rangeStart: 0x1700,
|
||||
rangeEnd: 0x171F
|
||||
},
|
||||
{
|
||||
name: 'Hanunoo',
|
||||
rangeStart: 0x1720,
|
||||
rangeEnd: 0x173F
|
||||
},
|
||||
{
|
||||
name: 'Buhid',
|
||||
rangeStart: 0x1740,
|
||||
rangeEnd: 0x175F
|
||||
},
|
||||
{
|
||||
name: 'Tagbanwa',
|
||||
rangeStart: 0x1760,
|
||||
rangeEnd: 0x177F
|
||||
},
|
||||
{
|
||||
name: 'Khmer',
|
||||
rangeStart: 0x1780,
|
||||
rangeEnd: 0x17FF
|
||||
},
|
||||
{
|
||||
name: 'Mongolian',
|
||||
rangeStart: 0x1800,
|
||||
rangeEnd: 0x18AF
|
||||
},
|
||||
{
|
||||
name: 'Limbu',
|
||||
rangeStart: 0x1900,
|
||||
rangeEnd: 0x194F
|
||||
},
|
||||
{
|
||||
name: 'Tai Le',
|
||||
rangeStart: 0x1950,
|
||||
rangeEnd: 0x197F
|
||||
},
|
||||
{
|
||||
name: 'Hiragana',
|
||||
rangeStart: 0x3040,
|
||||
rangeEnd: 0x309F
|
||||
},
|
||||
{
|
||||
name: 'Katakana',
|
||||
rangeStart: 0x30A0,
|
||||
rangeEnd: 0x30FF
|
||||
},
|
||||
{
|
||||
name: 'Bopomofo',
|
||||
rangeStart: 0x3100,
|
||||
rangeEnd: 0x312F
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
analyzeFont: analyzeFont,
|
||||
getMetricsFromFont: getMetricsFromFont,
|
||||
readUnicodeRanges: readUnicodeRanges,
|
||||
getAverageGlyphComplexity: getAverageGlyphComplexity,
|
||||
countPossiblyUsedGlyphs: countPossiblyUsedGlyphs,
|
||||
getCharacterSetAsString: getCharacterSetAsString,
|
||||
getUnicodeRangeFromChar: getUnicodeRangeFromChar
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new FontAnalyzer();
|
||||
@@ -0,0 +1,103 @@
|
||||
var debug = require('debug')('ylt:gzipCompressor');
|
||||
|
||||
var Q = require('q');
|
||||
var zlib = require('zlib');
|
||||
|
||||
var GzipCompressor = function() {
|
||||
|
||||
function compressFile(entry) {
|
||||
debug('Entering gzip compressor');
|
||||
return gzipUncompressedFile(entry)
|
||||
|
||||
.then(gzipOptimizedFile);
|
||||
}
|
||||
|
||||
// Gzip a file if it was not already gziped
|
||||
function gzipUncompressedFile(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (entryTypeCanBeCompressed(entry) && entry.weightCheck && !entry.weightCheck.isCompressed && entry.weightCheck.bodyBuffer) {
|
||||
debug('Compression missing, trying to gzip file %s', entry.url);
|
||||
|
||||
var uncompressedSize = entry.weightCheck.uncompressedSize;
|
||||
|
||||
zlib.gzip(entry.weightCheck.bodyBuffer, function(err, buffer) {
|
||||
if (err) {
|
||||
debug('Could not compress uncompressed file with gzip');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
var compressedSize = buffer.length;
|
||||
|
||||
if (gainIsEnough(uncompressedSize, compressedSize)) {
|
||||
debug('File correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize);
|
||||
|
||||
entry.weightCheck.afterGzipCompression = compressedSize;
|
||||
} else {
|
||||
debug('Gzip gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize);
|
||||
}
|
||||
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
debug('Compression not needed');
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Gzip a file after minification or optimization if this step was successful
|
||||
function gzipOptimizedFile(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (entryTypeCanBeCompressed(entry) && entry.weightCheck && entry.weightCheck.isOptimized === false) {
|
||||
debug('Trying to gzip file after minification: %s', entry.url);
|
||||
|
||||
var uncompressedSize = entry.weightCheck.optimized;
|
||||
|
||||
zlib.gzip(Buffer.from(entry.weightCheck.bodyAfterOptimization, 'utf8'), function(err, buffer) {
|
||||
if (err) {
|
||||
debug('Could not compress minified file with gzip');
|
||||
debug(err);
|
||||
|
||||
deferred.reject(err);
|
||||
} else {
|
||||
var compressedSize = buffer.length;
|
||||
|
||||
debug('Correctly gziped the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize);
|
||||
entry.weightCheck.afterOptimizationAndGzipCompression = compressedSize;
|
||||
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
debug('Compressing optimized file not needed');
|
||||
deferred.resolve(entry);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// The gain is estimated of enough value if it's over 1KB or over 20%,
|
||||
// but it's ignored if is below 100 bytes
|
||||
function gainIsEnough(oldWeight, newWeight) {
|
||||
var gain = oldWeight - newWeight;
|
||||
var ratio = gain / oldWeight;
|
||||
return (gain > 2048 || (ratio > 0.2 && gain > 100));
|
||||
}
|
||||
|
||||
function entryTypeCanBeCompressed(entry) {
|
||||
return entry.isJS || entry.isCSS || entry.isHTML || entry.isJSON || entry.isSVG || entry.isTTF || entry.isXML || entry.isFavicon;
|
||||
}
|
||||
|
||||
return {
|
||||
compressFile: compressFile,
|
||||
gainIsEnough: gainIsEnough,
|
||||
entryTypeCanBeCompressed: entryTypeCanBeCompressed
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new GzipCompressor();
|
||||
@@ -0,0 +1,51 @@
|
||||
var debug = require('debug')('ylt:imageDimensions');
|
||||
var Q = require('q');
|
||||
var sizeOf = require('image-size');
|
||||
|
||||
var ImageDimensions = function() {
|
||||
|
||||
function getDimensions(entry) {
|
||||
var deferred = Q.defer();
|
||||
|
||||
if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
|
||||
// No valid file available
|
||||
deferred.resolve(entry);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
var fileSize = entry.weightCheck.uncompressedSize;
|
||||
|
||||
if (isJPEG(entry) || isPNG(entry)) {
|
||||
try {
|
||||
var dimensions = sizeOf(entry.weightCheck.bodyBuffer);
|
||||
debug('Image dimensions of %s: %sx%s', entry.url, dimensions.width, dimensions.height);
|
||||
|
||||
entry.imageDimensions = {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height
|
||||
};
|
||||
} catch(err) {
|
||||
debug('Error while checking image dimensions:');
|
||||
debug(err);
|
||||
}
|
||||
}
|
||||
|
||||
deferred.resolve(entry);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function isJPEG(entry) {
|
||||
return entry.isImage && entry.contentType === 'image/jpeg';
|
||||
}
|
||||
|
||||
function isPNG(entry) {
|
||||
return entry.isImage && entry.contentType === 'image/png';
|
||||
}
|
||||
|
||||
return {
|
||||
getDimensions: getDimensions
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = new ImageDimensions();
|
||||