mirror of
https://github.com/rust-lang/mdBook.git
synced 2025-12-27 17:23:51 -05:00
Compare commits
650 Commits
v0.4.40
...
ehuss-patc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405f407260 | ||
|
|
eaa778bebd | ||
|
|
a17c1d1b95 | ||
|
|
8a27d1b7ac | ||
|
|
d6cd50b601 | ||
|
|
68d9bcfec4 | ||
|
|
3fa49214ad | ||
|
|
ddf02e0c0c | ||
|
|
3992bc18f5 | ||
|
|
49f9c9741e | ||
|
|
f84b1a15b6 | ||
|
|
ac11e00aa2 | ||
|
|
860e8d109e | ||
|
|
adcbd117da | ||
|
|
08d9fddfc9 | ||
|
|
118c1096ea | ||
|
|
18813516e1 | ||
|
|
a6944683e6 | ||
|
|
f27ae21825 | ||
|
|
58af25384d | ||
|
|
51a80febb3 | ||
|
|
7945ac8e8c | ||
|
|
a097fe6232 | ||
|
|
4b5004b621 | ||
|
|
7b7dee4a4e | ||
|
|
5282083dec | ||
|
|
816913bd72 | ||
|
|
1620858032 | ||
|
|
4a07076896 | ||
|
|
3a2705d742 | ||
|
|
b0229e76a5 | ||
|
|
a43ef4ca4b | ||
|
|
780fd83cac | ||
|
|
d5406c8dff | ||
|
|
5e7b6d7d9d | ||
|
|
07d2989486 | ||
|
|
5fe7e9531d | ||
|
|
f958a0e8cf | ||
|
|
dbad189b26 | ||
|
|
4a06e067c5 | ||
|
|
98a093a0ff | ||
|
|
9b5c57bf48 | ||
|
|
87e9cc0ac3 | ||
|
|
e37e5314f8 | ||
|
|
4913bf82f1 | ||
|
|
4e41c844c3 | ||
|
|
0ae202ea85 | ||
|
|
71083144e8 | ||
|
|
7eb63bf0e3 | ||
|
|
283b2798e4 | ||
|
|
91842c3db6 | ||
|
|
fd88719b68 | ||
|
|
c438e13027 | ||
|
|
8e9697bd8b | ||
|
|
dcf7ae45c2 | ||
|
|
38fa5b5ebc | ||
|
|
41262665e8 | ||
|
|
21dc3b0eb1 | ||
|
|
44cd371066 | ||
|
|
1c766160c3 | ||
|
|
2cb4093cc8 | ||
|
|
dad941454e | ||
|
|
2a93606727 | ||
|
|
78819f4525 | ||
|
|
2c7d192b50 | ||
|
|
e15f80407d | ||
|
|
4fc72e8d9f | ||
|
|
b4c53b9e9c | ||
|
|
2011ddb479 | ||
|
|
4a28995641 | ||
|
|
4c397a9be0 | ||
|
|
f1b413444b | ||
|
|
cd1b54f41f | ||
|
|
83c307be3c | ||
|
|
9ec49d978f | ||
|
|
7dee816838 | ||
|
|
5b79ed4144 | ||
|
|
aa96e1174e | ||
|
|
7fcacf3386 | ||
|
|
2aa2b95f0f | ||
|
|
797112ef36 | ||
|
|
f24221a1d7 | ||
|
|
6223189b95 | ||
|
|
73aeed48d7 | ||
|
|
09c3e542da | ||
|
|
9fae3c88eb | ||
|
|
8159dea9ea | ||
|
|
e5386f9230 | ||
|
|
5fafc3f1f6 | ||
|
|
39a148fc8d | ||
|
|
873e4fe40f | ||
|
|
604d4dd78a | ||
|
|
ddeb3ce54f | ||
|
|
15958773d5 | ||
|
|
ba4c3ed873 | ||
|
|
53d39a8654 | ||
|
|
f1731329e1 | ||
|
|
51d7998ba4 | ||
|
|
d27a2bdd1d | ||
|
|
cd3e26fb90 | ||
|
|
1c034bdd9a | ||
|
|
2b242494b0 | ||
|
|
d4763d2c90 | ||
|
|
03443f723c | ||
|
|
c3f4b8114a | ||
|
|
21e8c08827 | ||
|
|
de155f859b | ||
|
|
737090abf1 | ||
|
|
fb4fa867d1 | ||
|
|
c606a010c7 | ||
|
|
09f05e8a86 | ||
|
|
1daa650d61 | ||
|
|
2474ae799b | ||
|
|
f393e22896 | ||
|
|
3629e2c051 | ||
|
|
e7b15274b5 | ||
|
|
d15a40123c | ||
|
|
166a972e9a | ||
|
|
f2db034587 | ||
|
|
a4140ba535 | ||
|
|
e3bb655663 | ||
|
|
8bb9a7ff42 | ||
|
|
3e673ce424 | ||
|
|
787882069e | ||
|
|
e6fffcf9ec | ||
|
|
c78d463864 | ||
|
|
14f249071c | ||
|
|
0dc65a1ac4 | ||
|
|
8f46ad8575 | ||
|
|
6dd8be2ab2 | ||
|
|
29b71be0a5 | ||
|
|
f3bb6ce7ff | ||
|
|
0ce670d124 | ||
|
|
ebcd293fee | ||
|
|
5eaae38bf4 | ||
|
|
be63b44038 | ||
|
|
30d3aeb691 | ||
|
|
06af133838 | ||
|
|
327417373f | ||
|
|
1b55d4a389 | ||
|
|
ac1674845f | ||
|
|
148d282065 | ||
|
|
c0dc4b7367 | ||
|
|
6f3fac763c | ||
|
|
73a1652b64 | ||
|
|
9b5e5e7c0f | ||
|
|
d071d127ef | ||
|
|
321a76bd27 | ||
|
|
8c5b72ca3f | ||
|
|
3e421d353d | ||
|
|
3cd055c508 | ||
|
|
189eea5beb | ||
|
|
3f45b024f2 | ||
|
|
a40f1f281b | ||
|
|
6c847275c5 | ||
|
|
aced9f609c | ||
|
|
82a7df41a6 | ||
|
|
f6c11d12e0 | ||
|
|
313be7162f | ||
|
|
800fb54aeb | ||
|
|
45e700db00 | ||
|
|
24c7ffcd62 | ||
|
|
ec436adca2 | ||
|
|
b8ad85c16f | ||
|
|
6be8e526d6 | ||
|
|
f4012757a7 | ||
|
|
0722d81295 | ||
|
|
402d11414c | ||
|
|
988ed9b5bc | ||
|
|
4a47b3d18a | ||
|
|
82a457b548 | ||
|
|
54a5d749fc | ||
|
|
c177081104 | ||
|
|
1b00525574 | ||
|
|
dbb51d32db | ||
|
|
b4641d2830 | ||
|
|
03f2806cae | ||
|
|
4498739095 | ||
|
|
29ae40c3ec | ||
|
|
6746df7ce9 | ||
|
|
a0a01ecd60 | ||
|
|
21f2435182 | ||
|
|
338a9b424e | ||
|
|
25c47ed0bc | ||
|
|
0f0d1f3377 | ||
|
|
ae1a8c362c | ||
|
|
0043043bb3 | ||
|
|
e9e3bb6ddd | ||
|
|
03ba7d9089 | ||
|
|
d7892f5601 | ||
|
|
0a29ba6eb6 | ||
|
|
4d9095b603 | ||
|
|
235c1f87f0 | ||
|
|
5d44ef91dc | ||
|
|
e7084e5548 | ||
|
|
4637d5f5d1 | ||
|
|
4bac54883b | ||
|
|
2dc8c5e686 | ||
|
|
7b3e6973be | ||
|
|
9fce4ad74c | ||
|
|
76c5b6967a | ||
|
|
04ff12a206 | ||
|
|
3236ec0aea | ||
|
|
ff5e85af51 | ||
|
|
c1b631d086 | ||
|
|
39ce6123b6 | ||
|
|
01da420f76 | ||
|
|
df037d132d | ||
|
|
534725cbb8 | ||
|
|
4c948b4547 | ||
|
|
bba1216df1 | ||
|
|
3e5ec749ba | ||
|
|
7e0949175a | ||
|
|
b51d3e5106 | ||
|
|
f47066f68f | ||
|
|
9ac0eb288a | ||
|
|
79e9ae48a1 | ||
|
|
d29072783f | ||
|
|
e284eb1c30 | ||
|
|
b09c588ca9 | ||
|
|
a6a60eef31 | ||
|
|
95128b6662 | ||
|
|
824e2a7681 | ||
|
|
43e690dd13 | ||
|
|
4a2d3a7e85 | ||
|
|
eda77b81db | ||
|
|
8befcc74d1 | ||
|
|
5956092b4b | ||
|
|
c25e866796 | ||
|
|
1d1274e53a | ||
|
|
841c68d05e | ||
|
|
37273ba8e0 | ||
|
|
91f04bc7ba | ||
|
|
7cce45818a | ||
|
|
84fbd679e7 | ||
|
|
aef12bbb20 | ||
|
|
00eba964ce | ||
|
|
cf7762f57f | ||
|
|
26319f4124 | ||
|
|
f8e66db41c | ||
|
|
529cfc34ec | ||
|
|
8053774ba3 | ||
|
|
fe76ee626f | ||
|
|
f6c062fc98 | ||
|
|
97d9078a32 | ||
|
|
a397f64356 | ||
|
|
fad53f720f | ||
|
|
dcfb527342 | ||
|
|
e0a4fb1ea9 | ||
|
|
6e6518a7ae | ||
|
|
12fc0ff5c3 | ||
|
|
b8a7b6e846 | ||
|
|
9229e80499 | ||
|
|
ae6c4522bb | ||
|
|
fdebbfdce2 | ||
|
|
40745600a3 | ||
|
|
5a31947eb7 | ||
|
|
d758753551 | ||
|
|
9b27b14985 | ||
|
|
f5fc54461a | ||
|
|
6aac696ee1 | ||
|
|
c8571f592c | ||
|
|
7eccd1d556 | ||
|
|
06324d8b24 | ||
|
|
753780f653 | ||
|
|
3087686559 | ||
|
|
6805740e20 | ||
|
|
8f3b6b4776 | ||
|
|
3278f84373 | ||
|
|
12285f505d | ||
|
|
e123879c8c | ||
|
|
7bcdfe6f0f | ||
|
|
29f936b1eb | ||
|
|
bd3e555962 | ||
|
|
02b6628048 | ||
|
|
4ae5a53791 | ||
|
|
fc76a47d6e | ||
|
|
a224bfd7d7 | ||
|
|
f51d89ba02 | ||
|
|
461884f109 | ||
|
|
bc3399cc22 | ||
|
|
4a655ff2a3 | ||
|
|
877a3af671 | ||
|
|
1499e850ac | ||
|
|
c7b67e363b | ||
|
|
d5a505e0c6 | ||
|
|
0de13cf5a9 | ||
|
|
d6d5d6e674 | ||
|
|
5264074c1b | ||
|
|
702c676107 | ||
|
|
a387846b20 | ||
|
|
0eabdb0169 | ||
|
|
92836f3988 | ||
|
|
05c6a99446 | ||
|
|
68893f785f | ||
|
|
f6dd0a4a13 | ||
|
|
432b4296ab | ||
|
|
6b1dc01a3f | ||
|
|
9d8e99f8d7 | ||
|
|
e152e197c1 | ||
|
|
7e68d01e7d | ||
|
|
bd97611eb0 | ||
|
|
8e579072b8 | ||
|
|
a918910a52 | ||
|
|
1eeb0d23e6 | ||
|
|
c842b5d06e | ||
|
|
0ac89dd826 | ||
|
|
7ec083e426 | ||
|
|
15c93b56ed | ||
|
|
ec6f26e652 | ||
|
|
cdce9a7666 | ||
|
|
63432355e6 | ||
|
|
4bf2b472bb | ||
|
|
1476ec72c3 | ||
|
|
d1c09791ab | ||
|
|
16b99be17f | ||
|
|
bcd4552bdf | ||
|
|
f942f3835e | ||
|
|
e96c608c11 | ||
|
|
e6315bf2b1 | ||
|
|
6d63343b46 | ||
|
|
e1e4518499 | ||
|
|
34d68403da | ||
|
|
e395341210 | ||
|
|
f4c54178c8 | ||
|
|
63e1dac122 | ||
|
|
e8dff6f97e | ||
|
|
534666f551 | ||
|
|
f3ee794283 | ||
|
|
94f9a9c5e0 | ||
|
|
dc6b0a6e58 | ||
|
|
2fa13cf4e0 | ||
|
|
d64a863223 | ||
|
|
1fb91d67f6 | ||
|
|
1b046e5a90 | ||
|
|
e9f5e3d7c0 | ||
|
|
c6a5d05c64 | ||
|
|
f93b2675ff | ||
|
|
365918bf89 | ||
|
|
76be253f76 | ||
|
|
0210e69abc | ||
|
|
cdbf6d2806 | ||
|
|
902ded9f89 | ||
|
|
851932bd4b | ||
|
|
f38dc687e3 | ||
|
|
391ee3bae2 | ||
|
|
ad3096e871 | ||
|
|
179bd8dcd5 | ||
|
|
426e7bee17 | ||
|
|
564c80bf6d | ||
|
|
363c12e9c3 | ||
|
|
1ffa9fe830 | ||
|
|
91d13c390a | ||
|
|
ce8d8120f8 | ||
|
|
038b5fb232 | ||
|
|
fd768efba2 | ||
|
|
481346812c | ||
|
|
3d07798832 | ||
|
|
33da0c26c4 | ||
|
|
91e94024cd | ||
|
|
c9ad6dbf10 | ||
|
|
2b455fcd34 | ||
|
|
4573f4d882 | ||
|
|
63ae0d5c18 | ||
|
|
52e406bf95 | ||
|
|
3e871d1971 | ||
|
|
84a5ba9707 | ||
|
|
44d9f4e95b | ||
|
|
d24c0ca0d7 | ||
|
|
623fc606a4 | ||
|
|
a8aee21cd0 | ||
|
|
649a021647 | ||
|
|
785ee564c5 | ||
|
|
006f99ee99 | ||
|
|
2a4e5140c9 | ||
|
|
3c10b00096 | ||
|
|
c9ddb4dd98 | ||
|
|
9822c2a178 | ||
|
|
199efd0f2c | ||
|
|
17b197620b | ||
|
|
23abd20589 | ||
|
|
7e9be8dee3 | ||
|
|
09d22e926f | ||
|
|
1696f5680e | ||
|
|
a54ee0215e | ||
|
|
9b12c5130f | ||
|
|
084771bcd5 | ||
|
|
7215d60c67 | ||
|
|
0224190ec0 | ||
|
|
ae2fc9a9d1 | ||
|
|
d65d2b2a8e | ||
|
|
69972080f0 | ||
|
|
5f2453e446 | ||
|
|
20f71af4cb | ||
|
|
efc5ee4449 | ||
|
|
14d412b279 | ||
|
|
707319e004 | ||
|
|
bdd16e25fa | ||
|
|
9a1f983e65 | ||
|
|
c2c37705e7 | ||
|
|
5f227613aa | ||
|
|
0274ad6e87 | ||
|
|
dd27c4f8ba | ||
|
|
25b9acc321 | ||
|
|
10fae8596c | ||
|
|
909bd1c54e | ||
|
|
f324aebdec | ||
|
|
5a84d641cd | ||
|
|
0b577ebd76 | ||
|
|
2056c87e28 | ||
|
|
8bfa6462f8 | ||
|
|
a8660048ca | ||
|
|
cad8988f8d | ||
|
|
3fce1151dd | ||
|
|
d23bdaa527 | ||
|
|
2f10831a80 | ||
|
|
a38a30da1e | ||
|
|
82000d917f | ||
|
|
f482aeaca3 | ||
|
|
86638abea9 | ||
|
|
a2cf838baf | ||
|
|
5bc25e32eb | ||
|
|
15c6f3f318 | ||
|
|
cb2a63ea0a | ||
|
|
50dfa365c7 | ||
|
|
3e22a5cdad | ||
|
|
5034707a73 | ||
|
|
d815b0cc52 | ||
|
|
fca149a52c | ||
|
|
b4221680e4 | ||
|
|
ba448a9dd5 | ||
|
|
aa29ef04a2 | ||
|
|
20d42a53d3 | ||
|
|
8c8f0a4dbf | ||
|
|
6904653a82 | ||
|
|
74e01ea6e3 | ||
|
|
0732cb47b9 | ||
|
|
29338b5ade | ||
|
|
4019060ef4 | ||
|
|
3e1d750efa | ||
|
|
41bfbc69e6 | ||
|
|
6fdd7b4a17 | ||
|
|
c6d9f15cba | ||
|
|
0f397ebdb5 | ||
|
|
342b6ee7b5 | ||
|
|
9952ac15a5 | ||
|
|
7add0dbf10 | ||
|
|
03470a7531 | ||
|
|
dd778d50f9 | ||
|
|
ac3e4b6c1e | ||
|
|
3706ddc5cc | ||
|
|
adcea9b3b9 | ||
|
|
ba8107120c | ||
|
|
b9e433710d | ||
|
|
f10d23e893 | ||
|
|
12b4a9631a | ||
|
|
36fa0064de | ||
|
|
566a42c4f7 | ||
|
|
6e143ce2a1 | ||
|
|
46963ebf65 | ||
|
|
c948fe4d6a | ||
|
|
b0ef5a54cc | ||
|
|
fbc875dd9f | ||
|
|
e6b1413d22 | ||
|
|
1d3b99c0df | ||
|
|
8181445d99 | ||
|
|
14aeb0cb83 | ||
|
|
3e6b42cfba | ||
|
|
c57a8fcfc4 | ||
|
|
9d6fcc9afe | ||
|
|
ea8f0f6161 | ||
|
|
06e8f6f849 | ||
|
|
36e5525ea5 | ||
|
|
e5d5f5d02b | ||
|
|
98088c91dd | ||
|
|
a4f7d11e92 | ||
|
|
4c7e85ba82 | ||
|
|
9693c4af05 | ||
|
|
3052fe3827 | ||
|
|
c85c3eb292 | ||
|
|
0d734bbb03 | ||
|
|
b9b34f97d9 | ||
|
|
ee59e22603 | ||
|
|
22a6dca69b | ||
|
|
4f698f813c | ||
|
|
97f1948681 | ||
|
|
7acc7a03a8 | ||
|
|
0ed1cbe486 | ||
|
|
2c382a58d3 | ||
|
|
b7a27d2759 | ||
|
|
d67dbc74fd | ||
|
|
d9d27f38c3 | ||
|
|
4886c92fa4 | ||
|
|
195d97a514 | ||
|
|
e954e872f0 | ||
|
|
e74b4b0507 | ||
|
|
4946c78e8c | ||
|
|
54d8d37b77 | ||
|
|
20eea0b41e | ||
|
|
8835bdc47e | ||
|
|
7a1977a78c | ||
|
|
629c09df4d | ||
|
|
7247e5f9a1 | ||
|
|
a3c0ecdb45 | ||
|
|
b20b1757a9 | ||
|
|
1bc2ebd775 | ||
|
|
a56cffeb4e | ||
|
|
c908ac8cc5 | ||
|
|
b47d1cff33 | ||
|
|
780daa73ae | ||
|
|
9114905a93 | ||
|
|
a7aaef1e85 | ||
|
|
9823246ecd | ||
|
|
861940ba4b | ||
|
|
3ed302467e | ||
|
|
f54356da10 | ||
|
|
418d677584 | ||
|
|
a0eb8c0a0e | ||
|
|
67b4260021 | ||
|
|
7c6d47e8b6 | ||
|
|
43281c85c5 | ||
|
|
e73d3b7cfa | ||
|
|
1de8cf8ba6 | ||
|
|
85afbe466e | ||
|
|
63b312948a | ||
|
|
3a8faba645 | ||
|
|
6d6bee0dc9 | ||
|
|
4b266f1ebc | ||
|
|
fc7ef59dee | ||
|
|
5fa9f12427 | ||
|
|
07b25cdb64 | ||
|
|
105d836fbc | ||
|
|
74fcaf5273 | ||
|
|
74200f7395 | ||
|
|
a7ca2e169f | ||
|
|
1a5286b25c | ||
|
|
c493d3b5e3 | ||
|
|
a68091a84c | ||
|
|
32daca669a | ||
|
|
cf5a78c0e1 | ||
|
|
b0cf568ba4 | ||
|
|
bf544be282 | ||
|
|
4f0dba8fdb | ||
|
|
5390e44dec | ||
|
|
0fb814c6d6 | ||
|
|
e7e3317ff0 | ||
|
|
d68a596455 | ||
|
|
ace2abff34 | ||
|
|
0c6439faad | ||
|
|
e7418f21f9 | ||
|
|
19146c403e | ||
|
|
66ded2302f | ||
|
|
98abb22be1 | ||
|
|
ab304e7d38 | ||
|
|
fbc21592af | ||
|
|
e7b69114ed | ||
|
|
8a9ecd212d | ||
|
|
ec157cd1cd | ||
|
|
d3bcb359fa | ||
|
|
2a4e5583ab | ||
|
|
3978612611 | ||
|
|
4941acdb87 | ||
|
|
7e3d2f96ab | ||
|
|
ddba36b24c | ||
|
|
35cf96a064 | ||
|
|
5777a0edc4 | ||
|
|
53c3a92285 | ||
|
|
82db7f5b93 | ||
|
|
879449447f | ||
|
|
132ca0dca3 | ||
|
|
56c2b9ba3a | ||
|
|
542b6feed1 | ||
|
|
2af44a396f | ||
|
|
40d91fff29 | ||
|
|
59eab7cfc2 | ||
|
|
1b524ff356 | ||
|
|
9b873e9d97 | ||
|
|
b6d6cb2711 | ||
|
|
c8095160d0 | ||
|
|
ae6db3a87e | ||
|
|
18f57f5bd9 | ||
|
|
09a37284b0 | ||
|
|
dff5ac64e5 | ||
|
|
0ee565a5ff | ||
|
|
9e4854f349 | ||
|
|
74d48f5ad2 | ||
|
|
0b51a74c16 | ||
|
|
ce63cc31f4 | ||
|
|
d6720fc671 | ||
|
|
64cca1399b | ||
|
|
629c2ad2fd | ||
|
|
d325e821cd | ||
|
|
ac3a7faa54 | ||
|
|
35ed24cd18 | ||
|
|
81d42f1c6e | ||
|
|
618a2fa78b | ||
|
|
0bf6751eed | ||
|
|
f92eac4acd | ||
|
|
69ef52fd13 | ||
|
|
cc8ce35b4d | ||
|
|
2a13ca2fbf | ||
|
|
59e6afcaad | ||
|
|
4d9a455a27 | ||
|
|
74b2c79d46 | ||
|
|
ed407b091c | ||
|
|
6c8020a3b9 | ||
|
|
42f18d1e51 | ||
|
|
abf3e4ab50 | ||
|
|
d1078434af | ||
|
|
8f024dabc3 | ||
|
|
0c580c32c4 | ||
|
|
90960126e8 | ||
|
|
aa37f24fc1 | ||
|
|
3f4f287e6e | ||
|
|
55fe75c716 | ||
|
|
c6236ead67 | ||
|
|
68e3572278 | ||
|
|
27ab7eb2f0 | ||
|
|
6d183be0ec | ||
|
|
c83a34b473 | ||
|
|
d3e0e597d2 | ||
|
|
271bbba7dd | ||
|
|
86ff2e1e6b | ||
|
|
6ef7cc0ccb | ||
|
|
f4cf32e768 | ||
|
|
47384c1f18 | ||
|
|
9e3d533acc | ||
|
|
5ec4f65ac3 | ||
|
|
4a330ae36f | ||
|
|
d93fbc0f6b | ||
|
|
684bb78897 | ||
|
|
d0dd16c527 | ||
|
|
f4805343f8 | ||
|
|
f9add3e936 | ||
|
|
1fd9656291 | ||
|
|
6f281a6401 | ||
|
|
5194d2b3cd | ||
|
|
b3c23c5f88 | ||
|
|
a15134cc2f | ||
|
|
b51bb101f2 | ||
|
|
59d26dbbe7 | ||
|
|
94baf19e6a | ||
|
|
f1a446fb02 | ||
|
|
01d1242753 | ||
|
|
203685e91c | ||
|
|
2cb5b85ab2 | ||
|
|
ec996d3509 | ||
|
|
5ed3223185 | ||
|
|
3bdcc0a5a6 | ||
|
|
1e4d4887e1 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --manifest-path=crates/xtask/Cargo.toml --"
|
||||
26
.git-blame-ignore-revs
Normal file
26
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,26 @@
|
||||
# Use `git config blame.ignorerevsfile .git-blame-ignore-revs` to make `git blame` ignore the following commits.
|
||||
|
||||
# rustfmt
|
||||
ad0794a0bd692e4f2ff23b85e361889620e93f51
|
||||
# rustfmt and use_try_shorthand
|
||||
75bbd55128083897d40c3f5265cc5b1f10314ddb
|
||||
# rustfmt
|
||||
382fc4139b96bde3c4b8875b499c720eabc89c6a
|
||||
# rustfmt
|
||||
154e0fb3080c6ffc225b0d47b5d835e589789892
|
||||
# rustfmt
|
||||
5835da243244bfc5c95c6c6db96f453da4bb5740
|
||||
# rustfmt
|
||||
fd9d27e082f5e9eea50e4fa9fa3a22060d02c66b
|
||||
# rustfmt
|
||||
1d69ccae4854f13552d452d0bffef95cbff70364
|
||||
# rustfmt
|
||||
3688f73052454bf510a5acc85cf55aae450c6e46
|
||||
# rustfmt
|
||||
742dbbc91700dce1b7d910bca6b3e10a5ae46b86
|
||||
# rustfmt 1.38
|
||||
b88839cc25a6fd1c782101e94318959e8079bb20
|
||||
# rustfmt 1.40
|
||||
2f59943c04f0aa204a9238d6a699ba9cc06c88d9
|
||||
# Rustfmt for 2024
|
||||
c7b67e363bb9ce3383636ee615e8e761bf185b33
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -6,3 +6,5 @@
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.png binary
|
||||
*.eot binary
|
||||
*.woff2 binary
|
||||
|
||||
71
.github/renovate.json5
vendored
Normal file
71
.github/renovate.json5
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
schedule: ['before 5am on the first day of the month'],
|
||||
// Raise from default of 2 to reduce trickle.
|
||||
prHourlyLimit: 6,
|
||||
dependencyDashboard: true,
|
||||
// Creates PRs if this renovate config file needs updating.
|
||||
configMigration: true,
|
||||
ignorePaths: [
|
||||
'guide/src/for_developers/mdbook-wordcount/',
|
||||
],
|
||||
customManagers: [
|
||||
// Custom manager to extract the version of cargo-semver-checks from the workflow.
|
||||
{
|
||||
customType: 'regex',
|
||||
managerFilePatterns: [
|
||||
'/^.github.workflows.main.yml$/',
|
||||
],
|
||||
matchStrings: [
|
||||
'cargo-semver-checks.releases.download.v(?<currentValue>\\d+\\.\\d+(\\.\\d+)?)',
|
||||
],
|
||||
depNameTemplate: 'cargo-semver-checks',
|
||||
packageNameTemplate: 'obi1kenobi/cargo-semver-checks',
|
||||
datasourceTemplate: 'github-releases',
|
||||
},
|
||||
],
|
||||
packageRules: [
|
||||
// The next two rules disable compatible dependency updates. I wasn't
|
||||
// able to get Renovate to be able to update Cargo.toml for compatible
|
||||
// updates only, update all transitive dependencies, and do that all
|
||||
// in a single PR. Instead, the `update-dependencies.sh` will handle
|
||||
// that.
|
||||
{
|
||||
matchManagers: ['cargo'],
|
||||
matchUpdateTypes: ['patch'],
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
matchManagers: ['cargo'],
|
||||
matchCurrentVersion: '>=1.0.0',
|
||||
matchUpdateTypes: ['minor'],
|
||||
enabled: false,
|
||||
},
|
||||
// Allow minor updates for pre-1.0 dependencies (semver-breaking)
|
||||
{
|
||||
matchManagers: ['cargo'],
|
||||
matchCurrentVersion: '<1.0.0',
|
||||
matchUpdateTypes: ['minor'],
|
||||
},
|
||||
// Allow major updates for stable dependencies (semver-breaking)
|
||||
{
|
||||
matchManagers: ['cargo'],
|
||||
matchCurrentVersion: '>=1.0.0',
|
||||
matchUpdateTypes: ['major'],
|
||||
},
|
||||
// Update cargo-semver-checks when a new version is available.
|
||||
{
|
||||
commitMessageTopic: 'cargo-semver-checks',
|
||||
matchManagers: [
|
||||
'custom.regex',
|
||||
],
|
||||
matchDepNames: [
|
||||
'cargo-semver-checks',
|
||||
],
|
||||
extractVersion: '^v(?<version>\\d+\\.\\d+\\.\\d+)',
|
||||
schedule: [
|
||||
'* * * * *',
|
||||
],
|
||||
internalChecksFilter: 'strict',
|
||||
},
|
||||
]
|
||||
}
|
||||
36
.github/workflows/deploy.yml
vendored
36
.github/workflows/deploy.yml
vendored
@@ -17,18 +17,20 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
name: Deploy ${{ matrix.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: ci/install-rust.sh stable ${{ matrix.target }}
|
||||
- name: Build asset
|
||||
@@ -41,27 +43,25 @@ jobs:
|
||||
name: GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust (rustup)
|
||||
run: rustup update stable --no-self-update && rustup default stable
|
||||
- name: Build book
|
||||
run: cargo run -- build guide
|
||||
- name: Deploy to GitHub
|
||||
env:
|
||||
GITHUB_DEPLOY_KEY: ${{ secrets.GITHUB_DEPLOY_KEY }}
|
||||
run: |
|
||||
touch guide/book/.nojekyll
|
||||
curl -LsSf https://raw.githubusercontent.com/rust-lang/simpleinfra/master/setup-deploy-keys/src/deploy.rs | rustc - -o /tmp/deploy
|
||||
cd guide/book
|
||||
/tmp/deploy
|
||||
- name: Deploy the User Guide to GitHub Pages using the gh-pages branch
|
||||
run: ci/publish-guide.sh
|
||||
publish:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Required for OIDC token exchange
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust (rustup)
|
||||
run: rustup update stable --no-self-update && rustup default stable
|
||||
- name: Authenticate with crates.io
|
||||
id: auth
|
||||
uses: rust-lang/crates-io-auth-action@v1
|
||||
- name: Publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
run: cargo publish --no-verify
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
run: cargo publish --workspace --no-verify
|
||||
|
||||
77
.github/workflows/main.yml
vendored
77
.github/workflows/main.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
rust: nightly
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: stable x86_64-unknown-linux-musl
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
rust: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: stable x86_64 macos
|
||||
@@ -38,24 +38,24 @@ jobs:
|
||||
rust: stable
|
||||
target: x86_64-pc-windows-msvc
|
||||
- name: msrv
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
# sync MSRV with docs: guide/src/guide/installation.md and Cargo.toml
|
||||
rust: 1.74.0
|
||||
rust: 1.88.0
|
||||
target: x86_64-unknown-linux-gnu
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh ${{ matrix.rust }} ${{ matrix.target }}
|
||||
- name: Build and run tests
|
||||
run: cargo test --locked --target ${{ matrix.target }}
|
||||
run: cargo test --workspace --locked --target ${{ matrix.target }}
|
||||
- name: Test no default
|
||||
run: cargo test --no-default-features --target ${{ matrix.target }}
|
||||
run: cargo test --workspace --no-default-features --target ${{ matrix.target }}
|
||||
|
||||
aarch64-cross-builds:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh stable aarch64-unknown-linux-musl
|
||||
- name: Build
|
||||
@@ -65,11 +65,65 @@ jobs:
|
||||
name: Rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: rustup update stable && rustup default stable && rustup component add rustfmt
|
||||
- run: cargo fmt --check
|
||||
|
||||
gui:
|
||||
name: GUI tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh stable x86_64-unknown-linux-gnu
|
||||
- name: Install npm
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install browser-ui-test
|
||||
run: npm install
|
||||
- name: Run eslint
|
||||
run: npm run lint
|
||||
- name: Build and run tests (+ GUI)
|
||||
run: cargo test --locked --target x86_64-unknown-linux-gnu --test gui
|
||||
|
||||
# Ensure there are no clippy warnings
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh stable x86_64-unknown-linux-gnu
|
||||
- run: rustup component add clippy
|
||||
- run: cargo clippy --workspace --all-targets --no-deps -- -D warnings
|
||||
|
||||
docs:
|
||||
name: Check API docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh stable x86_64-unknown-linux-gnu
|
||||
- name: Ensure intradoc links are valid
|
||||
run: cargo doc --workspace --document-private-items --no-deps
|
||||
env:
|
||||
RUSTDOCFLAGS: -D warnings
|
||||
|
||||
check-version-bump:
|
||||
name: Check version bump
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: rustup update stable && rustup default stable
|
||||
- name: Install cargo-semver-checks
|
||||
run: |
|
||||
mkdir installed-bins
|
||||
curl -Lf https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v0.44.0/cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz \
|
||||
| tar -xz --directory=./installed-bins
|
||||
echo `pwd`/installed-bins >> $GITHUB_PATH
|
||||
- run: cargo semver-checks --workspace
|
||||
|
||||
# The success job is here to consolidate the total success/failure state of
|
||||
# all other jobs. This job is then included in the GitHub branch protection
|
||||
# rule which prevents merges unless all other jobs are passing. This makes
|
||||
@@ -81,6 +135,11 @@ jobs:
|
||||
needs:
|
||||
- test
|
||||
- rustfmt
|
||||
- aarch64-cross-builds
|
||||
- gui
|
||||
- clippy
|
||||
- docs
|
||||
- check-version-bump
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: jq --exit-status 'all(.result == "success")' <<< '${{ toJson(needs) }}'
|
||||
|
||||
20
.github/workflows/update-dependencies.yml
vendored
Normal file
20
.github/workflows/update-dependencies.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Update dependencies
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
name: Update dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
run: bash ci/install-rust.sh stable x86_64-unknown-linux-gnu
|
||||
- name: Install cargo-edit
|
||||
run: cargo install cargo-edit --locked
|
||||
- name: Update dependencies
|
||||
run: ci/update-dependencies.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,7 +8,8 @@ guide/book
|
||||
|
||||
.vscode
|
||||
tests/dummy_book/book/
|
||||
test_book/book/
|
||||
tests/gui/books/*/book/
|
||||
tests/testsuite/*/*/book/
|
||||
|
||||
# Ignore Jetbrains specific files.
|
||||
.idea/
|
||||
@@ -16,3 +17,8 @@ test_book/book/
|
||||
# Ignore Vim temporary and swap files.
|
||||
*.sw?
|
||||
*~
|
||||
|
||||
# GUI tests
|
||||
node_modules
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
469
CHANGELOG.md
469
CHANGELOG.md
@@ -1,5 +1,474 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5 Migration Guide
|
||||
|
||||
During the pre-release phase of the 0.5 release, the documentation may be found at <https://rust-lang.github.io/mdBook/pre-release/>.
|
||||
|
||||
The 0.5 release contains several breaking changes from the 0.4 release. Preprocessors and renderers will need to be migrated to continue to work with this release. After updating your configuration, it is recommended to carefully compare and review how your book renders to ensure everything is working correctly.
|
||||
|
||||
If you have overridden any of the theme files, you will likely need to update them to match the current version.
|
||||
|
||||
The following is a summary of the changes that may require your attention when updating to 0.5:
|
||||
|
||||
### Config changes
|
||||
|
||||
- Unknown fields in config are now an error.
|
||||
[#2787](https://github.com/rust-lang/mdBook/pull/2787)
|
||||
[#2801](https://github.com/rust-lang/mdBook/pull/2801)
|
||||
- Removed `curly-quotes`, use `output.html.smart-punctuation` instead.
|
||||
[#2788](https://github.com/rust-lang/mdBook/pull/2788)
|
||||
- Removed `output.html.copy-fonts`. The default fonts are now always copied unless you override the `theme/fonts/fonts.css` file.
|
||||
[#2790](https://github.com/rust-lang/mdBook/pull/2790)
|
||||
- If the `command` path for a renderer or preprocessor is relative, it is now always relative to the book root.
|
||||
[#2792](https://github.com/rust-lang/mdBook/pull/2792)
|
||||
[#2796](https://github.com/rust-lang/mdBook/pull/2796)
|
||||
- Added the `optional` field for preprocessors. The default is `false`, so this also means it is an error by default if the preprocessor is missing.
|
||||
[#2797](https://github.com/rust-lang/mdBook/pull/2797)
|
||||
- `output.html.smart-punctuation` is now `true` by default.
|
||||
[#2810](https://github.com/rust-lang/mdBook/pull/2810)
|
||||
- `output.html.hash-files` is now `true` by default.
|
||||
[#2820](https://github.com/rust-lang/mdBook/pull/2820)
|
||||
- Removed support for google-analytics. Use a theme extension (like `head.hbs`) if you need to continue to support this.
|
||||
[#2776](https://github.com/rust-lang/mdBook/pull/2776)
|
||||
- Removed the `book.multilingual` field. This was never used.
|
||||
[#2775](https://github.com/rust-lang/mdBook/pull/2775)
|
||||
- Removed the very old legacy config support. Warnings have been displayed in previous versions on how to migrate.
|
||||
[#2783](https://github.com/rust-lang/mdBook/pull/2783)
|
||||
|
||||
### Theme changes
|
||||
|
||||
- Replaced the `{{#previous}}` and `{{#next}}` handlebars helpers with simple objects that contain the previous and next values.
|
||||
[#2794](https://github.com/rust-lang/mdBook/pull/2794)
|
||||
- Removed the `{{theme_option}}` handlebars helper. It has not been used for a while.
|
||||
[#2795](https://github.com/rust-lang/mdBook/pull/2795)
|
||||
|
||||
### Rendering changes
|
||||
|
||||
- Updated to a newer version of `pulldown-cmark`. This brings a large number of fixes to markdown processing.
|
||||
[#2401](https://github.com/rust-lang/mdBook/pull/2401)
|
||||
- The font-awesome font is no longer loaded as a font. Instead, the corresponding SVG is embedded in the output for the corresponding `<i>` tags. Additionally, a handlebars helper has been added for the `hbs` files.
|
||||
[#1330](https://github.com/rust-lang/mdBook/pull/1330)
|
||||
- Changed all internal HTML IDs to have an `mdbook-` prefix. This helps avoid namespace conflicts with header IDs.
|
||||
[#2808](https://github.com/rust-lang/mdBook/pull/2808)
|
||||
- There is a new internal HTML rendering pipeline. This is primarily intended to give mdBook more flexibility in generating its HTML output. This resulted in some small changes to the HTML structure. HTML parsing may now be more strict than before.
|
||||
[#2844](https://github.com/rust-lang/mdBook/pull/2844)
|
||||
- Links on the print page now link to elements on the print page instead of linking out to the individual chapters.
|
||||
[#2844](https://github.com/rust-lang/mdBook/pull/2844)
|
||||
- Added support for definition lists. These are enabled by default, with the option `output.html.definition-lists` to disable it.
|
||||
[#2847](https://github.com/rust-lang/mdBook/pull/2847)
|
||||
- Added support for admonitions. These are enabled by default, with the option `output.html.admonitions` to disable it.
|
||||
[#2851](https://github.com/rust-lang/mdBook/pull/2851)
|
||||
|
||||
### CLI changes
|
||||
|
||||
- Removed the `--dest-dir` option to `mdbook test`. It was unused since `mdbook test` does not generate output.
|
||||
[#2805](https://github.com/rust-lang/mdBook/pull/2805)
|
||||
- Changed CLI `--dest-dir` to be relative to the current directory, not the book root.
|
||||
[#2806](https://github.com/rust-lang/mdBook/pull/2806)
|
||||
|
||||
### Rust API
|
||||
|
||||
- The Rust API has been split into several crates ([#2766](https://github.com/rust-lang/mdBook/pull/2766)). In summary, the different crates are:
|
||||
- `mdbook` — The CLI binary.
|
||||
- [`mdbook-driver`](https://docs.rs/mdbook-driver/latest/mdbook_driver/) — The high-level library for running mdBook, primarily through the `MDBook` type. If you are driving mdBook programmatically, this is the crate you want.
|
||||
- [`mdbook-preprocessor`](https://docs.rs/mdbook-preprocessor/latest/mdbook_preprocessor/) — Support for implementing preprocessors. If you have a preprocessor, then this is the crate you should depend on.
|
||||
- [`mdbook-renderer`](https://docs.rs/mdbook-renderer/latest/mdbook_renderer/) — Support for implementing renderers. If you have a custom renderer, this is the crate you should depend on.
|
||||
- [`mdbook-markdown`](https://docs.rs/mdbook-markdown/latest/mdbook_markdown/) — The Markdown renderer. If you are processing markdown, this is the crate you should depend on. This is essentially a thin wrapper around `pulldown-cmark`, and re-exports that crate so that you can ensure the version stays in sync with mdBook.
|
||||
- [`mdbook-summary`](https://docs.rs/mdbook-summary/latest/mdbook_summary/) — The `SUMMARY.md` parser.
|
||||
- [`mdbook-html`](https://docs.rs/mdbook-html/latest/mdbook_html/) — The HTML renderer.
|
||||
- [`mdbook-core`](https://docs.rs/mdbook-core/latest/mdbook_core/) — An internal library that is used by the other crates for shared types. You should not depend on this crate directly since types from this crate are re-exported from the other crates as appropriate.
|
||||
- Changes to `Config`:
|
||||
- [`Config::get`](https://docs.rs/mdbook-core/latest/mdbook_core/config/struct.Config.html#method.get) is now generic over the return value, using `serde` to deserialize the value. It also returns a `Result` to handle deserialization errors. [#2773](https://github.com/rust-lang/mdBook/pull/2773)
|
||||
- Removed `Config::get_deserialized`. Use `Config::get` instead.
|
||||
- Removed `Config::get_deserialized_opt`. Use `Config::get` instead.
|
||||
- Removed `Config::get_mut`. Use `Config::set` instead.
|
||||
- Removed deprecated `Config::get_deserialized_opt`. Use `Config::get` instead.
|
||||
- Removed `Config::get_renderer`. Use `Config::get` instead.
|
||||
- Removed `Config::get_preprocessor`. Use `Config::get` instead.
|
||||
- Public types have been switch to use the `#[non_exhaustive]` attribute to help allow them to change in a backwards-compatible way.
|
||||
[#2779](https://github.com/rust-lang/mdBook/pull/2779)
|
||||
[#2823](https://github.com/rust-lang/mdBook/pull/2823)
|
||||
- Changed `MDBook` `with_renderer`/`with_preprocessor` to overwrite the entry if an extension of the same name is already loaded. This allows the caller to replace an entry.
|
||||
[#2802](https://github.com/rust-lang/mdBook/pull/2802)
|
||||
- Added `MarkdownOptions` struct to specify settings for markdown rendering for `mdbook_markdown::new_cmark_parser`.
|
||||
[#2809](https://github.cocm/rust-lang/mdBook/pull/2809)
|
||||
- Renamed `Book::sections` to `Book::items`.
|
||||
[#2813](https://github.com/rust-lang/mdBook/pull/2813)
|
||||
- `mdbook::book::load_book` is now private. Instead, use one of the `MDBook` load functions like `MDBook::load_with_config`.
|
||||
- Removed `HtmlConfig::smart_punctuation` method, use the field of the same name.
|
||||
- `CmdPreprocessor::parse_input` moved to `mdbook_preprocessor::parse_input`.
|
||||
- `Preprocessor::supports_renderer` now returns a `Result<bool>` instead of `bool` to be able to handle errors.
|
||||
- Most of the types from the `theme` module are now private. The `Theme` struct is still exposed for working with themes.
|
||||
- Various functions in the `utils::fs` module have been removed, renamed, or reworked.
|
||||
- Most of the functions in the `utils` module have been moved, removed, or made private.
|
||||
|
||||
## mdBook 0.5.0-beta.1
|
||||
[v0.5.0-alpha.1...v0.5.0-beta.1](https://github.com/rust-lang/mdBook/compare/v0.5.0-alpha.1...v0.5.0-beta.1)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the look of the header navigation.
|
||||
[#2898](https://github.com/rust-lang/mdBook/pull/2898)
|
||||
- Update cargo dependencies.
|
||||
[#2896](https://github.com/rust-lang/mdBook/pull/2896)
|
||||
- Improved the heading nav debug.
|
||||
[#2892](https://github.com/rust-lang/mdBook/pull/2892)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed error message for config.get deserialization error.
|
||||
[#2902](https://github.com/rust-lang/mdBook/pull/2902)
|
||||
- Filter `<mark>` tags from sidebar heading nav.
|
||||
[#2899](https://github.com/rust-lang/mdBook/pull/2899)
|
||||
- Avoid divide-by-zero in heading nav computation
|
||||
[#2891](https://github.com/rust-lang/mdBook/pull/2891)
|
||||
- Fixed heading nav with folded chapters.
|
||||
[#2893](https://github.com/rust-lang/mdBook/pull/2893)
|
||||
|
||||
## mdBook 0.5.0-alpha.1
|
||||
[v0.4.52...v0.5.0-alpha.1](https://github.com/rust-lang/mdBook/compare/v0.4.52...v0.5.0-alpha.1)
|
||||
|
||||
### Added
|
||||
|
||||
- The location of the generated HTML book is now displayed on the console.
|
||||
[#2729](https://github.com/rust-lang/mdBook/pull/2729)
|
||||
- ❗ Added the `optional` field for preprocessors. The default is `false`, so this also changes it so that it is an error if the preprocessor is missing.
|
||||
[#2797](https://github.com/rust-lang/mdBook/pull/2797)
|
||||
- ❗ Added `MarkdownOptions` struct to specify settings for markdown rendering.
|
||||
[#2809](https://github.cocm/rust-lang/mdBook/pull/2809)
|
||||
- Added sidebar heading navigation. This includes the `output.html.sidebar-header-nav` option to disable it.
|
||||
[#2822](https://github.com/rust-lang/mdBook/pull/2822)
|
||||
- Added the mdbook version to the guide.
|
||||
[#2826](https://github.com/rust-lang/mdBook/pull/2826)
|
||||
- Added `Book::chapters` and `Book::for_each_chapter_mut` to more conveniently iterate over chapters (instead of all items).
|
||||
[#2838](https://github.com/rust-lang/mdBook/pull/2838)
|
||||
- ❗ Added support for definition lists. These are enabled by default, with the option `output.html.definition-lists` to disable it.
|
||||
[#2847](https://github.com/rust-lang/mdBook/pull/2847)
|
||||
- ❗ Added support for admonitions. These are enabled by default, with the option `output.html.admonitions` to disable it.
|
||||
[#2851](https://github.com/rust-lang/mdBook/pull/2851)
|
||||
|
||||
### Changed
|
||||
|
||||
- ❗ The `mdbook` crate has been split into multiple crates.
|
||||
[#2766](https://github.com/rust-lang/mdBook/pull/2766)
|
||||
- The minimum Rust version has been updated to 1.88.
|
||||
[#2844](https://github.com/rust-lang/mdBook/pull/2844)
|
||||
- ❗ `pulldown-cmark` has been upgraded to 0.13.0, bringing a large number of fixes to markdown processing.
|
||||
[#2401](https://github.com/rust-lang/mdBook/pull/2401)
|
||||
- ❗ Switched public types to `non_exhaustive` to help allow them to change in a backwards-compatible way.
|
||||
[#2779](https://github.com/rust-lang/mdBook/pull/2779)
|
||||
[#2823](https://github.com/rust-lang/mdBook/pull/2823)
|
||||
- ❗ Unknown fields in config are now an error.
|
||||
[#2787](https://github.com/rust-lang/mdBook/pull/2787)
|
||||
[#2801](https://github.com/rust-lang/mdBook/pull/2801)
|
||||
- ❗ Changed `id_from_content` to be private.
|
||||
[#2791](https://github.com/rust-lang/mdBook/pull/2791)
|
||||
- ❗ Changed preprocessor `command` to use paths relative to the book root.
|
||||
[#2796](https://github.com/rust-lang/mdBook/pull/2796)
|
||||
- ❗ Replaced the `{{#previous}}` and `{{#next}}` handelbars navigation helpers with objects.
|
||||
[#2794](https://github.com/rust-lang/mdBook/pull/2794)
|
||||
- ❗ Use embedded SVG instead of fonts for icons, font-awesome 6.2.
|
||||
[#1330](https://github.com/rust-lang/mdBook/pull/1330)
|
||||
- The `book.src` field is no longer serialized if it is the default of "src".
|
||||
[#2800](https://github.com/rust-lang/mdBook/pull/2800)
|
||||
- ❗ Changed `MDBook` `with_renderer`/`with_preprocessor` to overwrite the entry if an extension of the same name is already loaded.
|
||||
[#2802](https://github.com/rust-lang/mdBook/pull/2802)
|
||||
- ❗ Changed CLI `--dest-dir` to be relative to the current directory, not the book root.
|
||||
[#2806](https://github.com/rust-lang/mdBook/pull/2806)
|
||||
- ❗ Changed all internal HTML IDs to have an `mdbook-` prefix. This helps avoid namespace conflicts with header IDs.
|
||||
[#2808](https://github.com/rust-lang/mdBook/pull/2808)
|
||||
- ❗ `output.html.smart-punctuation` is now `true` by default.
|
||||
[#2810](https://github.com/rust-lang/mdBook/pull/2810)
|
||||
- ❗ Renamed `Book::sections` to `Book::items`.
|
||||
[#2813](https://github.com/rust-lang/mdBook/pull/2813)
|
||||
- ❗ `output.html.hash-files` is now `true` by default.
|
||||
[#2820](https://github.com/rust-lang/mdBook/pull/2820)
|
||||
- Switched from `log` to `tracing`.
|
||||
[#2829](https://github.com/rust-lang/mdBook/pull/2829)
|
||||
- ❗ Rewrote the HTML rendering pipeline.
|
||||
[#2844](https://github.com/rust-lang/mdBook/pull/2844)
|
||||
- ❗ Links on the print page now link to elements on the print page instead of linking out to the individual chapters.
|
||||
[#2844](https://github.com/rust-lang/mdBook/pull/2844)
|
||||
- ❗ Moved theme copy to the Theme type and reduced visibility.
|
||||
[#2857](https://github.com/rust-lang/mdBook/pull/2857)
|
||||
- ❗ Cleaned up some fs-related utilities.
|
||||
[#2856](https://github.com/rust-lang/mdBook/pull/2856)
|
||||
- ❗ Moved `get_404_output_file` to `HtmlConfig`.
|
||||
[#2855](https://github.com/rust-lang/mdBook/pull/2855)
|
||||
- ❗ Moved `take_lines` functions to `mdbook-driver` and made private.
|
||||
[#2854](https://github.com/rust-lang/mdBook/pull/2854)
|
||||
- Updated dependencies.
|
||||
[#2793](https://github.com/rust-lang/mdBook/pull/2793)
|
||||
[#2869](https://github.com/rust-lang/mdBook/pull/2869)
|
||||
|
||||
### Removed
|
||||
|
||||
- ❗ Removed `toml` as a public dependency.
|
||||
[#2773](https://github.com/rust-lang/mdBook/pull/2773)
|
||||
- ❗ Removed the `book.multilingual` field. This was never used.
|
||||
[#2775](https://github.com/rust-lang/mdBook/pull/2775)
|
||||
- ❗ Removed support for google-analytics.
|
||||
[#2776](https://github.com/rust-lang/mdBook/pull/2776)
|
||||
- ❗ Removed the very old legacy config support.
|
||||
[#2783](https://github.com/rust-lang/mdBook/pull/2783)
|
||||
- ❗ Removed `curly-quotes`, use `output.html.smart-punctuation` instead.
|
||||
[#2788](https://github.com/rust-lang/mdBook/pull/2788)
|
||||
- Removed old warning about `book.json`.
|
||||
[#2789](https://github.com/rust-lang/mdBook/pull/2789)
|
||||
- ❗ Removed `output.html.copy-fonts`. The default fonts are now always copied unless you override the `theme/fonts/fonts.css` file.
|
||||
[#2790](https://github.com/rust-lang/mdBook/pull/2790)
|
||||
- ❗ Removed legacy relative renderer command paths. Relative renderer command paths now must always be relative to the book root.
|
||||
[#2792](https://github.com/rust-lang/mdBook/pull/2792)
|
||||
- ❗ Removed the `{{theme_option}}` handlebars helper. It has not been used for a while.
|
||||
[#2795](https://github.com/rust-lang/mdBook/pull/2795)
|
||||
- ❗ Removed the `--dest-dir` option to `mdbook test`.
|
||||
[#2805](https://github.com/rust-lang/mdBook/pull/2805)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed handling of multiple footnotes in a row.
|
||||
[#2807](https://github.com/rust-lang/mdBook/pull/2807)
|
||||
- Fixed ID collisions when the numeric suffix gets used.
|
||||
[#2846](https://github.com/rust-lang/mdBook/pull/2846)
|
||||
- Fixed missing css vars for no-js dark mode.
|
||||
[#2850](https://github.com/rust-lang/mdBook/pull/2850)
|
||||
|
||||
## mdBook 0.4.52
|
||||
[v0.4.51...v0.4.52](https://github.com/rust-lang/mdBook/compare/v0.4.51...v0.4.52)
|
||||
|
||||
**Note:** If you have a custom `index.hbs` theme file, it is recommended that you update it to the latest version to pick up the fixes in this release.
|
||||
|
||||
### Added
|
||||
- Added the ability to redirect `#` HTML fragments using the existing `output.html.redirect` table.
|
||||
[#2747](https://github.com/rust-lang/mdBook/pull/2747)
|
||||
- Added the `rel="edit"` attribute to the edit page button.
|
||||
[#2702](https://github.com/rust-lang/mdBook/pull/2702)
|
||||
|
||||
### Changed
|
||||
- The search index is now only loaded when the search input is opened instead of always being loaded.
|
||||
[#2553](https://github.com/rust-lang/mdBook/pull/2553)
|
||||
[#2735](https://github.com/rust-lang/mdBook/pull/2735)
|
||||
- The `mdbook serve` command has switched its underlying server library from warp to axum.
|
||||
[#2748](https://github.com/rust-lang/mdBook/pull/2748)
|
||||
- Updated dependencies.
|
||||
[#2752](https://github.com/rust-lang/mdBook/pull/2752)
|
||||
|
||||
### Fixed
|
||||
- The sidebar is now set to `display:none` when it is hidden in order to prevent the browser's search from thinking the sidebar's text is visible.
|
||||
[#2725](https://github.com/rust-lang/mdBook/pull/2725)
|
||||
- Fixed search index URL not updating correctly when `hash-files` is enabled.
|
||||
[#2742](https://github.com/rust-lang/mdBook/pull/2742)
|
||||
[#2746](https://github.com/rust-lang/mdBook/pull/2746)
|
||||
- Fixed several sidebar animation bugs, particularly when manually resizing.
|
||||
[#2750](https://github.com/rust-lang/mdBook/pull/2750)
|
||||
|
||||
## mdBook 0.4.51
|
||||
[v0.4.50...v0.4.51](https://github.com/rust-lang/mdBook/compare/v0.4.50...v0.4.51)
|
||||
|
||||
### Fixed
|
||||
- Fixed regression that broke the `S` search hotkey.
|
||||
[#2713](https://github.com/rust-lang/mdBook/pull/2713)
|
||||
|
||||
## mdBook 0.4.50
|
||||
[v0.4.49...v0.4.50](https://github.com/rust-lang/mdBook/compare/v0.4.49...v0.4.50)
|
||||
|
||||
### Added
|
||||
|
||||
- Added a keyboard shortcut help popup when pressing `?`.
|
||||
[#2608](https://github.com/rust-lang/mdBook/pull/2608)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the look of the sidebar resize handle to match the new rustdoc format.
|
||||
[#2691](https://github.com/rust-lang/mdBook/pull/2691)
|
||||
- `/` can now be used to open the search bar.
|
||||
[#2698](https://github.com/rust-lang/mdBook/pull/2698)
|
||||
- Pressing enter from the search bar will navigate to the first entry.
|
||||
[#2698](https://github.com/rust-lang/mdBook/pull/2698)
|
||||
- Updated `opener` to drop some dependencies.
|
||||
[#2709](https://github.com/rust-lang/mdBook/pull/2709)
|
||||
- Updated dependencies, MSRV raised to 1.82.
|
||||
[#2711](https://github.com/rust-lang/mdBook/pull/2711)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed uncaught exception when pressing down when there are no search results.
|
||||
[#2698](https://github.com/rust-lang/mdBook/pull/2698)
|
||||
- Fixed syntax highlighting of Rust code in the ACE editor.
|
||||
[#2710](https://github.com/rust-lang/mdBook/pull/2710)
|
||||
|
||||
## mdBook 0.4.49
|
||||
[v0.4.48...v0.4.49](https://github.com/rust-lang/mdBook/compare/v0.4.48...v0.4.49)
|
||||
|
||||
### Added
|
||||
|
||||
- Added a warning on unused fields in the root of `book.toml`.
|
||||
[#2622](https://github.com/rust-lang/mdBook/pull/2622)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated dependencies.
|
||||
[#2650](https://github.com/rust-lang/mdBook/pull/2650)
|
||||
[#2688](https://github.com/rust-lang/mdBook/pull/2688)
|
||||
- Updated minimum Rust version to 1.81.
|
||||
[#2688](https://github.com/rust-lang/mdBook/pull/2688)
|
||||
- The unused `book.multilingual` field is no longer serialized, or shown in `mdbook init`.
|
||||
[#2689](https://github.com/rust-lang/mdBook/pull/2689)
|
||||
- Speed up search index loading by using `JSON.parse` instead of parsing JavaScript.
|
||||
[#2633](https://github.com/rust-lang/mdBook/pull/2633)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Search highlighting will not try to highlight in SVG `<text>` elements because it breaks the element.
|
||||
[#2668](https://github.com/rust-lang/mdBook/pull/2668)
|
||||
- Fixed scrolling of the sidebar when a search highlight term is in the URL.
|
||||
[#2675](https://github.com/rust-lang/mdBook/pull/2675)
|
||||
- Fixed issues when multiple footnote definitions use the same ID. Now, only one definition is used, and a warning is displayed.
|
||||
[#2681](https://github.com/rust-lang/mdBook/pull/2681)
|
||||
- The sidebar is now restricted to 80% of the viewport width to make it possible to collapse it when the viewport is very narrow.
|
||||
[#2679](https://github.com/rust-lang/mdBook/pull/2679)
|
||||
|
||||
## mdBook 0.4.48
|
||||
[v0.4.47...v0.4.48](https://github.com/rust-lang/mdBook/compare/v0.4.47...v0.4.48)
|
||||
|
||||
### Added
|
||||
|
||||
- Footnotes now have back-reference links. These links bring the reader back to the original location. As part of this change, footnotes are now only rendered at the bottom of the page. This also includes some styling updates and fixes for footnote rendering.
|
||||
[#2626](https://github.com/rust-lang/mdBook/pull/2626)
|
||||
- Added an "Auto" theme selection option which will default to the system-preferred mode. This will also automatically switch when the system changes the preferred mode.
|
||||
[#2576](https://github.com/rust-lang/mdBook/pull/2576)
|
||||
|
||||
### Changed
|
||||
|
||||
- The `searchindex.json` file has been removed; only the `searchindex.js` file will be generated.
|
||||
[#2552](https://github.com/rust-lang/mdBook/pull/2552)
|
||||
- Updated Javascript code to use eslint.
|
||||
[#2554](https://github.com/rust-lang/mdBook/pull/2554)
|
||||
- An error is generated if there are duplicate files in `SUMMARY.md`.
|
||||
[#2613](https://github.com/rust-lang/mdBook/pull/2613)
|
||||
|
||||
## mdBook 0.4.47
|
||||
[v0.4.46...v0.4.47](https://github.com/rust-lang/mdBook/compare/v0.4.46...v0.4.47)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed search not showing up in sub-directories.
|
||||
[#2586](https://github.com/rust-lang/mdBook/pull/2586)
|
||||
|
||||
## mdBook 0.4.46
|
||||
[v0.4.45...v0.4.46](https://github.com/rust-lang/mdBook/compare/v0.4.45...v0.4.46)
|
||||
|
||||
### Changed
|
||||
|
||||
- The `output.html.hash-files` config option has been added to add hashes to static filenames to bust any caches when a book is updated. `{{resource}}` template tags have been added so that links can be properly generated to those files.
|
||||
[#1368](https://github.com/rust-lang/mdBook/pull/1368)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Playground links for Rust 2024 now set the edition correctly.
|
||||
[#2557](https://github.com/rust-lang/mdBook/pull/2557)
|
||||
|
||||
## mdBook 0.4.45
|
||||
[v0.4.44...v0.4.45](https://github.com/rust-lang/mdBook/compare/v0.4.44...v0.4.45)
|
||||
|
||||
### Changed
|
||||
|
||||
- Added context to error message when rustdoc is not found.
|
||||
[#2545](https://github.com/rust-lang/mdBook/pull/2545)
|
||||
- Slightly changed the styling rules around margins of footnotes.
|
||||
[#2524](https://github.com/rust-lang/mdBook/pull/2524)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where it would panic if a source_path is not set.
|
||||
[#2550](https://github.com/rust-lang/mdBook/pull/2550)
|
||||
|
||||
## mdBook 0.4.44
|
||||
[v0.4.43...v0.4.44](https://github.com/rust-lang/mdBook/compare/v0.4.43...v0.4.44)
|
||||
|
||||
### Added
|
||||
|
||||
- Added pre-built aarch64-apple-darwin binaries to the releases.
|
||||
[#2500](https://github.com/rust-lang/mdBook/pull/2500)
|
||||
- `mdbook clean` now shows a summary of what it did.
|
||||
[#2458](https://github.com/rust-lang/mdBook/pull/2458)
|
||||
- Added the `output.html.search.chapter` config setting to disable search indexing of individual chapters.
|
||||
[#2533](https://github.com/rust-lang/mdBook/pull/2533)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto-scrolling the side-bar when loading a page with a `#` fragment URL.
|
||||
[#2517](https://github.com/rust-lang/mdBook/pull/2517)
|
||||
- Fixed display of sidebar when javascript is disabled.
|
||||
[#2529](https://github.com/rust-lang/mdBook/pull/2529)
|
||||
- Fixed the sidebar visibility getting out of sync with the button.
|
||||
[#2532](https://github.com/rust-lang/mdBook/pull/2532)
|
||||
|
||||
### Changed
|
||||
|
||||
- ❗ Rust code block hidden lines now follow the same logic as rustdoc. This requires a space after the `#` symbol.
|
||||
[#2530](https://github.com/rust-lang/mdBook/pull/2530)
|
||||
- ❗ Updated the Linux pre-built binaries which requires a newer version of glibc (2.34).
|
||||
[#2523](https://github.com/rust-lang/mdBook/pull/2523)
|
||||
- Updated dependencies
|
||||
[#2538](https://github.com/rust-lang/mdBook/pull/2538)
|
||||
[#2539](https://github.com/rust-lang/mdBook/pull/2539)
|
||||
|
||||
## mdBook 0.4.43
|
||||
[v0.4.42...v0.4.43](https://github.com/rust-lang/mdBook/compare/v0.4.42...v0.4.43)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed setting the title in `mdbook init` when no git user is configured.
|
||||
[#2486](https://github.com/rust-lang/mdBook/pull/2486)
|
||||
|
||||
### Changed
|
||||
|
||||
- The Rust 2024 edition no longer needs `-Zunstable-options`.
|
||||
[#2495](https://github.com/rust-lang/mdBook/pull/2495)
|
||||
|
||||
## mdBook 0.4.42
|
||||
[v0.4.41...v0.4.42](https://github.com/rust-lang/mdBook/compare/v0.4.41...v0.4.42)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed chapter list folding.
|
||||
[#2473](https://github.com/rust-lang/mdBook/pull/2473)
|
||||
|
||||
## mdBook 0.4.41
|
||||
[v0.4.40...v0.4.41](https://github.com/rust-lang/mdBook/compare/v0.4.40...v0.4.41)
|
||||
|
||||
**Note:** If you have a custom `index.hbs` theme file, you will need to update it to the latest version.
|
||||
|
||||
### Added
|
||||
|
||||
- Added preliminary support for Rust 2024 edition.
|
||||
[#2398](https://github.com/rust-lang/mdBook/pull/2398)
|
||||
- Added a full example of the remove-emphasis preprocessor.
|
||||
[#2464](https://github.com/rust-lang/mdBook/pull/2464)
|
||||
|
||||
### Changed
|
||||
|
||||
- Adjusted styling of clipboard/play icons.
|
||||
[#2421](https://github.com/rust-lang/mdBook/pull/2421)
|
||||
- Updated to handlebars v6.
|
||||
[#2416](https://github.com/rust-lang/mdBook/pull/2416)
|
||||
- Attr and section rules now have specific code highlighting.
|
||||
[#2448](https://github.com/rust-lang/mdBook/pull/2448)
|
||||
- The sidebar is now loaded from a common file, significantly reducing the book size when there are many chapters.
|
||||
[#2414](https://github.com/rust-lang/mdBook/pull/2414)
|
||||
- Updated dependencies.
|
||||
[#2470](https://github.com/rust-lang/mdBook/pull/2470)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved theme support when JavaScript is disabled.
|
||||
[#2454](https://github.com/rust-lang/mdBook/pull/2454)
|
||||
- Fixed broken themes when localStorage has an invalid theme id.
|
||||
[#2463](https://github.com/rust-lang/mdBook/pull/2463)
|
||||
- Adjusted the line-height of superscripts (and footnotes) to avoid adding extra space between lines.
|
||||
[#2465](https://github.com/rust-lang/mdBook/pull/2465)
|
||||
|
||||
## mdBook 0.4.40
|
||||
[v0.4.39...v0.4.40](https://github.com/rust-lang/mdBook/compare/v0.4.39...v0.4.40)
|
||||
|
||||
|
||||
105
CONTRIBUTING.md
105
CONTRIBUTING.md
@@ -7,7 +7,7 @@ If you have come here to learn how to contribute to mdBook, we have some tips fo
|
||||
First of all, don't hesitate to ask questions!
|
||||
Use the [issue tracker](https://github.com/rust-lang/mdBook/issues), no question is too simple.
|
||||
|
||||
### Issue assignment
|
||||
## Issue assignment
|
||||
|
||||
**:warning: Important :warning:**
|
||||
|
||||
@@ -16,7 +16,7 @@ The current PR backlog is beyond what we can process at this time.
|
||||
Only issues that have an [`E-Help-wanted`](https://github.com/rust-lang/mdBook/labels/E-Help-wanted) or [`Feature accepted`](https://github.com/rust-lang/mdBook/labels/Feature%20accepted) label will likely receive reviews.
|
||||
If there isn't already an open issue for what you want to work on, please open one first to see if it is something we would be available to review.
|
||||
|
||||
### Issues to work on
|
||||
## Issues to work on
|
||||
|
||||
If you are starting out, you might be interested in the
|
||||
[E-Easy issues](https://github.com/rust-lang/mdBook/issues?q=is%3Aopen+is%3Aissue+label%3AE-Easy).
|
||||
@@ -41,7 +41,7 @@ Issues on the issue tracker are categorized with the following labels:
|
||||
- **S**-prefixed labels show the status of the issue
|
||||
- **C**-prefixed labels show the category of issue
|
||||
|
||||
### Building mdBook
|
||||
## Building mdBook
|
||||
|
||||
mdBook builds on stable Rust, if you want to build mdBook from source, here are the steps to follow:
|
||||
|
||||
@@ -56,11 +56,11 @@ mdBook builds on stable Rust, if you want to build mdBook from source, here are
|
||||
|
||||
The resulting binary can be found in `mdBook/target/debug/` under the name `mdbook` or `mdbook.exe`.
|
||||
|
||||
### Code Quality
|
||||
## Code quality
|
||||
|
||||
We love code quality and Rust has some excellent tools to assist you with contributions.
|
||||
|
||||
#### Formatting Code with rustfmt
|
||||
### Formatting code with rustfmt
|
||||
|
||||
Before you make your Pull Request to the project, please run it through the `rustfmt` utility.
|
||||
This will ensure we have good quality source code that is better for us all to maintain.
|
||||
@@ -84,8 +84,7 @@ The quick guide is
|
||||
|
||||
For more information, such as running it from your favourite editor, please see the `rustfmt` project. [rustfmt](https://github.com/rust-lang/rustfmt)
|
||||
|
||||
|
||||
#### Finding Issues with Clippy
|
||||
### Finding issues with clippy
|
||||
|
||||
[Clippy](https://doc.rust-lang.org/clippy/) is a code analyser/linter detecting mistakes, and therefore helps to improve your code.
|
||||
Like formatting your code with `rustfmt`, running clippy regularly and before your Pull Request will help us maintain awesome code.
|
||||
@@ -99,7 +98,7 @@ Like formatting your code with `rustfmt`, running clippy regularly and before yo
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
### Change requirements
|
||||
## Change requirements
|
||||
|
||||
Please consider the following when making a change:
|
||||
|
||||
@@ -124,7 +123,34 @@ Please consider the following when making a change:
|
||||
|
||||
* Check out the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) for guidelines on designing the API.
|
||||
|
||||
### Making a pull-request
|
||||
## Tests
|
||||
|
||||
The main test harness is described in the [testsuite documentation](tests/testsuite/README.md). There are several different commands to run different kinds of tests:
|
||||
|
||||
- `cargo test --workspace` — This runs all of the unit and integration tests, except for the GUI tests.
|
||||
- `cargo test --test gui` — This runs the [GUI test harness](#browser-compatibility-and-testing). This does not get run automatically due to its extra requirements.
|
||||
- `npm run lint` — [Checks the `.js` files](#checking-changes-in-js-files)
|
||||
- `cargo test --workspace --no-default-features` — Testing without default features helps check that all feature checks are implemented correctly.
|
||||
- `cargo clippy --workspace --all-targets --no-deps -- -D warnings` — This makes sure that there are no clippy warnings.
|
||||
- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --document-private-items --no-deps` — This verifies that there aren't any rustdoc warnings.
|
||||
- `cargo fmt --check` — Verifies that everything is formatted correctly.
|
||||
- `cargo +stable semver-checks` — Verifies that no SemVer breaking changes have been made. You must install [`cargo-semver-checks`](https://crates.io/crates/cargo-semver-checks) first.
|
||||
|
||||
To help simplify running all these commands, you can run the following cargo command:
|
||||
|
||||
```sh
|
||||
cargo xtask test-all
|
||||
```
|
||||
|
||||
It is useful to run all tests before submitting a PR. While developing I recommend to run some subset of that command based on what you are working on. There are individual arguments for each one. For example:
|
||||
|
||||
```sh
|
||||
cargo xtask test-workspace clippy doc eslint fmt gui semver-checks
|
||||
```
|
||||
|
||||
While developing, remove any of those arguments that are not relevant to what you are changing, or are really slow.
|
||||
|
||||
## Making a pull-request
|
||||
|
||||
When you feel comfortable that your changes could be integrated into mdBook, you can create a pull-request on GitHub.
|
||||
One of the core maintainers will then approve the changes or request some changes before it gets merged.
|
||||
@@ -138,8 +164,43 @@ We generally strive to keep mdBook compatible with a relatively recent browser o
|
||||
That is, supporting Chrome, Safari, Firefox, Edge on Windows, macOS, Linux, iOS, and Android.
|
||||
If possible, do your best to avoid breaking older browser releases.
|
||||
|
||||
Any change to the HTML or styling is encouraged to manually check on as many browsers and platforms that you can.
|
||||
Unfortunately at this time we don't have any automated UI or browser testing, so your assistance in testing is appreciated.
|
||||
GUI tests are checked with the GUI testsuite. To run it, you need to install `npm` first. Then run:
|
||||
|
||||
```
|
||||
cargo test --test gui
|
||||
```
|
||||
|
||||
If you want to only run some tests, you can filter them by passing (part of) their name:
|
||||
|
||||
```
|
||||
cargo test --test gui -- search
|
||||
```
|
||||
|
||||
The first time, it'll fail and ask you to install the `browser-ui-test` package. Install it with the provided
|
||||
command then re-run the tests.
|
||||
|
||||
If you want to disable the headless mode, use the `--disable-headless-test` option:
|
||||
|
||||
```
|
||||
cargo test --test gui -- --disable-headless-test
|
||||
```
|
||||
|
||||
The GUI tests are in the directory `tests/gui` in text files with the `.goml` extension. The books that the tests use are located in the `tests/gui/books` directory. These tests are run using a `node.js` framework called `browser-ui-test`. You can find documentation for this language on its [repository](https://github.com/GuillaumeGomez/browser-UI-test/blob/master/goml-script.md).
|
||||
|
||||
### Checking changes in `.js` files
|
||||
|
||||
The `.js` files source code is checked using [`eslint`](https://eslint.org/). This is a linter (just like `clippy` in Rust)
|
||||
for the Javascript language. You can install it with `npm` by running the following command:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
Then you can run it using:
|
||||
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Updating highlight.js
|
||||
|
||||
@@ -152,20 +213,24 @@ The following are instructions for updating [highlight.js](https://highlightjs.o
|
||||
1. Compare the language list that it spits out to the one in [`syntax-highlighting.md`](https://github.com/camelid/mdBook/blob/master/guide/src/format/theme/syntax-highlighting.md). If any are missing, add them to the list and rebuild (and update these docs). If any are added to the common set, add them to `syntax-highlighting.md`.
|
||||
1. Copy `build/highlight.min.js` to mdbook's directory [`highlight.js`](https://github.com/rust-lang/mdBook/blob/master/src/theme/highlight.js).
|
||||
1. Be sure to check the highlight.js [CHANGES](https://github.com/highlightjs/highlight.js/blob/main/CHANGES.md) for any breaking changes. Breaking changes that would affect users will need to wait until the next major release.
|
||||
1. Build mdbook with the new file and build some books with the new version and compare the output with a variety of languages to see if anything changes. The [test_book](https://github.com/rust-lang/mdBook/tree/master/test_book) contains a chapter with many languages to examine.
|
||||
1. Build mdbook with the new file and build some books with the new version and compare the output with a variety of languages to see if anything changes. The [syntax GUI test](https://github.com/rust-lang/mdBook/tree/master/tests/gui/books/highlighting) contains a chapter with many languages to examine. Update the test (`highlighting.goml`) to add any new languages.
|
||||
|
||||
## Publishing new releases
|
||||
|
||||
Instructions for mdBook maintainers to publish a new release:
|
||||
|
||||
1. Create a PR to update the version and update the CHANGELOG:
|
||||
1. Update the version in `Cargo.toml`
|
||||
2. Run `cargo test` to verify that everything is passing, and to update `Cargo.lock`.
|
||||
3. Double-check for any SemVer breaking changes.
|
||||
Try [`cargo-semver-checks`](https://crates.io/crates/cargo-semver-checks), though beware that the current version of mdBook isn't properly adhering to SemVer due to the lack of `#[non_exhaustive]` and other issues. See https://github.com/rust-lang/mdBook/issues/1835.
|
||||
4. Update `CHANGELOG.md` with any changes that users may be interested in.
|
||||
5. Update `continuous-integration.md` to update the version number for the installation instructions.
|
||||
6. Commit the changes, and open a PR.
|
||||
1. Create a PR that bumps the version and updates the changelog:
|
||||
1. `git fetch upstream`
|
||||
2. `git checkout -B bump-version upstream/master`
|
||||
3. `cargo xtask bump <BUMP>`
|
||||
- This will update the version of all the crates.
|
||||
- `cargo set-version` must first be installed with `cargo install cargo-edit`.
|
||||
- Replace `<BUMP>` with the kind of bump (patch, alpha, etc.)
|
||||
4. `cargo xtask changelog`
|
||||
- This will update `CHANGELOG.md` to add a list of all changes at the top. You will need to move those into the appropriate categories. Most changes that are generally not relevant to a user should be removed. Rewrite the descriptions so that a user can reasonably figure out what it means.
|
||||
5. `git add --update .`
|
||||
6. `git commit`
|
||||
7. `git push`
|
||||
2. After the PR has been merged, create a release in GitHub. This can either be done in the GitHub web UI, or on the command-line:
|
||||
```bash
|
||||
MDBOOK_VERS="`cargo read-manifest | jq -r .version`" ; \
|
||||
|
||||
2039
Cargo.lock
generated
2039
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
168
Cargo.toml
168
Cargo.toml
@@ -1,70 +1,136 @@
|
||||
[workspace]
|
||||
members = [
|
||||
".",
|
||||
"crates/*",
|
||||
"examples/remove-emphasis/mdbook-remove-emphasis", "guide/guide-helper",
|
||||
]
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "allow", priority = -2 }
|
||||
correctness = { level = "warn", priority = -1 }
|
||||
complexity = { level = "warn", priority = -1 }
|
||||
exhaustive_enums = "warn"
|
||||
exhaustive_structs = "warn"
|
||||
manual_non_exhaustive = "warn"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
rust_2018_idioms = "warn"
|
||||
unreachable_pub = "warn"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://github.com/rust-lang/mdBook"
|
||||
rust-version = "1.88.0" # Keep in sync with installation.md and .github/workflows/main.yml
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
axum = "0.8.6"
|
||||
clap = { version = "4.5.50", features = ["cargo", "wrap_help"] }
|
||||
clap_complete = "4.5.59"
|
||||
ego-tree = "0.10.0"
|
||||
elasticlunr-rs = "3.0.2"
|
||||
font-awesome-as-a-crate = "0.3.0"
|
||||
futures-util = "0.3.31"
|
||||
glob = "0.3.3"
|
||||
handlebars = "6.3.2"
|
||||
hex = "0.4.3"
|
||||
html5ever = "0.35.0"
|
||||
indexmap = "2.12.0"
|
||||
ignore = "0.4.24"
|
||||
mdbook-core = { path = "crates/mdbook-core", version = "0.5.0-beta.1" }
|
||||
mdbook-driver = { path = "crates/mdbook-driver", version = "0.5.0-beta.1" }
|
||||
mdbook-html = { path = "crates/mdbook-html", version = "0.5.0-beta.1" }
|
||||
mdbook-markdown = { path = "crates/mdbook-markdown", version = "0.5.0-beta.1" }
|
||||
mdbook-preprocessor = { path = "crates/mdbook-preprocessor", version = "0.5.0-beta.1" }
|
||||
mdbook-renderer = { path = "crates/mdbook-renderer", version = "0.5.0-beta.1" }
|
||||
mdbook-summary = { path = "crates/mdbook-summary", version = "0.5.0-beta.1" }
|
||||
memchr = "2.7.6"
|
||||
notify = "8.2.0"
|
||||
notify-debouncer-mini = "0.7.0"
|
||||
opener = "0.8.3"
|
||||
pathdiff = "0.2.3"
|
||||
pulldown-cmark = { version = "0.13.0", default-features = false, features = ["html"] } # Do not update, part of the public api.
|
||||
regex = "1.12.2"
|
||||
select = "0.6.1"
|
||||
semver = "1.0.27"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
snapbox = "0.6.22"
|
||||
tempfile = "3.23.0"
|
||||
tokio = "1.48.0"
|
||||
toml = "0.9.8"
|
||||
topological-sort = "0.2.2"
|
||||
tower-http = "0.6.6"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
walkdir = "2.5.0"
|
||||
|
||||
[package]
|
||||
name = "mdbook"
|
||||
version = "0.4.40"
|
||||
version = "0.5.0-beta.1"
|
||||
authors = [
|
||||
"Mathieu David <mathieudavid@mathieudavid.org>",
|
||||
"Michael-F-Bryan <michaelfbryan@gmail.com>",
|
||||
"Matt Ickstadt <mattico8@gmail.com>"
|
||||
]
|
||||
documentation = "https://rust-lang.github.io/mdBook/index.html"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
exclude = ["/guide/*"]
|
||||
keywords = ["book", "gitbook", "rustbook", "markdown"]
|
||||
license = "MPL-2.0"
|
||||
license.workspace = true
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/rust-lang/mdBook"
|
||||
repository.workspace = true
|
||||
description = "Creates a book from markdown files"
|
||||
rust-version = "1.74"
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.71"
|
||||
chrono = { version = "0.4.24", default-features = false, features = ["clock"] }
|
||||
clap = { version = "4.3.12", features = ["cargo", "wrap_help"] }
|
||||
clap_complete = "4.3.2"
|
||||
once_cell = "1.17.1"
|
||||
env_logger = "0.11.1"
|
||||
handlebars = "5.0"
|
||||
log = "0.4.17"
|
||||
memchr = "2.5.0"
|
||||
opener = "0.7.0"
|
||||
pulldown-cmark = { version = "0.10.0", default-features = false, features = ["html"] } # Do not update, part of the public api.
|
||||
regex = "1.8.1"
|
||||
serde = { version = "1.0.163", features = ["derive"] }
|
||||
serde_json = "1.0.96"
|
||||
shlex = "1.3.0"
|
||||
tempfile = "3.4.0"
|
||||
toml = "0.5.11" # Do not update, see https://github.com/rust-lang/mdBook/issues/2037
|
||||
topological-sort = "0.2.2"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
mdbook-core.workspace = true
|
||||
mdbook-driver.workspace = true
|
||||
mdbook-html.workspace = true
|
||||
mdbook-markdown.workspace = true
|
||||
mdbook-preprocessor.workspace = true
|
||||
mdbook-renderer.workspace = true
|
||||
mdbook-summary.workspace = true
|
||||
opener.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
# Watch feature
|
||||
notify = { version = "6.1.1", optional = true }
|
||||
notify-debouncer-mini = { version = "0.4.1", optional = true }
|
||||
ignore = { version = "0.4.20", optional = true }
|
||||
pathdiff = { version = "0.2.1", optional = true }
|
||||
walkdir = { version = "2.3.3", optional = true }
|
||||
ignore = { workspace = true, optional = true }
|
||||
notify = { workspace = true, optional = true }
|
||||
notify-debouncer-mini = { workspace = true, optional = true }
|
||||
pathdiff = { workspace = true, optional = true }
|
||||
walkdir = { workspace = true, optional = true }
|
||||
|
||||
# Serve feature
|
||||
futures-util = { version = "0.3.28", optional = true }
|
||||
tokio = { version = "1.28.1", features = ["macros", "rt-multi-thread"], optional = true }
|
||||
warp = { version = "0.3.6", default-features = false, features = ["websocket"], optional = true }
|
||||
|
||||
# Search feature
|
||||
elasticlunr-rs = { version = "3.0.2", optional = true }
|
||||
ammonia = { version = "4.0.0", optional = true }
|
||||
axum = { workspace = true, features = ["ws"], optional = true }
|
||||
futures-util = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"], optional = true }
|
||||
tower-http = { workspace = true, features = ["fs", "trace"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0.11"
|
||||
predicates = "3.0.3"
|
||||
select = "0.6.0"
|
||||
semver = "1.0.17"
|
||||
pretty_assertions = "1.3.0"
|
||||
walkdir = "2.3.3"
|
||||
glob.workspace = true
|
||||
regex.workspace = true
|
||||
select.workspace = true
|
||||
semver.workspace = true
|
||||
serde_json.workspace = true
|
||||
snapbox = { workspace = true, features = ["diff", "dir", "term-svg", "regex", "json"] }
|
||||
tempfile.workspace = true
|
||||
walkdir.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["watch", "serve", "search"]
|
||||
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
|
||||
serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
|
||||
search = ["dep:elasticlunr-rs", "dep:ammonia"]
|
||||
serve = ["dep:futures-util", "dep:tokio", "dep:axum", "dep:tower-http"]
|
||||
search = ["mdbook-html/search"]
|
||||
|
||||
[[bin]]
|
||||
doc = false
|
||||
@@ -73,3 +139,19 @@ name = "mdbook"
|
||||
[[example]]
|
||||
name = "nop-preprocessor"
|
||||
test = true
|
||||
|
||||
[[example]]
|
||||
name = "remove-emphasis"
|
||||
path = "examples/remove-emphasis/test.rs"
|
||||
crate-type = ["lib"]
|
||||
test = true
|
||||
|
||||
[[test]]
|
||||
harness = false
|
||||
test = false
|
||||
name = "gui"
|
||||
path = "tests/gui/runner.rs"
|
||||
crate-type = ["bin"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# mdBook
|
||||
|
||||
[](https://github.com/rust-lang/mdBook/actions?workflow=CI)
|
||||
[](https://github.com/rust-lang/mdBook/actions/workflows/main.yml)
|
||||
[](https://crates.io/crates/mdbook)
|
||||
[](LICENSE)
|
||||
|
||||
|
||||
38
ci/publish-guide.sh
Executable file
38
ci/publish-guide.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# This publishes the user guide to GitHub Pages.
|
||||
#
|
||||
# If this is a pre-release, then it goes in a separate directory called "pre-release".
|
||||
# Commits are amended to avoid keeping history which can balloon the repo size.
|
||||
set -ex
|
||||
|
||||
cargo run --no-default-features -F search -- build guide
|
||||
|
||||
VERSION=$(cargo metadata --format-version 1 --no-deps | jq '.packages[] | select(.name == "mdbook") | .version')
|
||||
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
PRERELEASE=true
|
||||
else
|
||||
PRERELEASE=false
|
||||
fi
|
||||
|
||||
git fetch origin gh-pages
|
||||
git worktree add gh-pages gh-pages
|
||||
git config user.name "Deploy from CI"
|
||||
git config user.email ""
|
||||
cd gh-pages
|
||||
if [[ "$PRERELEASE" == "true" ]]
|
||||
then
|
||||
rm -rf pre-release
|
||||
mv ../guide/book pre-release
|
||||
git add pre-release
|
||||
git commit --amend -m "Deploy $GITHUB_SHA pre-release to gh-pages"
|
||||
else
|
||||
# Delete everything except pre-release and .git.
|
||||
find . -mindepth 1 -maxdepth 1 -not -name "pre-release" -not -name ".git" -exec rm -rf {} +
|
||||
# Copy the guide here.
|
||||
find ../guide/book/ -mindepth 1 -maxdepth 1 -exec mv {} . \;
|
||||
git add .
|
||||
git commit --amend -m "Deploy $GITHUB_SHA to gh-pages"
|
||||
fi
|
||||
|
||||
git push --force origin +gh-pages
|
||||
44
ci/update-dependencies.sh
Executable file
44
ci/update-dependencies.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Updates all compatible Cargo dependencies.
|
||||
#
|
||||
# I wasn't able to get Renovate to update compatible dependencies in a way
|
||||
# that I like, so this script takes care of it. This uses `cargo upgrade` to
|
||||
# ensure that `Cargo.toml` also gets updated. This also makes sure that all
|
||||
# transitive dependencies are updated.
|
||||
|
||||
set -ex
|
||||
|
||||
git fetch origin update-dependencies
|
||||
if git checkout update-dependencies
|
||||
then
|
||||
git reset --hard origin/master
|
||||
else
|
||||
git checkout -b update-dependencies
|
||||
fi
|
||||
|
||||
cat > commit-message << 'EOF'
|
||||
Update cargo dependencies
|
||||
|
||||
```
|
||||
EOF
|
||||
cargo upgrade >> commit-message
|
||||
echo '```' >> commit-message
|
||||
if git diff --quiet
|
||||
then
|
||||
echo "No changes detected, exiting."
|
||||
exit 0
|
||||
fi
|
||||
# Also update any transitive dependencies.
|
||||
cargo update
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -F commit-message
|
||||
|
||||
git push --force origin update-dependencies
|
||||
|
||||
gh pr create --fill \
|
||||
--head update-dependencies \
|
||||
--base master
|
||||
12
crates/mdbook-compare/Cargo.toml
Normal file
12
crates/mdbook-compare/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "mdbook-compare"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
26
crates/mdbook-compare/README.md
Normal file
26
crates/mdbook-compare/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# mdbook-compare
|
||||
|
||||
This is a simple utility to compare the output of two different versions of mdbook.
|
||||
|
||||
To use this:
|
||||
|
||||
1. Install [`tidy`](https://www.html-tidy.org/).
|
||||
2. Install or build the initial version of mdbook that you want to compare.
|
||||
3. Install or build the new version of mdbook that you want to compare.
|
||||
4. Run `mdbook-compare` with the arguments to the mdbook executables and the books to build.
|
||||
|
||||
```sh
|
||||
cargo run --manifest-path /path/to/mdBook/Cargo.toml -p mdbook-compare -- \
|
||||
/path/to/orig/mdbook /path/to/my-book /path/to/new/mdbook /path/to/my-book
|
||||
```
|
||||
|
||||
It takes two separate paths for the book to use for "before" and "after" in case you need to customize the book to run on older versions. If you don't need that, then you can use the same directory for both the before and after.
|
||||
|
||||
`mdbook-compare` will do the following:
|
||||
|
||||
1. Clean up any book directories.
|
||||
2. Build the book with the first mdbook.
|
||||
3. Build the book with the second mdbook.
|
||||
4. The output of those two commands are stored in directories called `compare1` and `compare2`.
|
||||
5. The HTML in those directories is normalized using `tidy`.
|
||||
6. Runs `git diff` to compare the output.
|
||||
113
crates/mdbook-compare/src/main.rs
Normal file
113
crates/mdbook-compare/src/main.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Utility to compare the output of two different versions of mdbook.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
macro_rules! error {
|
||||
($msg:literal $($arg:tt)*) => {
|
||||
eprint!("error: ");
|
||||
eprintln!($msg $($arg)*);
|
||||
std::process::exit(1);
|
||||
};
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(mdbook1), Some(book1), Some(mdbook2), Some(book2)) =
|
||||
(args.next(), args.next(), args.next(), args.next())
|
||||
else {
|
||||
eprintln!("error: Expected four arguments: <exe1> <dir1> <exe2> <dir2>");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let mdbook1 = Path::new(&mdbook1);
|
||||
let mdbook2 = Path::new(&mdbook2);
|
||||
let book1 = Path::new(&book1);
|
||||
let book2 = Path::new(&book2);
|
||||
let compare1 = Path::new("compare1");
|
||||
let compare2 = Path::new("compare2");
|
||||
clean(compare1);
|
||||
clean(compare2);
|
||||
clean(&book1.join("book"));
|
||||
clean(&book2.join("book"));
|
||||
build(mdbook1, book1);
|
||||
std::fs::rename(book1.join("book"), compare1).unwrap();
|
||||
build(mdbook2, book2);
|
||||
std::fs::rename(book2.join("book"), compare2).unwrap();
|
||||
diff(compare1, compare2);
|
||||
}
|
||||
|
||||
fn clean(path: &Path) {
|
||||
if path.exists() {
|
||||
println!("removing {path:?}");
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn build(mdbook: &Path, book: &Path) {
|
||||
println!("running `{mdbook:?} build` in `{book:?}`");
|
||||
let status = Command::new(mdbook)
|
||||
.arg("build")
|
||||
.current_dir(book)
|
||||
.status()
|
||||
.unwrap_or_else(|e| {
|
||||
error!("expected {mdbook:?} executable to exist: {e}");
|
||||
});
|
||||
if !status.success() {
|
||||
error!("process {mdbook:?} failed");
|
||||
}
|
||||
process(&book.join("book"));
|
||||
}
|
||||
|
||||
fn process(path: &Path) {
|
||||
for entry in std::fs::read_dir(path).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
process(&path);
|
||||
} else {
|
||||
if path.extension().is_some_and(|ext| ext == "html") {
|
||||
tidy(&path);
|
||||
process_html(&path);
|
||||
} else {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_html(path: &Path) {
|
||||
let content = std::fs::read_to_string(path).unwrap();
|
||||
let Some(start_index) = content.find("<main>") else {
|
||||
return;
|
||||
};
|
||||
let end_index = content.rfind("</main>").unwrap();
|
||||
let new_content = &content[start_index..end_index + 8];
|
||||
std::fs::write(path, new_content).unwrap();
|
||||
}
|
||||
|
||||
fn tidy(path: &Path) {
|
||||
// quiet, no wrap, modify in place
|
||||
let args = "-q -w 0 -m --custom-tags yes --drop-empty-elements no";
|
||||
println!("running `tidy {args}` in `{path:?}`");
|
||||
let status = Command::new("tidy")
|
||||
.args(args.split(' '))
|
||||
.arg(path)
|
||||
.status()
|
||||
.expect("tidy should be installed");
|
||||
if !status.success() {
|
||||
// Exit code 1 is a warning.
|
||||
if status.code() != Some(1) {
|
||||
error!("tidy failed: {status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diff(a: &Path, b: &Path) {
|
||||
let args = "diff --no-index";
|
||||
println!("running `git {args} {a:?} {b:?}`");
|
||||
Command::new("git")
|
||||
.args(args.split(' '))
|
||||
.args([a, b])
|
||||
.status()
|
||||
.unwrap();
|
||||
}
|
||||
22
crates/mdbook-core/Cargo.toml
Normal file
22
crates/mdbook-core/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "mdbook-core"
|
||||
version = "0.5.0-beta.1"
|
||||
description = "The base support library for mdbook, intended for internal use only"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
13
crates/mdbook-core/README.md
Normal file
13
crates/mdbook-core/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# mdbook-core
|
||||
|
||||
[](https://docs.rs/mdbook-core)
|
||||
[](https://crates.io/crates/mdbook-core)
|
||||
[](https://github.com/rust-lang/mdBook/blob/master/CHANGELOG.md)
|
||||
|
||||
This is the base support library for [mdBook](https://rust-lang.github.io/mdBook/). It is intended for internal use only. Other mdBook crates depend on this for any types that are shared across the crates.
|
||||
|
||||
> This crate is maintained by the mdBook team, primarily for use by mdBook and not intended for external use (except as a transitive dependency). This crate may make major changes to its APIs or be deprecated without warning.
|
||||
|
||||
## License
|
||||
|
||||
[Mozilla Public License, version 2.0](https://github.com/rust-lang/mdBook/blob/master/LICENSE)
|
||||
288
crates/mdbook-core/src/book.rs
Normal file
288
crates/mdbook-core/src/book.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! A tree structure representing a book.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// A tree structure representing a book.
|
||||
///
|
||||
/// A book is just a collection of [`BookItems`] which are accessible by
|
||||
/// either iterating (immutably) over the book with [`iter()`], or recursively
|
||||
/// applying a closure to each item to mutate the chapters, using
|
||||
/// [`for_each_mut()`].
|
||||
///
|
||||
/// [`iter()`]: #method.iter
|
||||
/// [`for_each_mut()`]: #method.for_each_mut
|
||||
#[allow(
|
||||
clippy::exhaustive_structs,
|
||||
reason = "This cannot be extended without breaking preprocessors."
|
||||
)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Book {
|
||||
/// The items in this book.
|
||||
pub items: Vec<BookItem>,
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// Create an empty book.
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Creates a new book with the given items.
|
||||
pub fn new_with_items(items: Vec<BookItem>) -> Book {
|
||||
Book { items }
|
||||
}
|
||||
|
||||
/// Get a depth-first iterator over the items in the book.
|
||||
pub fn iter(&self) -> BookItems<'_> {
|
||||
BookItems {
|
||||
items: self.items.iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth-first iterator over each [`Chapter`], skipping draft chapters.
|
||||
pub fn chapters(&self) -> impl Iterator<Item = &Chapter> {
|
||||
self.iter().filter_map(|item| match item {
|
||||
BookItem::Chapter(ch) if !ch.is_draft_chapter() => Some(ch),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively apply a closure to each item in the book, allowing you to
|
||||
/// mutate them.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Unlike the `iter()` method, this requires a closure instead of returning
|
||||
/// an iterator. This is because using iterators can possibly allow you
|
||||
/// to have iterator invalidation errors.
|
||||
pub fn for_each_mut<F>(&mut self, mut func: F)
|
||||
where
|
||||
F: FnMut(&mut BookItem),
|
||||
{
|
||||
for_each_mut(&mut func, &mut self.items);
|
||||
}
|
||||
|
||||
/// Recursively apply a closure to each non-draft chapter in the book,
|
||||
/// allowing you to mutate them.
|
||||
pub fn for_each_chapter_mut<F>(&mut self, mut func: F)
|
||||
where
|
||||
F: FnMut(&mut Chapter),
|
||||
{
|
||||
for_each_mut(
|
||||
&mut |item| {
|
||||
let BookItem::Chapter(ch) = item else {
|
||||
return;
|
||||
};
|
||||
if ch.is_draft_chapter() {
|
||||
return;
|
||||
}
|
||||
func(ch)
|
||||
},
|
||||
&mut self.items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Append a `BookItem` to the `Book`.
|
||||
pub fn push_item<I: Into<BookItem>>(&mut self, item: I) -> &mut Self {
|
||||
self.items.push(item.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn for_each_mut<'a, F, I>(func: &mut F, items: I)
|
||||
where
|
||||
F: FnMut(&mut BookItem),
|
||||
I: IntoIterator<Item = &'a mut BookItem>,
|
||||
{
|
||||
for item in items {
|
||||
if let BookItem::Chapter(ch) = item {
|
||||
for_each_mut(func, &mut ch.sub_items);
|
||||
}
|
||||
|
||||
func(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum representing any type of item which can be added to a book.
|
||||
#[allow(
|
||||
clippy::exhaustive_enums,
|
||||
reason = "This cannot be extended without breaking preprocessors."
|
||||
)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BookItem {
|
||||
/// A nested chapter.
|
||||
Chapter(Chapter),
|
||||
/// A section separator.
|
||||
Separator,
|
||||
/// A part title.
|
||||
PartTitle(String),
|
||||
}
|
||||
|
||||
impl From<Chapter> for BookItem {
|
||||
fn from(other: Chapter) -> BookItem {
|
||||
BookItem::Chapter(other)
|
||||
}
|
||||
}
|
||||
|
||||
/// The representation of a "chapter", usually mapping to a single file on
|
||||
/// disk however it may contain multiple sub-chapters.
|
||||
#[allow(
|
||||
clippy::exhaustive_structs,
|
||||
reason = "This cannot be extended without breaking preprocessors."
|
||||
)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Chapter {
|
||||
/// The chapter's name.
|
||||
pub name: String,
|
||||
/// The chapter's contents.
|
||||
pub content: String,
|
||||
/// The chapter's section number, if it has one.
|
||||
pub number: Option<SectionNumber>,
|
||||
/// Nested items.
|
||||
pub sub_items: Vec<BookItem>,
|
||||
/// The chapter's location, relative to the `SUMMARY.md` file.
|
||||
///
|
||||
/// **Note**: After the index preprocessor runs, any README files will be
|
||||
/// modified to be `index.md`. If you need access to the actual filename
|
||||
/// on disk, use [`Chapter::source_path`] instead.
|
||||
///
|
||||
/// This is `None` for a draft chapter.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The chapter's source file, relative to the `SUMMARY.md` file.
|
||||
///
|
||||
/// **Note**: Beware that README files will internally be treated as
|
||||
/// `index.md` via the [`Chapter::path`] field. The `source_path` field
|
||||
/// exists if you need access to the true file path.
|
||||
///
|
||||
/// This is `None` for a draft chapter, or a synthetically generated
|
||||
/// chapter that has no file on disk.
|
||||
pub source_path: Option<PathBuf>,
|
||||
/// An ordered list of the names of each chapter above this one in the hierarchy.
|
||||
pub parent_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
/// Create a new chapter with the provided content.
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
name: &str,
|
||||
content: String,
|
||||
p: P,
|
||||
parent_names: Vec<String>,
|
||||
) -> Chapter {
|
||||
let path: PathBuf = p.into();
|
||||
Chapter {
|
||||
name: name.to_string(),
|
||||
content,
|
||||
path: Some(path.clone()),
|
||||
source_path: Some(path),
|
||||
parent_names,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new draft chapter that is not attached to a source markdown file (and thus
|
||||
/// has no content).
|
||||
pub fn new_draft(name: &str, parent_names: Vec<String>) -> Self {
|
||||
Chapter {
|
||||
name: name.to_string(),
|
||||
content: String::new(),
|
||||
path: None,
|
||||
source_path: None,
|
||||
parent_names,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the chapter is a draft chapter, meaning it has no path to a source markdown file.
|
||||
pub fn is_draft_chapter(&self) -> bool {
|
||||
self.path.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Chapter {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
if let Some(ref section_number) = self.number {
|
||||
write!(f, "{section_number} ")?;
|
||||
}
|
||||
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A section number like "1.2.3", basically just a newtype'd `Vec<u32>` with
|
||||
/// a pretty `Display` impl.
|
||||
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SectionNumber(Vec<u32>);
|
||||
|
||||
impl SectionNumber {
|
||||
/// Creates a new [`SectionNumber`].
|
||||
pub fn new(numbers: impl Into<Vec<u32>>) -> SectionNumber {
|
||||
SectionNumber(numbers.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SectionNumber {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
if self.0.is_empty() {
|
||||
write!(f, "0")
|
||||
} else {
|
||||
for item in &self.0 {
|
||||
write!(f, "{item}.")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for SectionNumber {
|
||||
type Target = Vec<u32>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for SectionNumber {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<u32> for SectionNumber {
|
||||
fn from_iter<I: IntoIterator<Item = u32>>(it: I) -> Self {
|
||||
SectionNumber(it.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth-first iterator over the items in a book.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// This struct shouldn't be created directly, instead prefer the
|
||||
/// [`Book::iter()`] method.
|
||||
pub struct BookItems<'a> {
|
||||
items: VecDeque<&'a BookItem>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for BookItems<'a> {
|
||||
type Item = &'a BookItem;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let item = self.items.pop_front();
|
||||
|
||||
if let Some(BookItem::Chapter(ch)) = item {
|
||||
// if we wanted a breadth-first iterator we'd `extend()` here
|
||||
for sub_item in ch.sub_items.iter().rev() {
|
||||
self.items.push_front(sub_item);
|
||||
}
|
||||
}
|
||||
|
||||
item
|
||||
}
|
||||
}
|
||||
123
crates/mdbook-core/src/book/tests.rs
Normal file
123
crates/mdbook-core/src/book/tests.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn section_number_has_correct_dotted_representation() {
|
||||
let inputs = vec![
|
||||
(vec![0], "0."),
|
||||
(vec![1, 3], "1.3."),
|
||||
(vec![1, 2, 3], "1.2.3."),
|
||||
];
|
||||
|
||||
for (input, should_be) in inputs {
|
||||
let section_number = SectionNumber(input).to_string();
|
||||
assert_eq!(section_number, should_be);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_iter_iterates_over_sequential_items() {
|
||||
let items = vec![
|
||||
BookItem::Chapter(Chapter {
|
||||
name: String::from("Chapter 1"),
|
||||
content: String::from("# Chapter 1"),
|
||||
..Default::default()
|
||||
}),
|
||||
BookItem::Separator,
|
||||
];
|
||||
let book = Book::new_with_items(items);
|
||||
|
||||
let should_be: Vec<_> = book.items.iter().collect();
|
||||
|
||||
let got: Vec<_> = book.iter().collect();
|
||||
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_each_mut_visits_all_items() {
|
||||
let items = vec![
|
||||
BookItem::Chapter(Chapter {
|
||||
name: String::from("Chapter 1"),
|
||||
content: String::from("# Chapter 1"),
|
||||
number: None,
|
||||
path: Some(PathBuf::from("Chapter_1/index.md")),
|
||||
source_path: Some(PathBuf::from("Chapter_1/index.md")),
|
||||
parent_names: Vec::new(),
|
||||
sub_items: vec![
|
||||
BookItem::Chapter(Chapter::new(
|
||||
"Hello World",
|
||||
String::new(),
|
||||
"Chapter_1/hello.md",
|
||||
Vec::new(),
|
||||
)),
|
||||
BookItem::Separator,
|
||||
BookItem::Chapter(Chapter::new(
|
||||
"Goodbye World",
|
||||
String::new(),
|
||||
"Chapter_1/goodbye.md",
|
||||
Vec::new(),
|
||||
)),
|
||||
],
|
||||
}),
|
||||
BookItem::Separator,
|
||||
];
|
||||
let mut book = Book::new_with_items(items);
|
||||
|
||||
let num_items = book.iter().count();
|
||||
let mut visited = 0;
|
||||
|
||||
book.for_each_mut(|_| visited += 1);
|
||||
|
||||
assert_eq!(visited, num_items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterate_over_nested_book_items() {
|
||||
let items = vec![
|
||||
BookItem::Chapter(Chapter {
|
||||
name: String::from("Chapter 1"),
|
||||
content: String::from("# Chapter 1"),
|
||||
number: None,
|
||||
path: Some(PathBuf::from("Chapter_1/index.md")),
|
||||
source_path: Some(PathBuf::from("Chapter_1/index.md")),
|
||||
parent_names: Vec::new(),
|
||||
sub_items: vec![
|
||||
BookItem::Chapter(Chapter::new(
|
||||
"Hello World",
|
||||
String::new(),
|
||||
"Chapter_1/hello.md",
|
||||
Vec::new(),
|
||||
)),
|
||||
BookItem::Separator,
|
||||
BookItem::Chapter(Chapter::new(
|
||||
"Goodbye World",
|
||||
String::new(),
|
||||
"Chapter_1/goodbye.md",
|
||||
Vec::new(),
|
||||
)),
|
||||
],
|
||||
}),
|
||||
BookItem::Separator,
|
||||
];
|
||||
let book = Book::new_with_items(items);
|
||||
|
||||
let got: Vec<_> = book.iter().collect();
|
||||
|
||||
assert_eq!(got.len(), 5);
|
||||
|
||||
// checking the chapter names are in the order should be sufficient here...
|
||||
let chapter_names: Vec<String> = got
|
||||
.into_iter()
|
||||
.filter_map(|i| match *i {
|
||||
BookItem::Chapter(ref ch) => Some(ch.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let should_be: Vec<_> = vec![
|
||||
String::from("Chapter 1"),
|
||||
String::from("Hello World"),
|
||||
String::from("Goodbye World"),
|
||||
];
|
||||
|
||||
assert_eq!(chapter_names, should_be);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
16
crates/mdbook-core/src/lib.rs
Normal file
16
crates/mdbook-core/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! The base support library for mdbook, intended for internal use only.
|
||||
|
||||
/// The current version of `mdbook`.
|
||||
///
|
||||
/// This is provided as a way for custom preprocessors and renderers to do
|
||||
/// compatibility checks.
|
||||
pub const MDBOOK_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub mod book;
|
||||
pub mod config;
|
||||
pub mod utils;
|
||||
|
||||
/// The error types used in mdbook.
|
||||
pub mod errors {
|
||||
pub use anyhow::{Error, Result};
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
use crate::errors::*;
|
||||
use log::{debug, trace};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
//! Filesystem utilities and helpers.
|
||||
|
||||
/// Naively replaces any path separator with a forward-slash '/'
|
||||
pub fn normalize_path(path: &str) -> String {
|
||||
use std::path::is_separator;
|
||||
path.chars()
|
||||
.map(|ch| if is_separator(ch) { '/' } else { ch })
|
||||
.collect::<String>()
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Reads a file into a string.
|
||||
///
|
||||
/// Equivalent to [`std::fs::read_to_string`] with better error messages.
|
||||
pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read `{}`", path.display()))
|
||||
}
|
||||
|
||||
/// Write the given data to a file, creating it first if necessary
|
||||
pub fn write_file<P: AsRef<Path>>(build_dir: &Path, filename: P, content: &[u8]) -> Result<()> {
|
||||
let path = build_dir.join(filename);
|
||||
/// Writes a file to disk.
|
||||
///
|
||||
/// Equivalent to [`std::fs::write`] with better error messages. This will
|
||||
/// also create the parent directory if it doesn't exist.
|
||||
pub fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
debug!("Writing `{}`", path.display());
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, contents.as_ref())
|
||||
.with_context(|| format!("failed to write `{}`", path.display()))
|
||||
}
|
||||
|
||||
create_file(&path)?.write_all(content).map_err(Into::into)
|
||||
/// Equivalent to [`std::fs::create_dir_all`] with better error messages.
|
||||
pub fn create_dir_all(p: impl AsRef<Path>) -> Result<()> {
|
||||
let p = p.as_ref();
|
||||
fs::create_dir_all(p)
|
||||
.with_context(|| format!("failed to create directory `{}`", p.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Takes a path and returns a path containing just enough `../` to point to
|
||||
@@ -27,7 +43,7 @@ pub fn write_file<P: AsRef<Path>>(build_dir: &Path, filename: P, content: &[u8])
|
||||
///
|
||||
/// ```rust
|
||||
/// # use std::path::Path;
|
||||
/// # use mdbook::utils::fs::path_to_root;
|
||||
/// # use mdbook_core::utils::fs::path_to_root;
|
||||
/// let path = Path::new("some/relative/path");
|
||||
/// assert_eq!(path_to_root(path), "../../");
|
||||
/// ```
|
||||
@@ -54,30 +70,19 @@ pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
/// This function creates a file and returns it. But before creating the file
|
||||
/// it checks every directory in the path to see if it exists,
|
||||
/// and if it does not it will be created.
|
||||
pub fn create_file(path: &Path) -> Result<File> {
|
||||
debug!("Creating {}", path.display());
|
||||
|
||||
// Construct path
|
||||
if let Some(p) = path.parent() {
|
||||
trace!("Parent directory is: {:?}", p);
|
||||
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
|
||||
File::create(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Removes all the content of a directory but not the directory itself
|
||||
/// Removes all the content of a directory but not the directory itself.
|
||||
pub fn remove_dir_content(dir: &Path) -> Result<()> {
|
||||
for item in fs::read_dir(dir)?.flatten() {
|
||||
for item in fs::read_dir(dir)
|
||||
.with_context(|| format!("failed to read directory `{}`", dir.display()))?
|
||||
.flatten()
|
||||
{
|
||||
let item = item.path();
|
||||
if item.is_dir() {
|
||||
fs::remove_dir_all(item)?;
|
||||
fs::remove_dir_all(&item)
|
||||
.with_context(|| format!("failed to remove `{}`", item.display()))?;
|
||||
} else {
|
||||
fs::remove_file(item)?;
|
||||
fs::remove_file(&item)
|
||||
.with_context(|| format!("failed to remove `{}`", item.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -168,7 +173,7 @@ fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mut reader = File::open(from)?;
|
||||
let mut reader = std::fs::File::open(from)?;
|
||||
let metadata = reader.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
anyhow::bail!(
|
||||
@@ -202,17 +207,11 @@ fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_404_output_file(input_404: &Option<String>) -> String {
|
||||
input_404
|
||||
.as_ref()
|
||||
.unwrap_or(&"404.md".to_string())
|
||||
.replace(".md", ".html")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::copy_files_except_ext;
|
||||
use std::{fs, io::Result, path::Path};
|
||||
use super::*;
|
||||
use std::io::Result;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> Result<()> {
|
||||
@@ -228,47 +227,27 @@ mod tests {
|
||||
fn copy_files_except_ext_test() {
|
||||
let tmp = match tempfile::TempDir::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("Could not create a temp dir: {}", e),
|
||||
Err(e) => panic!("Could not create a temp dir: {e}"),
|
||||
};
|
||||
|
||||
// Create a couple of files
|
||||
if let Err(err) = fs::File::create(tmp.path().join("file.txt")) {
|
||||
panic!("Could not create file.txt: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::File::create(tmp.path().join("file.md")) {
|
||||
panic!("Could not create file.md: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::File::create(tmp.path().join("file.png")) {
|
||||
panic!("Could not create file.png: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::create_dir(tmp.path().join("sub_dir")) {
|
||||
panic!("Could not create sub_dir: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::File::create(tmp.path().join("sub_dir/file.png")) {
|
||||
panic!("Could not create sub_dir/file.png: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::create_dir(tmp.path().join("sub_dir_exists")) {
|
||||
panic!("Could not create sub_dir_exists: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::File::create(tmp.path().join("sub_dir_exists/file.txt")) {
|
||||
panic!("Could not create sub_dir_exists/file.txt: {}", err);
|
||||
}
|
||||
write(tmp.path().join("file.txt"), "").unwrap();
|
||||
write(tmp.path().join("file.md"), "").unwrap();
|
||||
write(tmp.path().join("file.png"), "").unwrap();
|
||||
write(tmp.path().join("sub_dir/file.png"), "").unwrap();
|
||||
write(tmp.path().join("sub_dir_exists/file.txt"), "").unwrap();
|
||||
if let Err(err) = symlink(tmp.path().join("file.png"), tmp.path().join("symlink.png")) {
|
||||
panic!("Could not symlink file.png: {}", err);
|
||||
panic!("Could not symlink file.png: {err}");
|
||||
}
|
||||
|
||||
// Create output dir
|
||||
if let Err(err) = fs::create_dir(tmp.path().join("output")) {
|
||||
panic!("Could not create output: {}", err);
|
||||
}
|
||||
if let Err(err) = fs::create_dir(tmp.path().join("output/sub_dir_exists")) {
|
||||
panic!("Could not create output/sub_dir_exists: {}", err);
|
||||
}
|
||||
create_dir_all(tmp.path().join("output")).unwrap();
|
||||
create_dir_all(tmp.path().join("output/sub_dir_exists")).unwrap();
|
||||
|
||||
if let Err(e) =
|
||||
copy_files_except_ext(tmp.path(), &tmp.path().join("output"), true, None, &["md"])
|
||||
{
|
||||
panic!("Error while executing the function:\n{:?}", e);
|
||||
panic!("Error while executing the function:\n{e:?}");
|
||||
}
|
||||
|
||||
// Check if the correct files where created
|
||||
78
crates/mdbook-core/src/utils/html.rs
Normal file
78
crates/mdbook-core/src/utils/html.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Utilities for dealing with HTML.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Escape characters to make it safe for an HTML string.
|
||||
pub fn escape_html_attribute(text: &str) -> Cow<'_, str> {
|
||||
let needs_escape: &[char] = &['<', '>', '\'', '"', '\\', '&'];
|
||||
let mut s = text;
|
||||
let mut output = String::new();
|
||||
while let Some(next) = s.find(needs_escape) {
|
||||
output.push_str(&s[..next]);
|
||||
match s.as_bytes()[next] {
|
||||
b'<' => output.push_str("<"),
|
||||
b'>' => output.push_str(">"),
|
||||
b'\'' => output.push_str("'"),
|
||||
b'"' => output.push_str("""),
|
||||
b'\\' => output.push_str("\"),
|
||||
b'&' => output.push_str("&"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
s = &s[next + 1..];
|
||||
}
|
||||
if output.is_empty() {
|
||||
Cow::Borrowed(text)
|
||||
} else {
|
||||
output.push_str(s);
|
||||
Cow::Owned(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape `<`, `>`, and '&' for HTML.
|
||||
pub fn escape_html(text: &str) -> Cow<'_, str> {
|
||||
let needs_escape: &[char] = &['<', '>', '&'];
|
||||
let mut s = text;
|
||||
let mut output = String::new();
|
||||
while let Some(next) = s.find(needs_escape) {
|
||||
output.push_str(&s[..next]);
|
||||
match s.as_bytes()[next] {
|
||||
b'<' => output.push_str("<"),
|
||||
b'>' => output.push_str(">"),
|
||||
b'&' => output.push_str("&"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
s = &s[next + 1..];
|
||||
}
|
||||
if output.is_empty() {
|
||||
Cow::Borrowed(text)
|
||||
} else {
|
||||
output.push_str(s);
|
||||
Cow::Owned(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_are_escaped() {
|
||||
assert_eq!(escape_html_attribute(""), "");
|
||||
assert_eq!(escape_html_attribute("<"), "<");
|
||||
assert_eq!(escape_html_attribute(">"), ">");
|
||||
assert_eq!(escape_html_attribute("<>"), "<>");
|
||||
assert_eq!(escape_html_attribute("<test>"), "<test>");
|
||||
assert_eq!(escape_html_attribute("a<test>b"), "a<test>b");
|
||||
assert_eq!(escape_html_attribute("'"), "'");
|
||||
assert_eq!(escape_html_attribute("\\"), "\");
|
||||
assert_eq!(escape_html_attribute("&"), "&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_is_escaped() {
|
||||
assert_eq!(escape_html(""), "");
|
||||
assert_eq!(escape_html("<"), "<");
|
||||
assert_eq!(escape_html(">"), ">");
|
||||
assert_eq!(escape_html("&"), "&");
|
||||
assert_eq!(escape_html("<>"), "<>");
|
||||
assert_eq!(escape_html("<test>"), "<test>");
|
||||
assert_eq!(escape_html("a<test>b"), "a<test>b");
|
||||
assert_eq!(escape_html("'"), "'");
|
||||
assert_eq!(escape_html("\\"), "\\");
|
||||
}
|
||||
37
crates/mdbook-core/src/utils/mod.rs
Normal file
37
crates/mdbook-core/src/utils/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Various helpers and utilities.
|
||||
|
||||
use anyhow::Error;
|
||||
use std::fmt::Write;
|
||||
use tracing::error;
|
||||
|
||||
pub mod fs;
|
||||
mod html;
|
||||
mod toml_ext;
|
||||
|
||||
pub(crate) use self::toml_ext::TomlExt;
|
||||
|
||||
pub use self::html::{escape_html, escape_html_attribute};
|
||||
|
||||
/// Defines a `static` with a [`regex::Regex`].
|
||||
#[macro_export]
|
||||
macro_rules! static_regex {
|
||||
($name:ident, $regex:literal) => {
|
||||
static $name: std::sync::LazyLock<regex::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::Regex::new($regex).unwrap());
|
||||
};
|
||||
($name:ident, bytes, $regex:literal) => {
|
||||
static $name: std::sync::LazyLock<regex::bytes::Regex> =
|
||||
std::sync::LazyLock::new(|| regex::bytes::Regex::new($regex).unwrap());
|
||||
};
|
||||
}
|
||||
|
||||
/// Prints a "backtrace" of some `Error`.
|
||||
pub fn log_backtrace(e: &Error) {
|
||||
let mut message = format!("{e}");
|
||||
|
||||
for cause in e.chain().skip(1) {
|
||||
write!(message, "\n\tCaused by: {cause}").unwrap();
|
||||
}
|
||||
|
||||
error!("{message}");
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Helper for working with toml types.
|
||||
|
||||
use toml::value::{Table, Value};
|
||||
|
||||
/// Helper for working with toml types.
|
||||
pub(crate) trait TomlExt {
|
||||
/// Read a dotted key.
|
||||
fn read(&self, key: &str) -> Option<&Value>;
|
||||
fn read_mut(&mut self, key: &str) -> Option<&mut Value>;
|
||||
/// Insert with a dotted key.
|
||||
fn insert(&mut self, key: &str, value: Value);
|
||||
fn delete(&mut self, key: &str) -> Option<Value>;
|
||||
}
|
||||
|
||||
impl TomlExt for Value {
|
||||
@@ -16,14 +19,6 @@ impl TomlExt for Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_mut(&mut self, key: &str) -> Option<&mut Value> {
|
||||
if let Some((head, tail)) = split(key) {
|
||||
self.get_mut(head)?.read_mut(tail)
|
||||
} else {
|
||||
self.get_mut(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: &str, value: Value) {
|
||||
if !self.is_table() {
|
||||
*self = Value::Table(Table::new());
|
||||
@@ -40,16 +35,6 @@ impl TomlExt for Value {
|
||||
table.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&mut self, key: &str) -> Option<Value> {
|
||||
if let Some((head, tail)) = split(key) {
|
||||
self.get_mut(head)?.delete(tail)
|
||||
} else if let Some(table) = self.as_table_mut() {
|
||||
table.remove(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split(key: &str) -> Option<(&str, &str)> {
|
||||
@@ -65,12 +50,11 @@ fn split(key: &str) -> Option<(&str, &str)> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn read_simple_table() {
|
||||
let src = "[table]";
|
||||
let value = Value::from_str(src).unwrap();
|
||||
let value: Value = toml::from_str(src).unwrap();
|
||||
|
||||
let got = value.read("table").unwrap();
|
||||
|
||||
@@ -80,7 +64,7 @@ mod tests {
|
||||
#[test]
|
||||
fn read_nested_item() {
|
||||
let src = "[table]\nnested=true";
|
||||
let value = Value::from_str(src).unwrap();
|
||||
let value: Value = toml::from_str(src).unwrap();
|
||||
|
||||
let got = value.read("table.nested").unwrap();
|
||||
|
||||
@@ -107,24 +91,4 @@ mod tests {
|
||||
let inserted = value.read("first.second").unwrap();
|
||||
assert_eq!(inserted, &item);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_a_top_level_item() {
|
||||
let src = "top = true";
|
||||
let mut value = Value::from_str(src).unwrap();
|
||||
|
||||
let got = value.delete("top").unwrap();
|
||||
|
||||
assert_eq!(got, Value::Boolean(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_a_nested_item() {
|
||||
let src = "[table]\n nested = true";
|
||||
let mut value = Value::from_str(src).unwrap();
|
||||
|
||||
let got = value.delete("table.nested").unwrap();
|
||||
|
||||
assert_eq!(got, Value::Boolean(true));
|
||||
}
|
||||
}
|
||||
32
crates/mdbook-driver/Cargo.toml
Normal file
32
crates/mdbook-driver/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "mdbook-driver"
|
||||
version = "0.5.0-beta.1"
|
||||
description = "High-level library for running mdBook"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
indexmap.workspace = true
|
||||
mdbook-core.workspace = true
|
||||
mdbook-html.workspace = true
|
||||
mdbook-markdown.workspace = true
|
||||
mdbook-preprocessor.workspace = true
|
||||
mdbook-renderer.workspace = true
|
||||
mdbook-summary.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shlex.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
topological-sort.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
search = ["mdbook-html/search"]
|
||||
13
crates/mdbook-driver/README.md
Normal file
13
crates/mdbook-driver/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# mdbook-driver
|
||||
|
||||
[](https://docs.rs/mdbook-driver)
|
||||
[](https://crates.io/crates/mdbook-driver)
|
||||
[](https://github.com/rust-lang/mdBook/blob/master/CHANGELOG.md)
|
||||
|
||||
This is the high-level Rust library for running [mdBook](https://rust-lang.github.io/mdBook/). New books can be created using [`BookBuilder`](https://docs.rs/mdbook-driver/latest/mdbook_driver/init/struct.BookBuilder.html). The primary type [`MDBook`](https://docs.rs/mdbook-driver/latest/mdbook_driver/struct.MDBook.html) can be used to manage and render books.
|
||||
|
||||
> This crate is maintained by the mdBook team for use by the wider ecosystem. This crate follows [semver compatibility](https://doc.rust-lang.org/cargo/reference/semver.html) for its APIs.
|
||||
|
||||
## License
|
||||
|
||||
[Mozilla Public License, version 2.0](https://github.com/rust-lang/mdBook/blob/master/LICENSE)
|
||||
@@ -1,50 +1,32 @@
|
||||
use super::{Preprocessor, PreprocessorContext};
|
||||
use crate::book::Book;
|
||||
use crate::errors::*;
|
||||
use log::{debug, trace, warn};
|
||||
use shlex::Shlex;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use mdbook_core::book::Book;
|
||||
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Stdio};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// A custom preprocessor which will shell out to a 3rd-party program.
|
||||
///
|
||||
/// # Preprocessing Protocol
|
||||
///
|
||||
/// When the `supports_renderer()` method is executed, `CmdPreprocessor` will
|
||||
/// execute the shell command `$cmd supports $renderer`. If the renderer is
|
||||
/// supported, custom preprocessors should exit with a exit code of `0`,
|
||||
/// any other exit code be considered as unsupported.
|
||||
///
|
||||
/// The `run()` method is implemented by passing a `(PreprocessorContext, Book)`
|
||||
/// tuple to the spawned command (`$cmd`) as JSON via `stdin`. Preprocessors
|
||||
/// should then "return" a processed book by printing it to `stdout` as JSON.
|
||||
/// For convenience, the `CmdPreprocessor::parse_input()` function can be used
|
||||
/// to parse the input provided by `mdbook`.
|
||||
///
|
||||
/// Exiting with a non-zero exit code while preprocessing is considered an
|
||||
/// error. `stderr` is passed directly through to the user, so it can be used
|
||||
/// for logging or emitting warnings if desired.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// An example preprocessor is available in this project's `examples/`
|
||||
/// directory.
|
||||
/// See <https://rust-lang.github.io/mdBook/for_developers/preprocessors.html>
|
||||
/// for a description of the preprocessor protocol.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CmdPreprocessor {
|
||||
name: String,
|
||||
cmd: String,
|
||||
root: PathBuf,
|
||||
optional: bool,
|
||||
}
|
||||
|
||||
impl CmdPreprocessor {
|
||||
/// Create a new `CmdPreprocessor`.
|
||||
pub fn new(name: String, cmd: String) -> CmdPreprocessor {
|
||||
CmdPreprocessor { name, cmd }
|
||||
}
|
||||
|
||||
/// A convenience function custom preprocessors can use to parse the input
|
||||
/// written to `stdin` by a `CmdRenderer`.
|
||||
pub fn parse_input<R: Read>(reader: R) -> Result<(PreprocessorContext, Book)> {
|
||||
serde_json::from_reader(reader).with_context(|| "Unable to parse the input")
|
||||
pub fn new(name: String, cmd: String, root: PathBuf, optional: bool) -> CmdPreprocessor {
|
||||
CmdPreprocessor {
|
||||
name,
|
||||
cmd,
|
||||
root,
|
||||
optional,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_input_to_child(&self, child: &mut Child, book: &Book, ctx: &PreprocessorContext) {
|
||||
@@ -70,22 +52,6 @@ impl CmdPreprocessor {
|
||||
pub fn cmd(&self) -> &str {
|
||||
&self.cmd
|
||||
}
|
||||
|
||||
fn command(&self) -> Result<Command> {
|
||||
let mut words = Shlex::new(&self.cmd);
|
||||
let executable = match words.next() {
|
||||
Some(e) => e,
|
||||
None => bail!("Command string was empty"),
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(executable);
|
||||
|
||||
for arg in words {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
impl Preprocessor for CmdPreprocessor {
|
||||
@@ -94,19 +60,31 @@ impl Preprocessor for CmdPreprocessor {
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> {
|
||||
let mut cmd = self.command()?;
|
||||
let mut cmd = crate::compose_command(&self.cmd, &ctx.root)?;
|
||||
|
||||
let mut child = cmd
|
||||
let mut child = match cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.current_dir(&self.root)
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unable to start the \"{}\" preprocessor. Is it installed?",
|
||||
self.name()
|
||||
)
|
||||
})?;
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::handle_command_error(
|
||||
e,
|
||||
self.optional,
|
||||
"preprocessor",
|
||||
"preprocessor",
|
||||
&self.name,
|
||||
&self.cmd,
|
||||
)?;
|
||||
// This should normally not be reached, since the validation
|
||||
// for NotFound should have already happened when running the
|
||||
// "supports" command.
|
||||
return Ok(book);
|
||||
}
|
||||
};
|
||||
|
||||
self.write_input_to_child(&mut child, &book, ctx);
|
||||
|
||||
@@ -134,45 +112,37 @@ impl Preprocessor for CmdPreprocessor {
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_renderer(&self, renderer: &str) -> bool {
|
||||
fn supports_renderer(&self, renderer: &str) -> Result<bool> {
|
||||
debug!(
|
||||
"Checking if the \"{}\" preprocessor supports \"{}\"",
|
||||
self.name(),
|
||||
renderer
|
||||
);
|
||||
|
||||
let mut cmd = match self.command() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Unable to create the command for the \"{}\" preprocessor, {}",
|
||||
self.name(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut cmd = crate::compose_command(&self.cmd, &self.root)?;
|
||||
|
||||
let outcome = cmd
|
||||
match cmd
|
||||
.arg("supports")
|
||||
.arg(renderer)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.current_dir(&self.root)
|
||||
.status()
|
||||
.map(|status| status.code() == Some(0));
|
||||
|
||||
if let Err(ref e) = outcome {
|
||||
if e.kind() == io::ErrorKind::NotFound {
|
||||
warn!(
|
||||
"The command wasn't found, is the \"{}\" preprocessor installed?",
|
||||
self.name
|
||||
);
|
||||
warn!("\tCommand: {}", self.cmd);
|
||||
{
|
||||
Ok(status) => Ok(status.code() == Some(0)),
|
||||
Err(e) => {
|
||||
crate::handle_command_error(
|
||||
e,
|
||||
self.optional,
|
||||
"preprocessor",
|
||||
"preprocessor",
|
||||
&self.name,
|
||||
&self.cmd,
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
outcome.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,14 +153,19 @@ mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
fn guide() -> MDBook {
|
||||
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("guide");
|
||||
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../guide");
|
||||
MDBook::load(example).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_write_and_parse_input() {
|
||||
let cmd = CmdPreprocessor::new("test".to_string(), "test".to_string());
|
||||
let md = guide();
|
||||
let cmd = CmdPreprocessor::new(
|
||||
"test".to_string(),
|
||||
"test".to_string(),
|
||||
md.root.clone(),
|
||||
false,
|
||||
);
|
||||
let ctx = PreprocessorContext::new(
|
||||
md.root.clone(),
|
||||
md.config.clone(),
|
||||
@@ -200,7 +175,7 @@ mod tests {
|
||||
let mut buffer = Vec::new();
|
||||
cmd.write_input(&mut buffer, &md.book, &ctx).unwrap();
|
||||
|
||||
let (got_ctx, got_book) = CmdPreprocessor::parse_input(buffer.as_slice()).unwrap();
|
||||
let (got_ctx, got_book) = mdbook_preprocessor::parse_input(buffer.as_slice()).unwrap();
|
||||
|
||||
assert_eq!(got_book, md.book);
|
||||
assert_eq!(got_ctx, ctx);
|
||||
@@ -1,19 +1,19 @@
|
||||
use regex::Regex;
|
||||
use anyhow::Result;
|
||||
use mdbook_core::book::{Book, BookItem};
|
||||
use mdbook_core::static_regex;
|
||||
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{Preprocessor, PreprocessorContext};
|
||||
use crate::book::{Book, BookItem};
|
||||
use crate::errors::*;
|
||||
use log::warn;
|
||||
use once_cell::sync::Lazy;
|
||||
use tracing::warn;
|
||||
|
||||
/// A preprocessor for converting file name `README.md` to `index.md` since
|
||||
/// `README.md` is the de facto index file in markdown-based documentation.
|
||||
#[derive(Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct IndexPreprocessor;
|
||||
|
||||
impl IndexPreprocessor {
|
||||
pub(crate) const NAME: &'static str = "index";
|
||||
/// Name of this preprocessor.
|
||||
pub const NAME: &'static str = "index";
|
||||
|
||||
/// Create a new `IndexPreprocessor`.
|
||||
pub fn new() -> Self {
|
||||
@@ -68,9 +68,9 @@ fn warn_readme_name_conflict<P: AsRef<Path>>(readme_path: P, index_path: P) {
|
||||
}
|
||||
|
||||
fn is_readme_file<P: AsRef<Path>>(path: P) -> bool {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^readme$").unwrap());
|
||||
static_regex!(README, r"(?i)^readme$");
|
||||
|
||||
RE.is_match(
|
||||
README.is_match(
|
||||
path.as_ref()
|
||||
.file_stem()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
@@ -1,17 +1,18 @@
|
||||
use crate::errors::*;
|
||||
use crate::utils::{
|
||||
use self::take_lines::{
|
||||
take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines,
|
||||
take_rustdoc_include_lines,
|
||||
};
|
||||
use regex::{CaptureMatches, Captures, Regex};
|
||||
use std::fs;
|
||||
use anyhow::{Context, Result};
|
||||
use mdbook_core::book::{Book, BookItem};
|
||||
use mdbook_core::static_regex;
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
|
||||
use regex::{CaptureMatches, Captures};
|
||||
use std::ops::{Bound, Range, RangeBounds, RangeFrom, RangeFull, RangeTo};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use super::{Preprocessor, PreprocessorContext};
|
||||
use crate::book::{Book, BookItem};
|
||||
use log::{error, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
mod take_lines;
|
||||
|
||||
const ESCAPE_CHAR: char = '\\';
|
||||
const MAX_LINK_NESTED_DEPTH: usize = 10;
|
||||
@@ -19,18 +20,20 @@ const MAX_LINK_NESTED_DEPTH: usize = 10;
|
||||
/// A preprocessor for expanding helpers in a chapter. Supported helpers are:
|
||||
///
|
||||
/// - `{{# include}}` - Insert an external file of any type. Include the whole file, only particular
|
||||
///. lines, or only between the specified anchors.
|
||||
/// lines, or only between the specified anchors.
|
||||
/// - `{{# rustdoc_include}}` - Insert an external Rust file, showing the particular lines
|
||||
///. specified or the lines between specified anchors, and include the rest of the file behind `#`.
|
||||
/// specified or the lines between specified anchors, and include the rest of the file behind `#`.
|
||||
/// This hides the lines from initial display but shows them when the reader expands the code
|
||||
/// block and provides them to Rustdoc for testing.
|
||||
/// - `{{# playground}}` - Insert runnable Rust files
|
||||
/// - `{{# title}}` - Override \<title\> of a webpage.
|
||||
#[derive(Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct LinkPreprocessor;
|
||||
|
||||
impl LinkPreprocessor {
|
||||
pub(crate) const NAME: &'static str = "links";
|
||||
/// Name of this preprocessor.
|
||||
pub const NAME: &'static str = "links";
|
||||
|
||||
/// Create a new `LinkPreprocessor`.
|
||||
pub fn new() -> Self {
|
||||
@@ -148,7 +151,6 @@ enum RangeOrAnchor {
|
||||
}
|
||||
|
||||
// A range of lines specified with some include directive.
|
||||
#[allow(clippy::enum_variant_names)] // The prefix can't be removed, and is meant to mirror the contained type
|
||||
#[derive(PartialEq, Debug, Clone)]
|
||||
enum LineRange {
|
||||
Range(Range<usize>),
|
||||
@@ -408,23 +410,19 @@ impl<'a> Iterator for LinkIter<'a> {
|
||||
}
|
||||
|
||||
fn find_links(contents: &str) -> LinkIter<'_> {
|
||||
// lazily compute following regex
|
||||
// r"\\\{\{#.*\}\}|\{\{#([a-zA-Z0-9]+)\s*([^}]+)\}\}")?;
|
||||
static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r"(?x) # insignificant whitespace mode
|
||||
static_regex!(
|
||||
LINK,
|
||||
r"(?x) # insignificant whitespace mode
|
||||
\\\{\{\#.*\}\} # match escaped link
|
||||
| # or
|
||||
\{\{\s* # link opening parens and whitespace
|
||||
\#([a-zA-Z0-9_]+) # link type
|
||||
\s+ # separating whitespace
|
||||
([^}]+) # link target path and space separated properties
|
||||
\}\} # link closing parens",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
\}\} # link closing parens"
|
||||
);
|
||||
|
||||
LinkIter(RE.captures_iter(contents))
|
||||
LinkIter(LINK.captures_iter(contents))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -493,7 +491,7 @@ mod tests {
|
||||
let s = "Some random text with {{#playground file.rs}} and {{#playground test.rs }}...";
|
||||
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
|
||||
assert_eq!(
|
||||
res,
|
||||
@@ -519,7 +517,7 @@ mod tests {
|
||||
let s = "Some random text with {{#playground foo-bar\\baz/_c++.rs}}...";
|
||||
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
|
||||
assert_eq!(
|
||||
res,
|
||||
@@ -536,7 +534,7 @@ mod tests {
|
||||
fn test_find_links_with_range() {
|
||||
let s = "Some random text with {{#include file.rs:10:20}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -555,7 +553,7 @@ mod tests {
|
||||
fn test_find_links_with_line_number() {
|
||||
let s = "Some random text with {{#include file.rs:10}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -574,7 +572,7 @@ mod tests {
|
||||
fn test_find_links_with_from_range() {
|
||||
let s = "Some random text with {{#include file.rs:10:}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -593,7 +591,7 @@ mod tests {
|
||||
fn test_find_links_with_to_range() {
|
||||
let s = "Some random text with {{#include file.rs::20}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -612,7 +610,7 @@ mod tests {
|
||||
fn test_find_links_with_full_range() {
|
||||
let s = "Some random text with {{#include file.rs::}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -631,7 +629,7 @@ mod tests {
|
||||
fn test_find_links_with_no_range_specified() {
|
||||
let s = "Some random text with {{#include file.rs}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -650,7 +648,7 @@ mod tests {
|
||||
fn test_find_links_with_anchor() {
|
||||
let s = "Some random text with {{#include file.rs:anchor}}...";
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![Link {
|
||||
@@ -670,7 +668,7 @@ mod tests {
|
||||
let s = "Some random text with escaped playground \\{{#playground file.rs editable}} ...";
|
||||
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
|
||||
assert_eq!(
|
||||
res,
|
||||
@@ -685,12 +683,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_find_playgrounds_with_properties() {
|
||||
let s =
|
||||
"Some random text with escaped playground {{#playground file.rs editable }} and some \
|
||||
let s = "Some random text with escaped playground {{#playground file.rs editable }} and some \
|
||||
more\n text {{#playground my.rs editable no_run should_panic}} ...";
|
||||
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(
|
||||
res,
|
||||
vec![
|
||||
@@ -715,13 +712,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_find_all_link_types() {
|
||||
let s =
|
||||
"Some random text with escaped playground {{#include file.rs}} and \\{{#contents are \
|
||||
let s = "Some random text with escaped playground {{#include file.rs}} and \\{{#contents are \
|
||||
insignifficant in escaped link}} some more\n text {{#playground my.rs editable \
|
||||
no_run should_panic}} ...";
|
||||
|
||||
let res = find_links(s).collect::<Vec<_>>();
|
||||
println!("\nOUTPUT: {:?}\n", res);
|
||||
println!("\nOUTPUT: {res:?}\n");
|
||||
assert_eq!(res.len(), 3);
|
||||
assert_eq!(
|
||||
res[0],
|
||||
@@ -1,10 +1,9 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use mdbook_core::static_regex;
|
||||
use std::ops::Bound::{Excluded, Included, Unbounded};
|
||||
use std::ops::RangeBounds;
|
||||
|
||||
/// Take a range of lines from a string.
|
||||
pub fn take_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String {
|
||||
pub(super) fn take_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String {
|
||||
let start = match range.start_bound() {
|
||||
Excluded(&n) => n + 1,
|
||||
Included(&n) => n,
|
||||
@@ -24,14 +23,12 @@ pub fn take_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
static ANCHOR_START: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"ANCHOR:\s*(?P<anchor_name>[\w_-]+)").unwrap());
|
||||
static ANCHOR_END: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"ANCHOR_END:\s*(?P<anchor_name>[\w_-]+)").unwrap());
|
||||
static_regex!(ANCHOR_START, r"ANCHOR:\s*(?P<anchor_name>[\w_-]+)");
|
||||
static_regex!(ANCHOR_END, r"ANCHOR_END:\s*(?P<anchor_name>[\w_-]+)");
|
||||
|
||||
/// Take anchored lines from a string.
|
||||
/// Lines containing anchor are ignored.
|
||||
pub fn take_anchored_lines(s: &str, anchor: &str) -> String {
|
||||
pub(super) fn take_anchored_lines(s: &str, anchor: &str) -> String {
|
||||
let mut retained = Vec::<&str>::new();
|
||||
let mut anchor_found = false;
|
||||
|
||||
@@ -63,7 +60,7 @@ pub fn take_anchored_lines(s: &str, anchor: &str) -> String {
|
||||
/// For any lines not in the range, include them but use `#` at the beginning. This will hide the
|
||||
/// lines from initial display but include them when expanding the code snippet or testing with
|
||||
/// rustdoc.
|
||||
pub fn take_rustdoc_include_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String {
|
||||
pub(super) fn take_rustdoc_include_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String {
|
||||
let mut output = String::with_capacity(s.len());
|
||||
|
||||
for (index, line) in s.lines().enumerate() {
|
||||
@@ -81,7 +78,7 @@ pub fn take_rustdoc_include_lines<R: RangeBounds<usize>>(s: &str, range: R) -> S
|
||||
/// For any lines not between the anchors, include them but use `#` at the beginning. This will
|
||||
/// hide the lines from initial display but include them when expanding the code snippet or testing
|
||||
/// with rustdoc.
|
||||
pub fn take_rustdoc_include_anchored_lines(s: &str, anchor: &str) -> String {
|
||||
pub(super) fn take_rustdoc_include_anchored_lines(s: &str, anchor: &str) -> String {
|
||||
let mut output = String::with_capacity(s.len());
|
||||
let mut within_anchored_section = false;
|
||||
|
||||
9
crates/mdbook-driver/src/builtin_preprocessors/mod.rs
Normal file
9
crates/mdbook-driver/src/builtin_preprocessors/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Built-in preprocessors.
|
||||
|
||||
pub use self::cmd::CmdPreprocessor;
|
||||
pub use self::index::IndexPreprocessor;
|
||||
pub use self::links::LinkPreprocessor;
|
||||
|
||||
mod cmd;
|
||||
mod index;
|
||||
mod links;
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::book::BookItem;
|
||||
use crate::errors::*;
|
||||
use crate::renderer::{RenderContext, Renderer};
|
||||
use crate::utils;
|
||||
use log::trace;
|
||||
use std::fs;
|
||||
use anyhow::{Context, Result};
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_renderer::{RenderContext, Renderer};
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Default)]
|
||||
/// A renderer to output the Markdown after the preprocessors have run. Mostly useful
|
||||
/// when debugging preprocessors.
|
||||
#[derive(Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct MarkdownRenderer;
|
||||
|
||||
impl MarkdownRenderer {
|
||||
@@ -27,21 +26,16 @@ impl Renderer for MarkdownRenderer {
|
||||
let book = &ctx.book;
|
||||
|
||||
if destination.exists() {
|
||||
utils::fs::remove_dir_content(destination)
|
||||
fs::remove_dir_content(destination)
|
||||
.with_context(|| "Unable to remove stale Markdown output")?;
|
||||
}
|
||||
|
||||
trace!("markdown render");
|
||||
for item in book.iter() {
|
||||
if let BookItem::Chapter(ref ch) = *item {
|
||||
if !ch.is_draft_chapter() {
|
||||
utils::fs::write_file(
|
||||
&ctx.destination,
|
||||
ch.path.as_ref().expect("Checked path exists before"),
|
||||
ch.content.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for ch in book.chapters() {
|
||||
let path = ctx
|
||||
.destination
|
||||
.join(ch.path.as_ref().expect("Checked path exists before"));
|
||||
fs::write(path, &ch.content)?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(destination)
|
||||
88
crates/mdbook-driver/src/builtin_renderers/mod.rs
Normal file
88
crates/mdbook-driver/src/builtin_renderers/mod.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Built-in renderers.
|
||||
//!
|
||||
//! The HTML renderer can be found in the [`mdbook_html`] crate.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_renderer::{RenderContext, Renderer};
|
||||
use std::process::Stdio;
|
||||
use tracing::{error, info, trace, warn};
|
||||
|
||||
pub use self::markdown_renderer::MarkdownRenderer;
|
||||
|
||||
mod markdown_renderer;
|
||||
|
||||
/// A generic renderer which will shell out to an arbitrary executable.
|
||||
///
|
||||
/// See <https://rust-lang.github.io/mdBook/for_developers/backends.html>
|
||||
/// for a description of the renderer protocol.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CmdRenderer {
|
||||
name: String,
|
||||
cmd: String,
|
||||
}
|
||||
|
||||
impl CmdRenderer {
|
||||
/// Create a new `CmdRenderer` which will invoke the provided `cmd` string.
|
||||
pub fn new(name: String, cmd: String) -> CmdRenderer {
|
||||
CmdRenderer { name, cmd }
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer for CmdRenderer {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &RenderContext) -> Result<()> {
|
||||
info!("Invoking the \"{}\" renderer", self.name);
|
||||
|
||||
let optional_key = format!("output.{}.optional", self.name);
|
||||
let optional = match ctx.config.get(&optional_key) {
|
||||
Ok(Some(value)) => value,
|
||||
Err(e) => bail!("expected bool for `{optional_key}`: {e}"),
|
||||
Ok(None) => false,
|
||||
};
|
||||
|
||||
let _ = fs::create_dir_all(&ctx.destination);
|
||||
|
||||
let mut cmd = crate::compose_command(&self.cmd, &ctx.root)?;
|
||||
let mut child = match cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.current_dir(&ctx.destination)
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return crate::handle_command_error(
|
||||
e, optional, "output", "backend", &self.name, &self.cmd,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mut stdin = child.stdin.take().expect("Child has stdin");
|
||||
if let Err(e) = serde_json::to_writer(&mut stdin, &ctx) {
|
||||
// Looks like the backend hung up before we could finish
|
||||
// sending it the render context. Log the error and keep going
|
||||
warn!("Error writing the RenderContext to the backend, {}", e);
|
||||
}
|
||||
|
||||
// explicitly close the `stdin` file handle
|
||||
drop(stdin);
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.with_context(|| "Error waiting for the backend to complete")?;
|
||||
|
||||
trace!("{} exited with output: {:?}", self.cmd, status);
|
||||
|
||||
if !status.success() {
|
||||
error!("Renderer exited with non-zero return code.");
|
||||
bail!("The \"{}\" renderer failed", self.name);
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
//! Support for initializing a new book.
|
||||
|
||||
use super::MDBook;
|
||||
use crate::config::Config;
|
||||
use crate::errors::*;
|
||||
use crate::theme;
|
||||
use crate::utils::fs::write_file;
|
||||
use log::{debug, error, info, trace};
|
||||
use anyhow::{Context, Result};
|
||||
use mdbook_core::config::Config;
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_html::theme::Theme;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
/// A helper for setting up a new book and its directory structure.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -99,12 +98,10 @@ impl BookBuilder {
|
||||
fn write_book_toml(&self) -> Result<()> {
|
||||
debug!("Writing book.toml");
|
||||
let book_toml = self.root.join("book.toml");
|
||||
let cfg = toml::to_vec(&self.config).with_context(|| "Unable to serialize the config")?;
|
||||
let cfg =
|
||||
toml::to_string(&self.config).with_context(|| "Unable to serialize the config")?;
|
||||
|
||||
File::create(book_toml)
|
||||
.with_context(|| "Couldn't create book.toml")?
|
||||
.write_all(&cfg)
|
||||
.with_context(|| "Unable to write config to book.toml")?;
|
||||
fs::write(&book_toml, cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -112,76 +109,15 @@ impl BookBuilder {
|
||||
debug!("Copying theme");
|
||||
|
||||
let html_config = self.config.html_config().unwrap_or_default();
|
||||
let themedir = html_config.theme_dir(&self.root);
|
||||
|
||||
if !themedir.exists() {
|
||||
debug!(
|
||||
"{} does not exist, creating the directory",
|
||||
themedir.display()
|
||||
);
|
||||
fs::create_dir(&themedir)?;
|
||||
}
|
||||
|
||||
let mut index = File::create(themedir.join("index.hbs"))?;
|
||||
index.write_all(theme::INDEX)?;
|
||||
|
||||
let cssdir = themedir.join("css");
|
||||
if !cssdir.exists() {
|
||||
fs::create_dir(&cssdir)?;
|
||||
}
|
||||
|
||||
let mut general_css = File::create(cssdir.join("general.css"))?;
|
||||
general_css.write_all(theme::GENERAL_CSS)?;
|
||||
|
||||
let mut chrome_css = File::create(cssdir.join("chrome.css"))?;
|
||||
chrome_css.write_all(theme::CHROME_CSS)?;
|
||||
|
||||
if html_config.print.enable {
|
||||
let mut print_css = File::create(cssdir.join("print.css"))?;
|
||||
print_css.write_all(theme::PRINT_CSS)?;
|
||||
}
|
||||
|
||||
let mut variables_css = File::create(cssdir.join("variables.css"))?;
|
||||
variables_css.write_all(theme::VARIABLES_CSS)?;
|
||||
|
||||
let mut favicon = File::create(themedir.join("favicon.png"))?;
|
||||
favicon.write_all(theme::FAVICON_PNG)?;
|
||||
|
||||
let mut favicon = File::create(themedir.join("favicon.svg"))?;
|
||||
favicon.write_all(theme::FAVICON_SVG)?;
|
||||
|
||||
let mut js = File::create(themedir.join("book.js"))?;
|
||||
js.write_all(theme::JS)?;
|
||||
|
||||
let mut highlight_css = File::create(themedir.join("highlight.css"))?;
|
||||
highlight_css.write_all(theme::HIGHLIGHT_CSS)?;
|
||||
|
||||
let mut highlight_js = File::create(themedir.join("highlight.js"))?;
|
||||
highlight_js.write_all(theme::HIGHLIGHT_JS)?;
|
||||
|
||||
write_file(&themedir.join("fonts"), "fonts.css", theme::fonts::CSS)?;
|
||||
for (file_name, contents) in theme::fonts::LICENSES {
|
||||
write_file(&themedir, file_name, contents)?;
|
||||
}
|
||||
for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
|
||||
write_file(&themedir, file_name, contents)?;
|
||||
}
|
||||
write_file(
|
||||
&themedir,
|
||||
theme::fonts::SOURCE_CODE_PRO.0,
|
||||
theme::fonts::SOURCE_CODE_PRO.1,
|
||||
)?;
|
||||
|
||||
Theme::copy_theme(&html_config, &self.root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_gitignore(&self) -> Result<()> {
|
||||
debug!("Creating .gitignore");
|
||||
|
||||
let mut f = File::create(self.root.join(".gitignore"))?;
|
||||
|
||||
writeln!(f, "{}", self.config.build.build_dir.display())?;
|
||||
|
||||
fs::write(
|
||||
self.root.join(".gitignore"),
|
||||
format!("{}", self.config.build.build_dir.display()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -192,14 +128,14 @@ impl BookBuilder {
|
||||
let summary = src_dir.join("SUMMARY.md");
|
||||
if !summary.exists() {
|
||||
trace!("No summary found creating stub summary and chapter_1.md.");
|
||||
let mut f = File::create(&summary).with_context(|| "Unable to create SUMMARY.md")?;
|
||||
writeln!(f, "# Summary")?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "- [Chapter 1](./chapter_1.md)")?;
|
||||
fs::write(
|
||||
summary,
|
||||
"# Summary\n\
|
||||
\n\
|
||||
- [Chapter 1](./chapter_1.md)\n",
|
||||
)?;
|
||||
|
||||
let chapter_1 = src_dir.join("chapter_1.md");
|
||||
let mut f = File::create(chapter_1).with_context(|| "Unable to create chapter_1.md")?;
|
||||
writeln!(f, "# Chapter 1")?;
|
||||
fs::write(src_dir.join("chapter_1.md"), "# Chapter 1\n")?;
|
||||
} else {
|
||||
trace!("Existing summary found, no need to create stub files.");
|
||||
}
|
||||
131
crates/mdbook-driver/src/lib.rs
Normal file
131
crates/mdbook-driver/src/lib.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! High-level library for running mdBook.
|
||||
//!
|
||||
//! This is the high-level library for running
|
||||
//! [mdBook](https://rust-lang.github.io/mdBook/). There are several
|
||||
//! reasons for using the programmatic API (over the CLI):
|
||||
//!
|
||||
//! - Integrate mdBook in a current project.
|
||||
//! - Extend the capabilities of mdBook.
|
||||
//! - Do some processing or test before building your book.
|
||||
//! - Accessing the public API to help create a new Renderer.
|
||||
//!
|
||||
//! ## Additional crates
|
||||
//!
|
||||
//! In addition to `mdbook-driver`, there are several other crates available
|
||||
//! for using and extending mdBook:
|
||||
//!
|
||||
//! - [`mdbook_preprocessor`]: Provides support for implementing preprocessors.
|
||||
//! - [`mdbook_renderer`]: Provides support for implementing renderers.
|
||||
//! - [`mdbook_markdown`]: The Markdown renderer.
|
||||
//! - [`mdbook_summary`]: The `SUMMARY.md` parser.
|
||||
//! - [`mdbook_html`]: The HTML renderer.
|
||||
//! - [`mdbook_core`]: An internal library that is used by the other crates
|
||||
//! for shared types. Types from this crate are rexported from the other
|
||||
//! crates as appropriate.
|
||||
//!
|
||||
//! ## Cargo features
|
||||
//!
|
||||
//! The following cargo features are available:
|
||||
//!
|
||||
//! - `search`: Enables the search index in the HTML renderer.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! If creating a new book from scratch, you'll want to get a [`init::BookBuilder`] via
|
||||
//! the [`MDBook::init()`] method.
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use mdbook_driver::MDBook;
|
||||
//! use mdbook_driver::config::Config;
|
||||
//!
|
||||
//! let root_dir = "/path/to/book/root";
|
||||
//!
|
||||
//! // create a default config and change a couple things
|
||||
//! let mut cfg = Config::default();
|
||||
//! cfg.book.title = Some("My Book".to_string());
|
||||
//! cfg.book.authors.push("Michael-F-Bryan".to_string());
|
||||
//!
|
||||
//! MDBook::init(root_dir)
|
||||
//! .create_gitignore(true)
|
||||
//! .with_config(cfg)
|
||||
//! .build()
|
||||
//! .expect("Book generation failed");
|
||||
//! ```
|
||||
//!
|
||||
//! You can also load an existing book and build it.
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use mdbook_driver::MDBook;
|
||||
//!
|
||||
//! let root_dir = "/path/to/book/root";
|
||||
//!
|
||||
//! let mut md = MDBook::load(root_dir)
|
||||
//! .expect("Unable to load the book");
|
||||
//! md.build().expect("Building failed");
|
||||
//! ```
|
||||
|
||||
pub mod builtin_preprocessors;
|
||||
pub mod builtin_renderers;
|
||||
pub mod init;
|
||||
mod load;
|
||||
mod mdbook;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
pub use mdbook::MDBook;
|
||||
pub use mdbook_core::{book, config, errors};
|
||||
use shlex::Shlex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::{error, warn};
|
||||
|
||||
/// Creates a [`Command`] for command renderers and preprocessors.
|
||||
fn compose_command(cmd: &str, root: &Path) -> Result<Command> {
|
||||
let mut words = Shlex::new(cmd);
|
||||
let exe = match words.next() {
|
||||
Some(e) => PathBuf::from(e),
|
||||
None => bail!("Command string was empty"),
|
||||
};
|
||||
|
||||
let exe = if exe.components().count() == 1 {
|
||||
// Search PATH for the executable.
|
||||
exe
|
||||
} else {
|
||||
// Relative path is relative to book root.
|
||||
root.join(&exe)
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(exe);
|
||||
|
||||
for arg in words {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Handles a failure for a preprocessor or renderer.
|
||||
fn handle_command_error(
|
||||
error: std::io::Error,
|
||||
optional: bool,
|
||||
key: &str,
|
||||
what: &str,
|
||||
name: &str,
|
||||
cmd: &str,
|
||||
) -> Result<()> {
|
||||
if let std::io::ErrorKind::NotFound = error.kind() {
|
||||
if optional {
|
||||
warn!(
|
||||
"The command `{cmd}` for {what} `{name}` was not found, \
|
||||
but is marked as optional.",
|
||||
);
|
||||
return Ok(());
|
||||
} else {
|
||||
error!(
|
||||
"The command `{cmd}` wasn't found, is the `{name}` {what} installed? \
|
||||
If you want to ignore this error when the `{name}` {what} is not installed, \
|
||||
set `optional = true` in the `[{key}.{name}]` section of the book.toml configuration file.",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error).with_context(|| format!("Unable to run the {what} `{name}`"))?
|
||||
}
|
||||
309
crates/mdbook-driver/src/load.rs
Normal file
309
crates/mdbook-driver/src/load.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use anyhow::{Context, Result};
|
||||
use mdbook_core::book::{Book, BookItem, Chapter};
|
||||
use mdbook_core::config::BuildConfig;
|
||||
use mdbook_core::utils::{escape_html, fs};
|
||||
use mdbook_summary::{Link, Summary, SummaryItem, parse_summary};
|
||||
use std::path::Path;
|
||||
use tracing::debug;
|
||||
|
||||
/// Load a book into memory from its `src/` directory.
|
||||
pub(crate) fn load_book<P: AsRef<Path>>(src_dir: P, cfg: &BuildConfig) -> Result<Book> {
|
||||
let src_dir = src_dir.as_ref();
|
||||
let summary_md = src_dir.join("SUMMARY.md");
|
||||
|
||||
let summary_content = fs::read_to_string(&summary_md)?;
|
||||
let summary = parse_summary(&summary_content)
|
||||
.with_context(|| format!("Summary parsing failed for file={summary_md:?}"))?;
|
||||
|
||||
if cfg.create_missing {
|
||||
create_missing(src_dir, &summary).with_context(|| "Unable to create missing chapters")?;
|
||||
}
|
||||
|
||||
load_book_from_disk(&summary, src_dir)
|
||||
}
|
||||
|
||||
fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> {
|
||||
let mut items: Vec<_> = summary
|
||||
.prefix_chapters
|
||||
.iter()
|
||||
.chain(summary.numbered_chapters.iter())
|
||||
.chain(summary.suffix_chapters.iter())
|
||||
.collect();
|
||||
|
||||
while let Some(next) = items.pop() {
|
||||
if let SummaryItem::Link(ref link) = *next {
|
||||
if let Some(ref location) = link.location {
|
||||
let filename = src_dir.join(location);
|
||||
if !filename.exists() {
|
||||
if let Some(parent) = filename.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
debug!("Creating missing file {}", filename.display());
|
||||
let title = escape_html(&link.name);
|
||||
fs::write(&filename, format!("# {title}\n"))?;
|
||||
}
|
||||
}
|
||||
|
||||
items.extend(&link.nested_items);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Use the provided `Summary` to load a `Book` from disk.
|
||||
///
|
||||
/// You need to pass in the book's source directory because all the links in
|
||||
/// `SUMMARY.md` give the chapter locations relative to it.
|
||||
pub(crate) fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<Book> {
|
||||
debug!("Loading the book from disk");
|
||||
let src_dir = src_dir.as_ref();
|
||||
|
||||
let prefix = summary.prefix_chapters.iter();
|
||||
let numbered = summary.numbered_chapters.iter();
|
||||
let suffix = summary.suffix_chapters.iter();
|
||||
|
||||
let summary_items = prefix.chain(numbered).chain(suffix);
|
||||
|
||||
let mut chapters = Vec::new();
|
||||
|
||||
for summary_item in summary_items {
|
||||
let chapter = load_summary_item(summary_item, src_dir, Vec::new())?;
|
||||
chapters.push(chapter);
|
||||
}
|
||||
|
||||
Ok(Book::new_with_items(chapters))
|
||||
}
|
||||
|
||||
fn load_summary_item<P: AsRef<Path> + Clone>(
|
||||
item: &SummaryItem,
|
||||
src_dir: P,
|
||||
parent_names: Vec<String>,
|
||||
) -> Result<BookItem> {
|
||||
match item {
|
||||
SummaryItem::Separator => Ok(BookItem::Separator),
|
||||
SummaryItem::Link(link) => load_chapter(link, src_dir, parent_names).map(BookItem::Chapter),
|
||||
SummaryItem::PartTitle(title) => Ok(BookItem::PartTitle(title.clone())),
|
||||
_ => panic!("SummaryItem {item:?} not covered"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_chapter<P: AsRef<Path>>(
|
||||
link: &Link,
|
||||
src_dir: P,
|
||||
parent_names: Vec<String>,
|
||||
) -> Result<Chapter> {
|
||||
let src_dir = src_dir.as_ref();
|
||||
|
||||
let mut ch = if let Some(ref link_location) = link.location {
|
||||
debug!("Loading {} ({})", link.name, link_location.display());
|
||||
|
||||
let location = if link_location.is_absolute() {
|
||||
link_location.clone()
|
||||
} else {
|
||||
src_dir.join(link_location)
|
||||
};
|
||||
|
||||
let mut content = std::fs::read_to_string(&location)
|
||||
.with_context(|| format!("failed to read chapter `{}`", link_location.display()))?;
|
||||
|
||||
if content.as_bytes().starts_with(b"\xef\xbb\xbf") {
|
||||
content.replace_range(..3, "");
|
||||
}
|
||||
|
||||
let stripped = location
|
||||
.strip_prefix(src_dir)
|
||||
.expect("Chapters are always inside a book");
|
||||
|
||||
Chapter::new(&link.name, content, stripped, parent_names.clone())
|
||||
} else {
|
||||
Chapter::new_draft(&link.name, parent_names.clone())
|
||||
};
|
||||
|
||||
let mut sub_item_parents = parent_names;
|
||||
|
||||
ch.number = link.number.clone();
|
||||
|
||||
sub_item_parents.push(link.name.clone());
|
||||
let sub_items = link
|
||||
.nested_items
|
||||
.iter()
|
||||
.map(|i| load_summary_item(i, src_dir, sub_item_parents.clone()))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
ch.sub_items = sub_items;
|
||||
|
||||
Ok(ch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mdbook_core::book::SectionNumber;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::{Builder as TempFileBuilder, TempDir};
|
||||
|
||||
const DUMMY_SRC: &str = "
|
||||
# Dummy Chapter
|
||||
|
||||
this is some dummy text.
|
||||
|
||||
And here is some \
|
||||
more text.
|
||||
";
|
||||
|
||||
/// Create a dummy `Link` in a temporary directory.
|
||||
fn dummy_link() -> (Link, TempDir) {
|
||||
let temp = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
||||
|
||||
let chapter_path = temp.path().join("chapter_1.md");
|
||||
fs::write(&chapter_path, DUMMY_SRC).unwrap();
|
||||
|
||||
let link = Link::new("Chapter 1", chapter_path);
|
||||
|
||||
(link, temp)
|
||||
}
|
||||
|
||||
/// Create a nested `Link` written to a temporary directory.
|
||||
fn nested_links() -> (Link, TempDir) {
|
||||
let (mut root, temp_dir) = dummy_link();
|
||||
|
||||
let second_path = temp_dir.path().join("second.md");
|
||||
fs::write(&second_path, "Hello World!").unwrap();
|
||||
|
||||
let mut second = Link::new("Nested Chapter 1", &second_path);
|
||||
second.number = Some(SectionNumber::new([1, 2]));
|
||||
|
||||
root.nested_items.push(second.clone().into());
|
||||
root.nested_items.push(SummaryItem::Separator);
|
||||
root.nested_items.push(second.into());
|
||||
|
||||
(root, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_a_single_chapter_from_disk() {
|
||||
let (link, temp_dir) = dummy_link();
|
||||
let should_be = Chapter::new(
|
||||
"Chapter 1",
|
||||
DUMMY_SRC.to_string(),
|
||||
"chapter_1.md",
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_a_single_chapter_with_utf8_bom_from_disk() {
|
||||
let temp_dir = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
||||
|
||||
let chapter_path = temp_dir.path().join("chapter_1.md");
|
||||
fs::write(&chapter_path, format!("\u{feff}{DUMMY_SRC}")).unwrap();
|
||||
|
||||
let link = Link::new("Chapter 1", chapter_path);
|
||||
|
||||
let should_be = Chapter::new(
|
||||
"Chapter 1",
|
||||
DUMMY_SRC.to_string(),
|
||||
"chapter_1.md",
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cant_load_a_nonexistent_chapter() {
|
||||
let link = Link::new("Chapter 1", "/foo/bar/baz.md");
|
||||
|
||||
let got = load_chapter(&link, "", Vec::new());
|
||||
assert!(got.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_recursive_link_with_separators() {
|
||||
let (root, temp) = nested_links();
|
||||
|
||||
let mut nested = Chapter::new(
|
||||
"Nested Chapter 1",
|
||||
String::from("Hello World!"),
|
||||
"second.md",
|
||||
vec![String::from("Chapter 1")],
|
||||
);
|
||||
nested.number = Some(SectionNumber::new([1, 2]));
|
||||
let mut chapter =
|
||||
Chapter::new("Chapter 1", String::from(DUMMY_SRC), "chapter_1.md", vec![]);
|
||||
chapter.sub_items = vec![
|
||||
BookItem::Chapter(nested.clone()),
|
||||
BookItem::Separator,
|
||||
BookItem::Chapter(nested),
|
||||
];
|
||||
let should_be = BookItem::Chapter(chapter);
|
||||
|
||||
let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).unwrap();
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_a_book_with_a_single_chapter() {
|
||||
let (link, temp) = dummy_link();
|
||||
let mut summary = Summary::default();
|
||||
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
||||
let chapter = Chapter::new(
|
||||
"Chapter 1",
|
||||
String::from(DUMMY_SRC),
|
||||
PathBuf::from("chapter_1.md"),
|
||||
vec![],
|
||||
);
|
||||
let items = vec![BookItem::Chapter(chapter)];
|
||||
let should_be = Book::new_with_items(items);
|
||||
|
||||
let got = load_book_from_disk(&summary, temp.path()).unwrap();
|
||||
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cant_load_chapters_with_an_empty_path() {
|
||||
let (_, temp) = dummy_link();
|
||||
let mut summary = Summary::default();
|
||||
let link = Link::new("Empty", "");
|
||||
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
||||
let got = load_book_from_disk(&summary, temp.path());
|
||||
assert!(got.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cant_load_chapters_when_the_link_is_a_directory() {
|
||||
let (_, temp) = dummy_link();
|
||||
let dir = temp.path().join("nested");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mut summary = Summary::default();
|
||||
let link = Link::new("nested", dir);
|
||||
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
||||
|
||||
let got = load_book_from_disk(&summary, temp.path());
|
||||
assert!(got.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cant_open_summary_md() {
|
||||
let cfg = BuildConfig::default();
|
||||
let temp_dir = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
||||
|
||||
let got = load_book(&temp_dir, &cfg);
|
||||
assert!(got.is_err());
|
||||
let error_message = got.err().unwrap().to_string();
|
||||
let expected = format!(
|
||||
r#"failed to read `{}`"#,
|
||||
temp_dir.path().join("SUMMARY.md").display()
|
||||
);
|
||||
assert_eq!(error_message, expected);
|
||||
}
|
||||
}
|
||||
574
crates/mdbook-driver/src/mdbook.rs
Normal file
574
crates/mdbook-driver/src/mdbook.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! The high-level interface for loading and rendering books.
|
||||
|
||||
use crate::builtin_preprocessors::{CmdPreprocessor, IndexPreprocessor, LinkPreprocessor};
|
||||
use crate::builtin_renderers::{CmdRenderer, MarkdownRenderer};
|
||||
use crate::init::BookBuilder;
|
||||
use crate::load::{load_book, load_book_from_disk};
|
||||
use anyhow::{Context, Error, Result, bail};
|
||||
use indexmap::IndexMap;
|
||||
use mdbook_core::book::{Book, BookItem, BookItems};
|
||||
use mdbook_core::config::{Config, RustEdition};
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_html::HtmlHandlebars;
|
||||
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
|
||||
use mdbook_renderer::{RenderContext, Renderer};
|
||||
use mdbook_summary::Summary;
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsString;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::Builder as TempFileBuilder;
|
||||
use topological_sort::TopologicalSort;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The object used to manage and build a book.
|
||||
pub struct MDBook {
|
||||
/// The book's root directory.
|
||||
pub root: PathBuf,
|
||||
|
||||
/// The configuration used to tweak now a book is built.
|
||||
pub config: Config,
|
||||
|
||||
/// A representation of the book's contents in memory.
|
||||
pub book: Book,
|
||||
|
||||
/// Renderers to execute.
|
||||
renderers: IndexMap<String, Box<dyn Renderer>>,
|
||||
|
||||
/// Pre-processors to be run on the book.
|
||||
preprocessors: IndexMap<String, Box<dyn Preprocessor>>,
|
||||
}
|
||||
|
||||
impl MDBook {
|
||||
/// Load a book from its root directory on disk.
|
||||
pub fn load<P: Into<PathBuf>>(book_root: P) -> Result<MDBook> {
|
||||
let book_root = book_root.into();
|
||||
let config_location = book_root.join("book.toml");
|
||||
|
||||
let mut config = if config_location.exists() {
|
||||
debug!("Loading config from {}", config_location.display());
|
||||
Config::from_disk(&config_location)?
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
|
||||
config.update_from_env();
|
||||
|
||||
if tracing::enabled!(tracing::Level::TRACE) {
|
||||
for line in format!("Config: {config:#?}").lines() {
|
||||
trace!("{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
MDBook::load_with_config(book_root, config)
|
||||
}
|
||||
|
||||
/// Load a book from its root directory using a custom `Config`.
|
||||
pub fn load_with_config<P: Into<PathBuf>>(book_root: P, config: Config) -> Result<MDBook> {
|
||||
let root = book_root.into();
|
||||
|
||||
let src_dir = root.join(&config.book.src);
|
||||
let book = load_book(src_dir, &config.build)?;
|
||||
|
||||
let renderers = determine_renderers(&config)?;
|
||||
let preprocessors = determine_preprocessors(&config, &root)?;
|
||||
|
||||
Ok(MDBook {
|
||||
root,
|
||||
config,
|
||||
book,
|
||||
renderers,
|
||||
preprocessors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a book from its root directory using a custom `Config` and a custom summary.
|
||||
pub fn load_with_config_and_summary<P: Into<PathBuf>>(
|
||||
book_root: P,
|
||||
config: Config,
|
||||
summary: Summary,
|
||||
) -> Result<MDBook> {
|
||||
let root = book_root.into();
|
||||
|
||||
let src_dir = root.join(&config.book.src);
|
||||
let book = load_book_from_disk(&summary, src_dir)?;
|
||||
|
||||
let renderers = determine_renderers(&config)?;
|
||||
let preprocessors = determine_preprocessors(&config, &root)?;
|
||||
|
||||
Ok(MDBook {
|
||||
root,
|
||||
config,
|
||||
book,
|
||||
renderers,
|
||||
preprocessors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a flat depth-first iterator over the [`BookItem`]s of the book.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use mdbook_driver::MDBook;
|
||||
/// # use mdbook_driver::book::BookItem;
|
||||
/// # let book = MDBook::load("mybook").unwrap();
|
||||
/// for item in book.iter() {
|
||||
/// match *item {
|
||||
/// BookItem::Chapter(ref chapter) => {},
|
||||
/// BookItem::Separator => {},
|
||||
/// BookItem::PartTitle(ref title) => {}
|
||||
/// _ => {}
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // would print something like this:
|
||||
/// // 1. Chapter 1
|
||||
/// // 1.1 Sub Chapter
|
||||
/// // 1.2 Sub Chapter
|
||||
/// // 2. Chapter 2
|
||||
/// //
|
||||
/// // etc.
|
||||
/// ```
|
||||
pub fn iter(&self) -> BookItems<'_> {
|
||||
self.book.iter()
|
||||
}
|
||||
|
||||
/// `init()` gives you a `BookBuilder` which you can use to setup a new book
|
||||
/// and its accompanying directory structure.
|
||||
///
|
||||
/// The `BookBuilder` creates some boilerplate files and directories to get
|
||||
/// you started with your book.
|
||||
///
|
||||
/// ```text
|
||||
/// book-test/
|
||||
/// ├── book
|
||||
/// └── src
|
||||
/// ├── chapter_1.md
|
||||
/// └── SUMMARY.md
|
||||
/// ```
|
||||
///
|
||||
/// It uses the path provided as the root directory for your book, then adds
|
||||
/// in a `src/` directory containing a `SUMMARY.md` and `chapter_1.md` file
|
||||
/// to get you started.
|
||||
pub fn init<P: Into<PathBuf>>(book_root: P) -> BookBuilder {
|
||||
BookBuilder::new(book_root)
|
||||
}
|
||||
|
||||
/// Tells the renderer to build our book and put it in the build directory.
|
||||
pub fn build(&self) -> Result<()> {
|
||||
info!("Book building has started");
|
||||
|
||||
for renderer in self.renderers.values() {
|
||||
self.execute_build_process(&**renderer)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run preprocessors and return the final book.
|
||||
pub fn preprocess_book(&self, renderer: &dyn Renderer) -> Result<(Book, PreprocessorContext)> {
|
||||
let preprocess_ctx = PreprocessorContext::new(
|
||||
self.root.clone(),
|
||||
self.config.clone(),
|
||||
renderer.name().to_string(),
|
||||
);
|
||||
let mut preprocessed_book = self.book.clone();
|
||||
for preprocessor in self.preprocessors.values() {
|
||||
if preprocessor_should_run(&**preprocessor, renderer, &self.config)? {
|
||||
debug!("Running the {} preprocessor.", preprocessor.name());
|
||||
preprocessed_book = preprocessor.run(&preprocess_ctx, preprocessed_book)?;
|
||||
}
|
||||
}
|
||||
Ok((preprocessed_book, preprocess_ctx))
|
||||
}
|
||||
|
||||
/// Run the entire build process for a particular [`Renderer`].
|
||||
pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> {
|
||||
let (preprocessed_book, preprocess_ctx) = self.preprocess_book(renderer)?;
|
||||
|
||||
let name = renderer.name();
|
||||
let build_dir = self.build_dir_for(name);
|
||||
|
||||
let mut render_context = RenderContext::new(
|
||||
self.root.clone(),
|
||||
preprocessed_book,
|
||||
self.config.clone(),
|
||||
build_dir,
|
||||
);
|
||||
render_context
|
||||
.chapter_titles
|
||||
.extend(preprocess_ctx.chapter_titles.borrow_mut().drain());
|
||||
|
||||
info!("Running the {} backend", renderer.name());
|
||||
renderer
|
||||
.render(&render_context)
|
||||
.with_context(|| "Rendering failed")
|
||||
}
|
||||
|
||||
/// You can change the default renderer to another one by using this method.
|
||||
/// The only requirement is that your renderer implement the [`Renderer`]
|
||||
/// trait.
|
||||
pub fn with_renderer<R: Renderer + 'static>(&mut self, renderer: R) -> &mut Self {
|
||||
self.renderers
|
||||
.insert(renderer.name().to_string(), Box::new(renderer));
|
||||
self
|
||||
}
|
||||
|
||||
/// Register a [`Preprocessor`] to be used when rendering the book.
|
||||
pub fn with_preprocessor<P: Preprocessor + 'static>(&mut self, preprocessor: P) -> &mut Self {
|
||||
self.preprocessors
|
||||
.insert(preprocessor.name().to_string(), Box::new(preprocessor));
|
||||
self
|
||||
}
|
||||
|
||||
/// Run `rustdoc` tests on the book, linking against the provided libraries.
|
||||
pub fn test(&mut self, library_paths: Vec<&str>) -> Result<()> {
|
||||
// test_chapter with chapter:None will run all tests.
|
||||
self.test_chapter(library_paths, None)
|
||||
}
|
||||
|
||||
/// Run `rustdoc` tests on a specific chapter of the book, linking against the provided libraries.
|
||||
/// If `chapter` is `None`, all tests will be run.
|
||||
pub fn test_chapter(&mut self, library_paths: Vec<&str>, chapter: Option<&str>) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let library_args: Vec<OsString> = library_paths
|
||||
.into_iter()
|
||||
.flat_map(|path| {
|
||||
let path = Path::new(path);
|
||||
let path = if path.is_relative() {
|
||||
cwd.join(path).into_os_string()
|
||||
} else {
|
||||
path.to_path_buf().into_os_string()
|
||||
};
|
||||
[OsString::from("-L"), path]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let temp_dir = TempFileBuilder::new().prefix("mdbook-").tempdir()?;
|
||||
|
||||
let mut chapter_found = false;
|
||||
|
||||
struct TestRenderer;
|
||||
impl Renderer for TestRenderer {
|
||||
// FIXME: Is "test" the proper renderer name to use here?
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn render(&self, _: &RenderContext) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Index Preprocessor is disabled so that chapter paths
|
||||
// continue to point to the actual markdown files.
|
||||
self.preprocessors = determine_preprocessors(&self.config, &self.root)?;
|
||||
self.preprocessors
|
||||
.shift_remove_entry(IndexPreprocessor::NAME);
|
||||
let (book, _) = self.preprocess_book(&TestRenderer)?;
|
||||
|
||||
let color_output = std::io::stderr().is_terminal();
|
||||
let mut failed = false;
|
||||
for item in book.iter() {
|
||||
if let BookItem::Chapter(ref ch) = *item {
|
||||
let chapter_path = match ch.path {
|
||||
Some(ref path) if !path.as_os_str().is_empty() => path,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(chapter) = chapter {
|
||||
if ch.name != chapter && chapter_path.to_str() != Some(chapter) {
|
||||
if chapter == "?" {
|
||||
info!("Skipping chapter '{}'...", ch.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
chapter_found = true;
|
||||
info!("Testing chapter '{}': {:?}", ch.name, chapter_path);
|
||||
|
||||
// write preprocessed file to tempdir
|
||||
let path = temp_dir.path().join(chapter_path);
|
||||
fs::write(&path, &ch.content)?;
|
||||
|
||||
let mut cmd = Command::new("rustdoc");
|
||||
cmd.current_dir(temp_dir.path())
|
||||
.arg(chapter_path)
|
||||
.arg("--test")
|
||||
.args(&library_args);
|
||||
|
||||
if let Some(edition) = self.config.rust.edition {
|
||||
match edition {
|
||||
RustEdition::E2015 => {
|
||||
cmd.args(["--edition", "2015"]);
|
||||
}
|
||||
RustEdition::E2018 => {
|
||||
cmd.args(["--edition", "2018"]);
|
||||
}
|
||||
RustEdition::E2021 => {
|
||||
cmd.args(["--edition", "2021"]);
|
||||
}
|
||||
RustEdition::E2024 => {
|
||||
cmd.args(["--edition", "2024"]);
|
||||
}
|
||||
_ => panic!("RustEdition {edition:?} not covered"),
|
||||
}
|
||||
}
|
||||
|
||||
if color_output {
|
||||
cmd.args(["--color", "always"]);
|
||||
}
|
||||
|
||||
debug!("running {:?}", cmd);
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| "failed to execute `rustdoc`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
failed = true;
|
||||
error!(
|
||||
"rustdoc returned an error:\n\
|
||||
\n--- stdout\n{}\n--- stderr\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
bail!("One or more tests failed");
|
||||
}
|
||||
if let Some(chapter) = chapter {
|
||||
if !chapter_found {
|
||||
bail!("Chapter not found: {}", chapter);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The logic for determining where a backend should put its build
|
||||
/// artefacts.
|
||||
///
|
||||
/// If there is only 1 renderer, put it in the directory pointed to by the
|
||||
/// `build.build_dir` key in [`Config`]. If there is more than one then the
|
||||
/// renderer gets its own directory within the main build dir.
|
||||
///
|
||||
/// i.e. If there were only one renderer (in this case, the HTML renderer):
|
||||
///
|
||||
/// - build/
|
||||
/// - index.html
|
||||
/// - ...
|
||||
///
|
||||
/// Otherwise if there are multiple:
|
||||
///
|
||||
/// - build/
|
||||
/// - epub/
|
||||
/// - my_awesome_book.epub
|
||||
/// - html/
|
||||
/// - index.html
|
||||
/// - ...
|
||||
/// - latex/
|
||||
/// - my_awesome_book.tex
|
||||
///
|
||||
pub fn build_dir_for(&self, backend_name: &str) -> PathBuf {
|
||||
let build_dir = self.root.join(&self.config.build.build_dir);
|
||||
|
||||
if self.renderers.len() <= 1 {
|
||||
build_dir
|
||||
} else {
|
||||
build_dir.join(backend_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the directory containing this book's source files.
|
||||
pub fn source_dir(&self) -> PathBuf {
|
||||
self.root.join(&self.config.book.src)
|
||||
}
|
||||
|
||||
/// Get the directory containing the theme resources for the book.
|
||||
pub fn theme_dir(&self) -> PathBuf {
|
||||
self.config
|
||||
.html_config()
|
||||
.unwrap_or_default()
|
||||
.theme_dir(&self.root)
|
||||
}
|
||||
}
|
||||
|
||||
/// An `output` table.
|
||||
#[derive(Deserialize)]
|
||||
struct OutputConfig {
|
||||
command: Option<String>,
|
||||
}
|
||||
|
||||
/// Look at the `Config` and try to figure out what renderers to use.
|
||||
fn determine_renderers(config: &Config) -> Result<IndexMap<String, Box<dyn Renderer>>> {
|
||||
let mut renderers = IndexMap::new();
|
||||
|
||||
let outputs = config.outputs::<OutputConfig>()?;
|
||||
renderers.extend(outputs.into_iter().map(|(key, table)| {
|
||||
let renderer = if key == "html" {
|
||||
Box::new(HtmlHandlebars::new()) as Box<dyn Renderer>
|
||||
} else if key == "markdown" {
|
||||
Box::new(MarkdownRenderer::new()) as Box<dyn Renderer>
|
||||
} else {
|
||||
let command = table.command.unwrap_or_else(|| format!("mdbook-{key}"));
|
||||
Box::new(CmdRenderer::new(key.clone(), command))
|
||||
};
|
||||
(key, renderer)
|
||||
}));
|
||||
|
||||
// if we couldn't find anything, add the HTML renderer as a default
|
||||
if renderers.is_empty() {
|
||||
renderers.insert("html".to_string(), Box::new(HtmlHandlebars::new()));
|
||||
}
|
||||
|
||||
Ok(renderers)
|
||||
}
|
||||
|
||||
const DEFAULT_PREPROCESSORS: &[&str] = &["links", "index"];
|
||||
|
||||
fn is_default_preprocessor(pre: &dyn Preprocessor) -> bool {
|
||||
let name = pre.name();
|
||||
name == LinkPreprocessor::NAME || name == IndexPreprocessor::NAME
|
||||
}
|
||||
|
||||
/// A `preprocessor` table.
|
||||
#[derive(Deserialize)]
|
||||
struct PreprocessorConfig {
|
||||
command: Option<String>,
|
||||
#[serde(default)]
|
||||
before: Vec<String>,
|
||||
#[serde(default)]
|
||||
after: Vec<String>,
|
||||
#[serde(default)]
|
||||
optional: bool,
|
||||
}
|
||||
|
||||
/// Look at the `MDBook` and try to figure out what preprocessors to run.
|
||||
fn determine_preprocessors(
|
||||
config: &Config,
|
||||
root: &Path,
|
||||
) -> Result<IndexMap<String, Box<dyn Preprocessor>>> {
|
||||
// Collect the names of all preprocessors intended to be run, and the order
|
||||
// in which they should be run.
|
||||
let mut preprocessor_names = TopologicalSort::<String>::new();
|
||||
|
||||
if config.build.use_default_preprocessors {
|
||||
for name in DEFAULT_PREPROCESSORS {
|
||||
preprocessor_names.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let preprocessor_table = config.preprocessors::<PreprocessorConfig>()?;
|
||||
|
||||
for (name, table) in preprocessor_table.iter() {
|
||||
preprocessor_names.insert(name.to_string());
|
||||
|
||||
let exists = |name| {
|
||||
(config.build.use_default_preprocessors && DEFAULT_PREPROCESSORS.contains(&name))
|
||||
|| preprocessor_table.contains_key(name)
|
||||
};
|
||||
|
||||
for after in &table.before {
|
||||
if !exists(&after) {
|
||||
// Only warn so that preprocessors can be toggled on and off (e.g. for
|
||||
// troubleshooting) without having to worry about order too much.
|
||||
warn!(
|
||||
"preprocessor.{}.after contains \"{}\", which was not found",
|
||||
name, after
|
||||
);
|
||||
} else {
|
||||
preprocessor_names.add_dependency(name, after);
|
||||
}
|
||||
}
|
||||
|
||||
for before in &table.after {
|
||||
if !exists(&before) {
|
||||
// See equivalent warning above for rationale
|
||||
warn!(
|
||||
"preprocessor.{}.before contains \"{}\", which was not found",
|
||||
name, before
|
||||
);
|
||||
} else {
|
||||
preprocessor_names.add_dependency(before, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now that all links have been established, queue preprocessors in a suitable order
|
||||
let mut preprocessors = IndexMap::with_capacity(preprocessor_names.len());
|
||||
// `pop_all()` returns an empty vector when no more items are not being depended upon
|
||||
for mut names in std::iter::repeat_with(|| preprocessor_names.pop_all())
|
||||
.take_while(|names| !names.is_empty())
|
||||
{
|
||||
// The `topological_sort` crate does not guarantee a stable order for ties, even across
|
||||
// runs of the same program. Thus, we break ties manually by sorting.
|
||||
// Careful: `str`'s default sorting, which we are implicitly invoking here, uses code point
|
||||
// values ([1]), which may not be an alphabetical sort.
|
||||
// As mentioned in [1], doing so depends on locale, which is not desirable for deciding
|
||||
// preprocessor execution order.
|
||||
// [1]: https://doc.rust-lang.org/stable/std/cmp/trait.Ord.html#impl-Ord-14
|
||||
names.sort();
|
||||
for name in names {
|
||||
let preprocessor: Box<dyn Preprocessor> = match name.as_str() {
|
||||
"links" => Box::new(LinkPreprocessor::new()),
|
||||
"index" => Box::new(IndexPreprocessor::new()),
|
||||
_ => {
|
||||
// The only way to request a custom preprocessor is through the `preprocessor`
|
||||
// table, so it must exist, be a table, and contain the key.
|
||||
let table = &preprocessor_table[&name];
|
||||
let command = table
|
||||
.command
|
||||
.to_owned()
|
||||
.unwrap_or_else(|| format!("mdbook-{name}"));
|
||||
Box::new(CmdPreprocessor::new(
|
||||
name.clone(),
|
||||
command,
|
||||
root.to_owned(),
|
||||
table.optional,
|
||||
))
|
||||
}
|
||||
};
|
||||
preprocessors.insert(name, preprocessor);
|
||||
}
|
||||
}
|
||||
|
||||
// "If `pop_all` returns an empty vector and `len` is not 0, there are cyclic dependencies."
|
||||
// Normally, `len() == 0` is equivalent to `is_empty()`, so we'll use that.
|
||||
if preprocessor_names.is_empty() {
|
||||
Ok(preprocessors)
|
||||
} else {
|
||||
Err(Error::msg("Cyclic dependency detected in preprocessors"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether we should run a particular `Preprocessor` in combination
|
||||
/// with the renderer, falling back to `Preprocessor::supports_renderer()`
|
||||
/// method if the user doesn't say anything.
|
||||
///
|
||||
/// The `build.use-default-preprocessors` config option can be used to ensure
|
||||
/// default preprocessors always run if they support the renderer.
|
||||
fn preprocessor_should_run(
|
||||
preprocessor: &dyn Preprocessor,
|
||||
renderer: &dyn Renderer,
|
||||
cfg: &Config,
|
||||
) -> Result<bool> {
|
||||
// default preprocessors should be run by default (if supported)
|
||||
if cfg.build.use_default_preprocessors && is_default_preprocessor(preprocessor) {
|
||||
return preprocessor.supports_renderer(renderer.name());
|
||||
}
|
||||
|
||||
let key = format!("preprocessor.{}.renderers", preprocessor.name());
|
||||
let renderer_name = renderer.name();
|
||||
|
||||
match cfg.get::<Vec<String>>(&key) {
|
||||
Ok(Some(explicit_renderers)) => {
|
||||
Ok(explicit_renderers.iter().any(|name| name == renderer_name))
|
||||
}
|
||||
Ok(None) => preprocessor.supports_renderer(renderer_name),
|
||||
Err(e) => bail!("failed to get `{key}`: {e}"),
|
||||
}
|
||||
}
|
||||
284
crates/mdbook-driver/src/mdbook/tests.rs
Normal file
284
crates/mdbook-driver/src/mdbook/tests.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
use toml::value::{Table, Value};
|
||||
|
||||
#[test]
|
||||
fn config_defaults_to_html_renderer_if_empty() {
|
||||
let cfg = Config::default();
|
||||
|
||||
// make sure we haven't got anything in the `output` table
|
||||
assert!(cfg.outputs::<toml::Value>().unwrap().is_empty());
|
||||
|
||||
let got = determine_renderers(&cfg).unwrap();
|
||||
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name(), "html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_a_random_renderer_to_the_config() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.set("output.random", Table::new()).unwrap();
|
||||
|
||||
let got = determine_renderers(&cfg).unwrap();
|
||||
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name(), "random");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_a_random_renderer_with_custom_command_to_the_config() {
|
||||
let mut cfg = Config::default();
|
||||
|
||||
let mut table = Table::new();
|
||||
table.insert("command".to_string(), Value::String("false".to_string()));
|
||||
cfg.set("output.random", table).unwrap();
|
||||
|
||||
let got = determine_renderers(&cfg).unwrap();
|
||||
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name(), "random");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_to_link_and_index_preprocessor_if_not_set() {
|
||||
let cfg = Config::default();
|
||||
|
||||
// make sure we haven't got anything in the `preprocessor` table
|
||||
assert!(cfg.preprocessors::<toml::Value>().unwrap().is_empty());
|
||||
|
||||
let got = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
let names: Vec<_> = got.values().map(|p| p.name()).collect();
|
||||
assert_eq!(names, ["index", "links"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_default_preprocessors_works() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.build.use_default_preprocessors = false;
|
||||
|
||||
let got = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
assert_eq!(got.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_determine_third_party_preprocessors() {
|
||||
let cfg_str = r#"
|
||||
[book]
|
||||
title = "Some Book"
|
||||
|
||||
[preprocessor.random]
|
||||
|
||||
[build]
|
||||
build-dir = "outputs"
|
||||
create-missing = false
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
// make sure the `preprocessor.random` table exists
|
||||
assert!(cfg.get::<Value>("preprocessor.random").unwrap().is_some());
|
||||
|
||||
let got = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
assert!(got.contains_key("random"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessors_can_provide_their_own_commands() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.random]
|
||||
command = "python random.py"
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
// make sure the `preprocessor.random` table exists
|
||||
let random = cfg
|
||||
.get::<OutputConfig>("preprocessor.random")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(random.command, Some("python random.py".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessor_before_must_be_array() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.random]
|
||||
before = 0
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
assert!(determine_preprocessors(&cfg, Path::new("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessor_after_must_be_array() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.random]
|
||||
after = 0
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
assert!(determine_preprocessors(&cfg, Path::new("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessor_order_is_honored() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.random]
|
||||
before = [ "last" ]
|
||||
after = [ "index" ]
|
||||
|
||||
[preprocessor.last]
|
||||
after = [ "links", "index" ]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let preprocessors = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
let index = |name| preprocessors.get_index_of(name).unwrap();
|
||||
let assert_before = |before, after| {
|
||||
if index(before) >= index(after) {
|
||||
eprintln!("Preprocessor order:");
|
||||
for preprocessor in preprocessors.keys() {
|
||||
eprintln!(" {}", preprocessor);
|
||||
}
|
||||
panic!("{before} should come before {after}");
|
||||
}
|
||||
};
|
||||
|
||||
assert_before("index", "random");
|
||||
assert_before("index", "last");
|
||||
assert_before("random", "last");
|
||||
assert_before("links", "last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_dependencies_are_detected() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.links]
|
||||
before = [ "index" ]
|
||||
|
||||
[preprocessor.index]
|
||||
before = [ "links" ]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
assert!(determine_preprocessors(&cfg, Path::new("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_dont_register_undefined_preprocessors() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.links]
|
||||
before = [ "random" ]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let preprocessors = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
// Does not contain "random"
|
||||
assert_eq!(preprocessors.keys().collect::<Vec<_>>(), ["index", "links"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_dont_register_builtin_preprocessors_if_disabled() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.random]
|
||||
before = [ "links" ]
|
||||
|
||||
[build]
|
||||
use-default-preprocessors = false
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let preprocessors = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
// Does not contain "links"
|
||||
assert_eq!(preprocessors.keys().collect::<Vec<_>>(), ["random"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_respects_preprocessor_selection() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.links]
|
||||
renderers = ["html"]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let html_renderer = HtmlHandlebars::default();
|
||||
let pre = LinkPreprocessor::new();
|
||||
|
||||
let should_run = preprocessor_should_run(&pre, &html_renderer, &cfg).unwrap();
|
||||
assert!(should_run);
|
||||
}
|
||||
|
||||
struct BoolPreprocessor(bool);
|
||||
impl Preprocessor for BoolPreprocessor {
|
||||
fn name(&self) -> &str {
|
||||
"bool-preprocessor"
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &PreprocessorContext, _book: Book) -> Result<Book> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn supports_renderer(&self, _renderer: &str) -> Result<bool> {
|
||||
Ok(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessor_should_run_falls_back_to_supports_renderer_method() {
|
||||
let cfg = Config::default();
|
||||
let html = HtmlHandlebars::new();
|
||||
|
||||
let should_be = true;
|
||||
let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg).unwrap();
|
||||
assert_eq!(got, should_be);
|
||||
|
||||
let should_be = false;
|
||||
let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg).unwrap();
|
||||
assert_eq!(got, should_be);
|
||||
}
|
||||
|
||||
// Default is to sort preprocessors alphabetically.
|
||||
#[test]
|
||||
fn preprocessor_sorted_by_name() {
|
||||
let cfg_str = r#"
|
||||
[preprocessor.xyz]
|
||||
[preprocessor.abc]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let got = determine_preprocessors(&cfg, Path::new("")).unwrap();
|
||||
|
||||
let names: Vec<_> = got.values().map(|p| p.name()).collect();
|
||||
assert_eq!(names, ["abc", "index", "links", "xyz"]);
|
||||
}
|
||||
|
||||
// Default is to sort renderers alphabetically.
|
||||
#[test]
|
||||
fn renderers_sorted_by_name() {
|
||||
let cfg_str = r#"
|
||||
[output.xyz]
|
||||
[output.abc]
|
||||
"#;
|
||||
|
||||
let cfg = Config::from_str(cfg_str).unwrap();
|
||||
|
||||
let got = determine_renderers(&cfg).unwrap();
|
||||
|
||||
let names: Vec<_> = got.values().map(|p| p.name()).collect();
|
||||
assert_eq!(names, ["abc", "xyz"]);
|
||||
}
|
||||
37
crates/mdbook-html/Cargo.toml
Normal file
37
crates/mdbook-html/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "mdbook-html"
|
||||
version = "0.5.0-beta.1"
|
||||
description = "mdBook HTML renderer"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
ego-tree.workspace = true
|
||||
elasticlunr-rs = { workspace = true, optional = true }
|
||||
font-awesome-as-a-crate.workspace = true
|
||||
handlebars.workspace = true
|
||||
hex.workspace = true
|
||||
html5ever.workspace = true
|
||||
indexmap.workspace = true
|
||||
mdbook-core.workspace = true
|
||||
mdbook-markdown.workspace = true
|
||||
mdbook-renderer.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
search = ["dep:elasticlunr-rs"]
|
||||
13
crates/mdbook-html/README.md
Normal file
13
crates/mdbook-html/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# mdbook-html
|
||||
|
||||
[](https://docs.rs/mdbook-html)
|
||||
[](https://crates.io/crates/mdbook-html)
|
||||
[](https://github.com/rust-lang/mdBook/blob/master/CHANGELOG.md)
|
||||
|
||||
This is the HTML renderer for [mdBook](https://rust-lang.github.io/mdBook/). This is intended for internal use only. It is automatically included by [`mdbook-driver`](https://crates.io/crates/mdbook-driver) to render books to HTML.
|
||||
|
||||
> This crate is maintained by the mdBook team, primarily for use by mdBook and not intended for external use (except as a transitive dependency). This crate may make major changes to its APIs or be deprecated without warning.
|
||||
|
||||
## License
|
||||
|
||||
[Mozilla Public License, version 2.0](https://github.com/rust-lang/mdBook/blob/master/LICENSE)
|
||||
@@ -13,7 +13,6 @@ Original by Dempfi (https://github.com/dempfi/ayu)
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #5c6773;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
@@ -3,7 +3,7 @@
|
||||
html {
|
||||
scrollbar-color: var(--scrollbar) var(--bg);
|
||||
}
|
||||
#searchresults a,
|
||||
#mdbook-searchresults a,
|
||||
.content a:link,
|
||||
a:visited,
|
||||
a > .hljs {
|
||||
@@ -11,10 +11,10 @@ a > .hljs {
|
||||
}
|
||||
|
||||
/*
|
||||
body-container is necessary because mobile browsers don't seem to like
|
||||
mdbook-body-container is necessary because mobile browsers don't seem to like
|
||||
overflow-x on the body tag when there is a <meta name="viewport"> tag.
|
||||
*/
|
||||
#body-container {
|
||||
#mdbook-body-container {
|
||||
/*
|
||||
This is used when the sidebar pushes the body content off the side of
|
||||
the screen on small screens. Without it, dragging on mobile Safari
|
||||
@@ -25,12 +25,12 @@ a > .hljs {
|
||||
|
||||
/* Menu Bar */
|
||||
|
||||
#menu-bar,
|
||||
#menu-bar-hover-placeholder {
|
||||
#mdbook-menu-bar,
|
||||
#mdbook-menu-bar-hover-placeholder {
|
||||
z-index: 101;
|
||||
margin: auto calc(0px - var(--page-padding));
|
||||
}
|
||||
#menu-bar {
|
||||
#mdbook-menu-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -39,24 +39,24 @@ a > .hljs {
|
||||
border-block-end-width: 1px;
|
||||
border-block-end-style: solid;
|
||||
}
|
||||
#menu-bar.sticky,
|
||||
.js #menu-bar-hover-placeholder:hover + #menu-bar,
|
||||
.js #menu-bar:hover,
|
||||
.js.sidebar-visible #menu-bar {
|
||||
#mdbook-menu-bar.sticky,
|
||||
#mdbook-menu-bar-hover-placeholder:hover + #mdbook-menu-bar,
|
||||
#mdbook-menu-bar:hover,
|
||||
html.sidebar-visible #mdbook-menu-bar {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0 !important;
|
||||
}
|
||||
#menu-bar-hover-placeholder {
|
||||
#mdbook-menu-bar-hover-placeholder {
|
||||
position: sticky;
|
||||
position: -webkit-sticky;
|
||||
top: 0;
|
||||
height: var(--menu-bar-height);
|
||||
}
|
||||
#menu-bar.bordered {
|
||||
#mdbook-menu-bar.bordered {
|
||||
border-block-end-color: var(--table-border-color);
|
||||
}
|
||||
#menu-bar i, #menu-bar .icon-button {
|
||||
#mdbook-menu-bar .fa-svg, #mdbook-menu-bar .icon-button {
|
||||
position: relative;
|
||||
padding: 0 8px;
|
||||
z-index: 10;
|
||||
@@ -65,7 +65,7 @@ a > .hljs {
|
||||
transition: color 0.5s;
|
||||
}
|
||||
@media only screen and (max-width: 420px) {
|
||||
#menu-bar i, #menu-bar .icon-button {
|
||||
#mdbook-menu-bar .fa-svg, #mdbook-menu-bar .icon-button {
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ a > .hljs {
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.icon-button i {
|
||||
.icon-button .fa-svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ a > .hljs {
|
||||
display: flex;
|
||||
margin: 0 5px;
|
||||
}
|
||||
.no-js .left-buttons button {
|
||||
html:not(.js) .left-buttons button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ a > .hljs {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.js .menu-title {
|
||||
.menu-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -118,14 +118,14 @@ a > .hljs {
|
||||
.mobile-nav-chapters,
|
||||
.mobile-nav-chapters:visited,
|
||||
.menu-bar .icon-button,
|
||||
.menu-bar a i {
|
||||
.menu-bar a .fa-svg {
|
||||
color: var(--icons);
|
||||
}
|
||||
|
||||
.menu-bar i:hover,
|
||||
.menu-bar .fa-svg:hover,
|
||||
.menu-bar .icon-button:hover,
|
||||
.nav-chapters:hover,
|
||||
.mobile-nav-chapters i:hover {
|
||||
.mobile-nav-chapters .fa-svg:hover {
|
||||
color: var(--icons-hover);
|
||||
}
|
||||
|
||||
@@ -186,10 +186,6 @@ a > .hljs {
|
||||
left: var(--page-padding);
|
||||
}
|
||||
|
||||
/* Use the correct buttons for RTL layouts*/
|
||||
[dir=rtl] .previous i.fa-angle-left:before {content:"\f105";}
|
||||
[dir=rtl] .next i.fa-angle-right:before { content:"\f104"; }
|
||||
|
||||
@media only screen and (max-width: 1080px) {
|
||||
.nav-wide-wrapper { display: none; }
|
||||
.nav-wrapper { display: block; }
|
||||
@@ -197,8 +193,8 @@ a > .hljs {
|
||||
|
||||
/* sidebar-visible */
|
||||
@media only screen and (max-width: 1380px) {
|
||||
#sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wide-wrapper { display: none; }
|
||||
#sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wrapper { display: block; }
|
||||
#mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wide-wrapper { display: none; }
|
||||
#mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wrapper { display: block; }
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
@@ -244,14 +240,11 @@ pre > .buttons :hover {
|
||||
border-color: var(--icons-hover);
|
||||
background-color: var(--theme-hover);
|
||||
}
|
||||
pre > .buttons i {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
pre > .buttons button {
|
||||
cursor: inherit;
|
||||
margin: 0px 5px;
|
||||
padding: 3px 5px;
|
||||
font-size: 14px;
|
||||
padding: 2px 3px 0px 4px;
|
||||
font-size: 23px;
|
||||
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
@@ -262,6 +255,27 @@ pre > .buttons button {
|
||||
transition-property: color,border-color,background-color;
|
||||
color: var(--icons);
|
||||
}
|
||||
|
||||
pre > .buttons button.clip-button {
|
||||
padding: 2px 4px 0px 6px;
|
||||
}
|
||||
pre > .buttons button.clip-button::before {
|
||||
/* clipboard image from octicons (https://github.com/primer/octicons/tree/v2.0.0) MIT license
|
||||
*/
|
||||
content: url('data:image/svg+xml,<svg width="21" height="20" viewBox="0 0 24 25" \
|
||||
xmlns="http://www.w3.org/2000/svg" aria-label="Copy to clipboard">\
|
||||
<path d="M18 20h2v3c0 1-1 2-2 2H2c-.998 0-2-1-2-2V5c0-.911.755-1.667 1.667-1.667h5A3.323 3.323 0 \
|
||||
0110 0a3.323 3.323 0 013.333 3.333h5C19.245 3.333 20 4.09 20 5v8.333h-2V9H2v14h16v-3zM3 \
|
||||
7h14c0-.911-.793-1.667-1.75-1.667H13.5c-.957 0-1.75-.755-1.75-1.666C11.75 2.755 10.957 2 10 \
|
||||
2s-1.75.755-1.75 1.667c0 .911-.793 1.666-1.75 1.666H4.75C3.793 5.333 3 6.09 3 7z"/>\
|
||||
<path d="M4 19h6v2H4zM12 11H4v2h8zM4 17h4v-2H4zM15 15v-3l-4.5 4.5L15 21v-3l8.027-.032L23 15z"/>\
|
||||
</svg>');
|
||||
filter: var(--copy-button-filter);
|
||||
}
|
||||
pre > .buttons button.clip-button:hover::before {
|
||||
filter: var(--copy-button-filter-hover);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
pre > .buttons button {
|
||||
/* On mobile, make it easier to tap buttons. */
|
||||
@@ -293,7 +307,7 @@ pre > .result {
|
||||
|
||||
/* Search */
|
||||
|
||||
#searchresults a {
|
||||
#mdbook-searchresults a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -323,9 +337,48 @@ mark.fade-out {
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
|
||||
#searchbar {
|
||||
#mdbook-searchbar-outer.searching #mdbook-searchbar {
|
||||
padding-right: 30px;
|
||||
}
|
||||
#mdbook-searchbar-outer .spinner-wrapper {
|
||||
display: none;
|
||||
}
|
||||
#mdbook-searchbar-outer.searching .spinner-wrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spinner-wrapper {
|
||||
--spinner-margin: 2px;
|
||||
position: absolute;
|
||||
margin-block-start: calc(var(--searchbar-margin-block-start) + var(--spinner-margin));
|
||||
right: var(--spinner-margin);
|
||||
top: 0;
|
||||
bottom: var(--spinner-margin);
|
||||
padding: 6px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
#fa-spin {
|
||||
animation: rotating 2s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#mdbook-searchbar {
|
||||
width: 100%;
|
||||
margin-block-start: 5px;
|
||||
margin-block-start: var(--searchbar-margin-block-start);
|
||||
margin-block-end: 0;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
@@ -336,8 +389,8 @@ mark.fade-out {
|
||||
background-color: var(--searchbar-bg);
|
||||
color: var(--searchbar-fg);
|
||||
}
|
||||
#searchbar:focus,
|
||||
#searchbar.active {
|
||||
#mdbook-searchbar:focus,
|
||||
#mdbook-searchbar.active {
|
||||
box-shadow: 0 0 3px var(--searchbar-shadow-color);
|
||||
}
|
||||
|
||||
@@ -358,19 +411,19 @@ mark.fade-out {
|
||||
border-block-end: 1px dashed var(--searchresults-border-color);
|
||||
}
|
||||
|
||||
ul#searchresults {
|
||||
ul#mdbook-searchresults {
|
||||
list-style: none;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
ul#searchresults li {
|
||||
ul#mdbook-searchresults li {
|
||||
margin: 10px 0px;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
ul#searchresults li.focus {
|
||||
ul#mdbook-searchresults li.focus {
|
||||
background-color: var(--searchresults-li-bg);
|
||||
}
|
||||
ul#searchresults span.teaser {
|
||||
ul#mdbook-searchresults span.teaser {
|
||||
display: block;
|
||||
clear: both;
|
||||
margin-block-start: 5px;
|
||||
@@ -379,7 +432,7 @@ ul#searchresults span.teaser {
|
||||
margin-inline-end: 0;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
ul#searchresults span.teaser em {
|
||||
ul#mdbook-searchresults span.teaser em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -399,6 +452,25 @@ ul#searchresults span.teaser em {
|
||||
background-color: var(--sidebar-bg);
|
||||
color: var(--sidebar-fg);
|
||||
}
|
||||
.sidebar-iframe-inner {
|
||||
--padding: 10px;
|
||||
|
||||
background-color: var(--sidebar-bg);
|
||||
padding: var(--padding);
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: var(--sidebar-fg);
|
||||
min-height: calc(100vh - var(--padding) * 2);
|
||||
}
|
||||
.sidebar-iframe-outer {
|
||||
border: none;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
[dir=rtl] .sidebar { left: unset; right: 0; }
|
||||
.sidebar-resizing {
|
||||
-moz-user-select: none;
|
||||
@@ -406,8 +478,7 @@ ul#searchresults span.teaser em {
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.no-js .sidebar,
|
||||
.js:not(.sidebar-resizing) .sidebar {
|
||||
html:not(.sidebar-resizing) .sidebar {
|
||||
transition: transform 0.3s; /* Animation: slide away */
|
||||
}
|
||||
.sidebar code {
|
||||
@@ -435,9 +506,24 @@ ul#searchresults span.teaser em {
|
||||
|
||||
.sidebar-resize-handle .sidebar-resize-indicator {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background-color: var(--icons);
|
||||
height: 16px;
|
||||
color: var(--icons);
|
||||
margin-inline-start: var(--sidebar-resize-indicator-space);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sidebar-resize-handle .sidebar-resize-indicator::before {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
border-left: dotted 2px currentColor;
|
||||
}
|
||||
.sidebar-resize-handle .sidebar-resize-indicator::after {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
border-left: dotted 2px currentColor;
|
||||
}
|
||||
|
||||
[dir=rtl] .sidebar .sidebar-resize-handle {
|
||||
@@ -449,11 +535,10 @@ ul#searchresults span.teaser em {
|
||||
width: calc(var(--sidebar-resize-indicator-width) - var(--sidebar-resize-indicator-space));
|
||||
}
|
||||
/* sidebar-hidden */
|
||||
#sidebar-toggle-anchor:not(:checked) ~ .sidebar {
|
||||
#mdbook-sidebar-toggle-anchor:not(:checked) ~ .sidebar {
|
||||
transform: translateX(calc(0px - var(--sidebar-width) - var(--sidebar-resize-indicator-width)));
|
||||
z-index: -1;
|
||||
}
|
||||
[dir=rtl] #sidebar-toggle-anchor:not(:checked) ~ .sidebar {
|
||||
[dir=rtl] #mdbook-sidebar-toggle-anchor:not(:checked) ~ .sidebar {
|
||||
transform: translateX(calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width)));
|
||||
}
|
||||
.sidebar::-webkit-scrollbar {
|
||||
@@ -464,18 +549,18 @@ ul#searchresults span.teaser em {
|
||||
}
|
||||
|
||||
/* sidebar-visible */
|
||||
#sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
#mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
transform: translateX(calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width)));
|
||||
}
|
||||
[dir=rtl] #sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
[dir=rtl] #mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
transform: translateX(calc(0px - var(--sidebar-width) - var(--sidebar-resize-indicator-width)));
|
||||
}
|
||||
@media only screen and (min-width: 620px) {
|
||||
#sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
#mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
transform: none;
|
||||
margin-inline-start: calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width));
|
||||
}
|
||||
[dir=rtl] #sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
[dir=rtl] #mdbook-sidebar-toggle-anchor:checked ~ .page-wrapper {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -486,17 +571,18 @@ ul#searchresults span.teaser em {
|
||||
line-height: 2.2em;
|
||||
}
|
||||
|
||||
.chapter ol {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chapter li {
|
||||
display: flex;
|
||||
color: var(--sidebar-non-existant);
|
||||
}
|
||||
|
||||
/* This is a span wrapping the chapter link and the fold chevron. */
|
||||
.chapter-link-wrapper {
|
||||
/* Used to position the chevron to the right, allowing the text to wrap before it. */
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chapter li a {
|
||||
display: block;
|
||||
padding: 0;
|
||||
/* Remove underlines. */
|
||||
text-decoration: none;
|
||||
color: var(--sidebar-fg);
|
||||
}
|
||||
@@ -509,21 +595,22 @@ ul#searchresults span.teaser em {
|
||||
color: var(--sidebar-active);
|
||||
}
|
||||
|
||||
.chapter li > a.toggle {
|
||||
/* This is the toggle chevron. */
|
||||
.chapter-fold-toggle {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
/* Positions the chevron to the side. */
|
||||
margin-inline-start: auto;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.chapter li > a.toggle div {
|
||||
.chapter-fold-toggle div {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
/* collapse the section */
|
||||
.chapter li:not(.expanded) + li > ol {
|
||||
.chapter li:not(.expanded) > ol {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -532,10 +619,26 @@ ul#searchresults span.teaser em {
|
||||
margin-block-start: 0.6em;
|
||||
}
|
||||
|
||||
.chapter li.expanded > a.toggle div {
|
||||
/* When expanded, rotate the chevron to point down. */
|
||||
.chapter li.expanded > span > .chapter-fold-toggle div {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.chapter a.current-header {
|
||||
color: var(--sidebar-active);
|
||||
}
|
||||
|
||||
.on-this-page {
|
||||
margin-left: 22px;
|
||||
border-inline-start: 4px solid var(--sidebar-header-border-color);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.on-this-page > ol {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Horizontal line in chapter list. */
|
||||
.spacer {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
@@ -545,6 +648,7 @@ ul#searchresults span.teaser em {
|
||||
background-color: var(--sidebar-spacer);
|
||||
}
|
||||
|
||||
/* On touch devices, add more vertical spacing to make it easier to tap links. */
|
||||
@media (-moz-touch-enabled: 1), (pointer: coarse) {
|
||||
.chapter li a { padding: 5px 0; }
|
||||
.spacer { margin: 10px 0; }
|
||||
@@ -602,3 +706,46 @@ ul#searchresults span.teaser em {
|
||||
margin-inline-start: -14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
/* The container for the help popup that covers the whole window. */
|
||||
#mdbook-help-container {
|
||||
/* Position and size for the whole window. */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* This uses flex layout (which is set in book.js), and centers the popup
|
||||
in the window.*/
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
/* Dim out the book while the popup is visible. */
|
||||
background: var(--overlay-bg);
|
||||
}
|
||||
|
||||
/* The popup help box. */
|
||||
#mdbook-help-popup {
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
border-width: 1px;
|
||||
border-color: var(--theme-popup-border);
|
||||
border-style: solid;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mdbook-help-title {
|
||||
text-align: center;
|
||||
/* mdbook's margin for h2 is way too large. */
|
||||
margin: 10px;
|
||||
}
|
||||
408
crates/mdbook-html/front-end/css/general.css
Normal file
408
crates/mdbook-html/front-end/css/general.css
Normal file
@@ -0,0 +1,408 @@
|
||||
/* Base styles and content styles */
|
||||
|
||||
:root {
|
||||
/* Browser default font-size is 16px, this way 1 rem = 10px */
|
||||
font-size: 62.5%;
|
||||
color-scheme: var(--color-scheme);
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
color: var(--fg);
|
||||
background-color: var(--bg);
|
||||
text-size-adjust: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono-font) !important;
|
||||
font-size: var(--code-font-size);
|
||||
direction: ltr !important;
|
||||
}
|
||||
|
||||
/* make long words/inline code not x overflow */
|
||||
main {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* make wide tables scroll if they overflow */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Don't change font size in headers. */
|
||||
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.left { float: left; }
|
||||
.right { float: right; }
|
||||
.boring { opacity: 0.6; }
|
||||
.hide-boring .boring { display: none; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
h2, h3 { margin-block-start: 2.5em; }
|
||||
h4, h5 { margin-block-start: 2em; }
|
||||
|
||||
.header + .header h3,
|
||||
.header + .header h4,
|
||||
.header + .header h5 {
|
||||
margin-block-start: 1em;
|
||||
}
|
||||
|
||||
h1:target::before,
|
||||
h2:target::before,
|
||||
h3:target::before,
|
||||
h4:target::before,
|
||||
h5:target::before,
|
||||
h6:target::before,
|
||||
dt:target::before {
|
||||
display: inline-block;
|
||||
content: "»";
|
||||
margin-inline-start: -30px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
/* This is broken on Safari as of version 14, but is fixed
|
||||
in Safari Technology Preview 117 which I think will be Safari 14.2.
|
||||
https://bugs.webkit.org/show_bug.cgi?id=218076
|
||||
*/
|
||||
:target {
|
||||
/* Safari does not support logical properties */
|
||||
scroll-margin-top: calc(var(--menu-bar-height) + 0.5em);
|
||||
}
|
||||
|
||||
.page {
|
||||
outline: 0;
|
||||
padding: 0 var(--page-padding);
|
||||
margin-block-start: calc(0px - var(--menu-bar-height)); /* Compensate for the #mdbook-menu-bar-hover-placeholder */
|
||||
}
|
||||
.page-wrapper {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
html:not(.js) .page-wrapper,
|
||||
.js:not(.sidebar-resizing) .page-wrapper {
|
||||
transition: margin-left 0.3s ease, transform 0.3s ease; /* Animation: slide away */
|
||||
}
|
||||
[dir=rtl]:not(.js) .page-wrapper,
|
||||
[dir=rtl].js:not(.sidebar-resizing) .page-wrapper {
|
||||
transition: margin-right 0.3s ease, transform 0.3s ease; /* Animation: slide away */
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow-y: auto;
|
||||
padding: 0 5px 50px 5px;
|
||||
}
|
||||
.content main {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
.content p { line-height: 1.45em; }
|
||||
.content ol { line-height: 1.45em; }
|
||||
.content ul { line-height: 1.45em; }
|
||||
.content a { text-decoration: none; }
|
||||
.content a:hover { text-decoration: underline; }
|
||||
.content img, .content video { max-width: 100%; }
|
||||
.content .header:link,
|
||||
.content .header:visited {
|
||||
color: var(--fg);
|
||||
}
|
||||
.content .header:link,
|
||||
.content .header:visited:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 0 auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table td {
|
||||
padding: 3px 20px;
|
||||
border: 1px var(--table-border-color) solid;
|
||||
}
|
||||
table thead {
|
||||
background: var(--table-header-bg);
|
||||
}
|
||||
table thead td {
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
table thead th {
|
||||
padding: 3px 20px;
|
||||
}
|
||||
table thead tr {
|
||||
border: 1px var(--table-header-bg) solid;
|
||||
}
|
||||
/* Alternate background colors for rows */
|
||||
table tbody tr:nth-child(2n) {
|
||||
background: var(--table-alternate-bg);
|
||||
}
|
||||
|
||||
|
||||
blockquote {
|
||||
margin: 20px 0;
|
||||
padding: 0 20px;
|
||||
color: var(--fg);
|
||||
background-color: var(--quote-bg);
|
||||
border-block-start: .1em solid var(--quote-border);
|
||||
border-block-end: .1em solid var(--quote-border);
|
||||
}
|
||||
|
||||
/* TODO: Remove .warning in a future version of mdbook, it is replaced by
|
||||
blockquote tags. */
|
||||
.warning {
|
||||
margin: 20px;
|
||||
padding: 0 20px;
|
||||
border-inline-start: 2px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.warning:before {
|
||||
position: absolute;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-inline-start: calc(-1.5rem - 21px);
|
||||
content: "ⓘ";
|
||||
text-align: center;
|
||||
background-color: var(--bg);
|
||||
color: var(--warning-border);
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
blockquote .warning:before {
|
||||
background-color: var(--quote-bg);
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: var(--table-border-color);
|
||||
border-radius: 4px;
|
||||
border: solid 1px var(--theme-popup-border);
|
||||
box-shadow: inset 0 -1px 0 var(--theme-hover);
|
||||
display: inline-block;
|
||||
font-size: var(--code-font-size);
|
||||
font-family: var(--mono-font);
|
||||
line-height: 10px;
|
||||
padding: 4px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
sup {
|
||||
/* Set the line-height for superscript and footnote references so that there
|
||||
isn't an awkward space appearing above lines that contain the footnote.
|
||||
|
||||
See https://github.com/rust-lang/mdBook/pull/2443#discussion_r1813773583
|
||||
for an explanation.
|
||||
*/
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.footnote-definition {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
/* The default spacing for a list is a little too large. */
|
||||
.footnote-definition ul,
|
||||
.footnote-definition ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.footnote-definition > li {
|
||||
/* Required to position the ::before target */
|
||||
position: relative;
|
||||
}
|
||||
.footnote-definition > li:target {
|
||||
scroll-margin-top: 50vh;
|
||||
}
|
||||
.footnote-reference:target {
|
||||
scroll-margin-top: 50vh;
|
||||
}
|
||||
/* Draws a border around the footnote (including the marker) when it is selected.
|
||||
TODO: If there are multiple linkbacks, highlight which one you just came
|
||||
from so you know which one to click.
|
||||
*/
|
||||
.footnote-definition > li:target::before {
|
||||
border: 2px solid var(--footnote-highlight);
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
left: -32px;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
}
|
||||
/* Pulses the footnote reference so you can quickly see where you left off reading.
|
||||
This could use some improvement.
|
||||
*/
|
||||
@media not (prefers-reduced-motion) {
|
||||
.footnote-reference:target {
|
||||
animation: fn-highlight 0.8s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes fn-highlight {
|
||||
from {
|
||||
background-color: var(--footnote-highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltiptext {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
color: #fff;
|
||||
background-color: #333;
|
||||
transform: translateX(-50%); /* Center by moving tooltip 50% of its width left */
|
||||
left: -8px; /* Half of the width of the icon */
|
||||
top: -35px;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
margin: 5px;
|
||||
z-index: 1000;
|
||||
}
|
||||
.tooltipped .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.chapter li.part-title {
|
||||
color: var(--sidebar-fg);
|
||||
margin: 5px 0px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-no-output {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fa-svg svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
margin-bottom: -0.1em;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
|
||||
/* This uses a CSS counter to add numbers to definitions, but only if there is
|
||||
more than one definition. */
|
||||
dl, dt {
|
||||
counter-reset: dd-counter;
|
||||
}
|
||||
|
||||
/* When there is more than one definition, increment the counter. The first
|
||||
selector selects the first definition, and the second one selects definitions
|
||||
2 and beyond.*/
|
||||
dd:has(+ dd), dd + dd {
|
||||
counter-increment: dd-counter;
|
||||
/* Use flex display to help with positioning the numbers when there is a p
|
||||
tag inside the definition. */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Shows the counter for definitions. The first selector selects the first
|
||||
definition, and the second one selections definitions 2 and beyond.*/
|
||||
dd:has(+ dd)::before, dd + dd::before {
|
||||
content: counter(dd-counter) ". ";
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
dd > p {
|
||||
/* For loose definitions that have a p tag inside, don't add a bunch of
|
||||
space before the definition. */
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Remove some excess space from the bottom. */
|
||||
.blockquote-tag p:last-child {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.blockquote-tag {
|
||||
/* Add some padding to make the vertical bar a little taller than the text.*/
|
||||
padding: 2px 0px 2px 20px;
|
||||
/* Add a solid color bar on the left side. */
|
||||
border-inline-start-style: solid;
|
||||
border-inline-start-width: 4px;
|
||||
/* Disable the background color from normal blockquotes . */
|
||||
background-color: inherit;
|
||||
/* Disable border blocks from blockquotes. */
|
||||
border-block-start: none;
|
||||
border-block-end: none;
|
||||
}
|
||||
|
||||
.blockquote-tag-title svg {
|
||||
fill: currentColor;
|
||||
/* Add space between the icon and the title. */
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.blockquote-tag-note {
|
||||
border-inline-start-color: var(--blockquote-note-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-tip {
|
||||
border-inline-start-color: var(--blockquote-tip-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-important {
|
||||
border-inline-start-color: var(--blockquote-important-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-warning {
|
||||
border-inline-start-color: var(--blockquote-warning-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-caution {
|
||||
border-inline-start-color: var(--blockquote-caution-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-note .blockquote-tag-title {
|
||||
color: var(--blockquote-note-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-tip .blockquote-tag-title {
|
||||
color: var(--blockquote-tip-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-important .blockquote-tag-title {
|
||||
color: var(--blockquote-important-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-warning .blockquote-tag-title {
|
||||
color: var(--blockquote-warning-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-caution .blockquote-tag-title {
|
||||
color: var(--blockquote-caution-color);
|
||||
}
|
||||
|
||||
.blockquote-tag-title {
|
||||
/* Slightly increase the weight for more emphasis. */
|
||||
font-weight: 600;
|
||||
/* Vertically center the icon with the text. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Remove default large margins for a more compact display. */
|
||||
margin: 2px 0 8px 0;
|
||||
}
|
||||
|
||||
.blockquote-tag-title .fa-svg {
|
||||
fill: currentColor;
|
||||
/* Add some space between the icon and the text. */
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-attr,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
@@ -1,18 +1,18 @@
|
||||
|
||||
#sidebar,
|
||||
#menu-bar,
|
||||
#mdbook-sidebar,
|
||||
#mdbook-menu-bar,
|
||||
.nav-chapters,
|
||||
.mobile-nav-chapters {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#page-wrapper.page-wrapper {
|
||||
#mdbook-page-wrapper.page-wrapper {
|
||||
transform: none !important;
|
||||
margin-inline-start: 0px;
|
||||
overflow-y: initial;
|
||||
}
|
||||
|
||||
#content {
|
||||
#mdbook-content {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -11,6 +11,7 @@
|
||||
/* Tomorrow Red */
|
||||
.hljs-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-attr,
|
||||
.hljs-tag,
|
||||
.hljs-regexp,
|
||||
.ruby .hljs-constant,
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
/* Tomorrow Aqua */
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.css .hljs-hexcolor {
|
||||
color: #8abeb7;
|
||||
}
|
||||
@@ -2,14 +2,16 @@
|
||||
/* Globals */
|
||||
|
||||
:root {
|
||||
--sidebar-width: 300px;
|
||||
--sidebar-target-width: 300px;
|
||||
--sidebar-width: min(var(--sidebar-target-width), 80vw);
|
||||
--sidebar-resize-indicator-width: 8px;
|
||||
--sidebar-resize-indicator-space: 2px;
|
||||
--page-padding: 15px;
|
||||
--content-max-width: 750px;
|
||||
--menu-bar-height: 50px;
|
||||
--mono-font: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace;
|
||||
--code-font-size: 0.875em /* please adjust the ace font size accordingly in editor.js */
|
||||
--code-font-size: 0.875em; /* please adjust the ace font size accordingly in editor.js */
|
||||
--searchbar-margin-block-start: 5px;
|
||||
}
|
||||
|
||||
/* Themes */
|
||||
@@ -56,6 +58,23 @@
|
||||
--search-mark-bg: #e3b171;
|
||||
|
||||
--color-scheme: dark;
|
||||
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(45%) sepia(6%) saturate(621%) hue-rotate(198deg) brightness(99%) contrast(85%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) hue-rotate(341deg) brightness(104%) contrast(101%);
|
||||
|
||||
--footnote-highlight: #2668a6;
|
||||
|
||||
--overlay-bg: rgba(33, 40, 48, 0.4);
|
||||
|
||||
--blockquote-note-color: #74b9ff;
|
||||
--blockquote-tip-color: #09ca09;
|
||||
--blockquote-important-color: #d3abff;
|
||||
--blockquote-warning-color: #f0b72f;
|
||||
--blockquote-caution-color: #f21424;
|
||||
|
||||
--sidebar-header-border-color: #c18639;
|
||||
}
|
||||
|
||||
.coal {
|
||||
@@ -100,9 +119,26 @@
|
||||
--search-mark-bg: #355c7d;
|
||||
|
||||
--color-scheme: dark;
|
||||
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
|
||||
|
||||
--footnote-highlight: #4079ae;
|
||||
|
||||
--overlay-bg: rgba(33, 40, 48, 0.4);
|
||||
|
||||
--blockquote-note-color: #4493f8;
|
||||
--blockquote-tip-color: #08ae08;
|
||||
--blockquote-important-color: #ab7df8;
|
||||
--blockquote-warning-color: #d29922;
|
||||
--blockquote-caution-color: #d91b29;
|
||||
|
||||
--sidebar-header-border-color: #3473ad;
|
||||
}
|
||||
|
||||
.light {
|
||||
.light, html:not(.js) {
|
||||
--bg: hsl(0, 0%, 100%);
|
||||
--fg: hsl(0, 0%, 0%);
|
||||
|
||||
@@ -144,6 +180,23 @@
|
||||
--search-mark-bg: #a2cff5;
|
||||
|
||||
--color-scheme: light;
|
||||
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(45.49%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(14%) sepia(93%) saturate(4250%) hue-rotate(243deg) brightness(99%) contrast(130%);
|
||||
|
||||
--footnote-highlight: #7e7eff;
|
||||
|
||||
--overlay-bg: rgba(200, 200, 205, 0.4);
|
||||
|
||||
--blockquote-note-color: #0969da;
|
||||
--blockquote-tip-color: #008000;
|
||||
--blockquote-important-color: #8250df;
|
||||
--blockquote-warning-color: #9a6700;
|
||||
--blockquote-caution-color: #b52731;
|
||||
|
||||
--sidebar-header-border-color: #6e6edb;
|
||||
}
|
||||
|
||||
.navy {
|
||||
@@ -188,6 +241,23 @@
|
||||
--search-mark-bg: #a2cff5;
|
||||
|
||||
--color-scheme: dark;
|
||||
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(46%) sepia(20%) saturate(1537%) hue-rotate(156deg) brightness(85%) contrast(90%);
|
||||
|
||||
--footnote-highlight: #4079ae;
|
||||
|
||||
--overlay-bg: rgba(33, 40, 48, 0.4);
|
||||
|
||||
--blockquote-note-color: #4493f8;
|
||||
--blockquote-tip-color: #09ca09;
|
||||
--blockquote-important-color: #ab7df8;
|
||||
--blockquote-warning-color: #d29922;
|
||||
--blockquote-caution-color: #f21424;
|
||||
|
||||
--sidebar-header-border-color: #2f6ab5;
|
||||
}
|
||||
|
||||
.rust {
|
||||
@@ -231,11 +301,26 @@
|
||||
--searchresults-li-bg: #dec2a2;
|
||||
--search-mark-bg: #e69f67;
|
||||
|
||||
--color-scheme: light;
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(77%) sepia(16%) saturate(1798%) hue-rotate(328deg) brightness(98%) contrast(83%);
|
||||
|
||||
--footnote-highlight: #d3a17a;
|
||||
|
||||
--overlay-bg: rgba(150, 150, 150, 0.25);
|
||||
|
||||
--blockquote-note-color: #023b95;
|
||||
--blockquote-tip-color: #007700;
|
||||
--blockquote-important-color: #8250df;
|
||||
--blockquote-warning-color: #603700;
|
||||
--blockquote-caution-color: #aa1721;
|
||||
|
||||
--sidebar-header-border-color: #8c391f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.light.no-js {
|
||||
html:not(.js) {
|
||||
--bg: hsl(200, 7%, 8%);
|
||||
--fg: #98a3ad;
|
||||
|
||||
@@ -275,5 +360,24 @@
|
||||
--searchresults-border-color: #98a3ad;
|
||||
--searchresults-li-bg: #2b2b2f;
|
||||
--search-mark-bg: #355c7d;
|
||||
|
||||
--color-scheme: dark;
|
||||
|
||||
/* Same as `--icons` */
|
||||
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
|
||||
/* Same as `--sidebar-active` */
|
||||
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
|
||||
|
||||
--footnote-highlight: #4079ae;
|
||||
|
||||
--overlay-bg: rgba(33, 40, 48, 0.4);
|
||||
|
||||
--blockquote-note-color: #4493f8;
|
||||
--blockquote-tip-color: #08ae08;
|
||||
--blockquote-important-color: #ab7df8;
|
||||
--blockquote-warning-color: #d29922;
|
||||
--blockquote-caution-color: #d91b29;
|
||||
|
||||
--sidebar-header-border-color: #3473ad;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local('Open Sans Light'), local('OpenSans-Light'),
|
||||
url('open-sans-v17-all-charsets-300.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-300.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-300italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -16,7 +16,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: local('Open Sans Light Italic'), local('OpenSans-LightItalic'),
|
||||
url('open-sans-v17-all-charsets-300italic.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-300italic.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-regular - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -25,7 +25,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'),
|
||||
url('open-sans-v17-all-charsets-regular.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-regular.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -34,7 +34,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: local('Open Sans Italic'), local('OpenSans-Italic'),
|
||||
url('open-sans-v17-all-charsets-italic.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-italic.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-600 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -43,7 +43,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'),
|
||||
url('open-sans-v17-all-charsets-600.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-600.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-600italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -52,7 +52,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: local('Open Sans SemiBold Italic'), local('OpenSans-SemiBoldItalic'),
|
||||
url('open-sans-v17-all-charsets-600italic.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-600italic.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-700 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -61,7 +61,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
||||
url('open-sans-v17-all-charsets-700.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-700.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-700italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -70,7 +70,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'),
|
||||
url('open-sans-v17-all-charsets-700italic.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-700italic.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-800 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -79,7 +79,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: local('Open Sans ExtraBold'), local('OpenSans-ExtraBold'),
|
||||
url('open-sans-v17-all-charsets-800.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-800.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* open-sans-800italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -88,7 +88,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
src: local('Open Sans ExtraBold Italic'), local('OpenSans-ExtraBoldItalic'),
|
||||
url('open-sans-v17-all-charsets-800italic.woff2') format('woff2');
|
||||
url('{{ resource "fonts/open-sans-v17-all-charsets-800italic.woff2" }}') format('woff2');
|
||||
}
|
||||
|
||||
/* source-code-pro-500 - latin_vietnamese_latin-ext_greek_cyrillic-ext_cyrillic */
|
||||
@@ -96,5 +96,5 @@
|
||||
font-family: 'Source Code Pro';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('source-code-pro-v11-all-charsets-500.woff2') format('woff2');
|
||||
src: url('{{ resource "fonts/source-code-pro-v11-all-charsets-500.woff2" }}') format('woff2');
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
843
crates/mdbook-html/front-end/js/book.js
Normal file
843
crates/mdbook-html/front-end/js/book.js
Normal file
@@ -0,0 +1,843 @@
|
||||
'use strict';
|
||||
|
||||
/* global default_theme, default_dark_theme, default_light_theme, hljs, ClipboardJS */
|
||||
|
||||
// Fix back button cache problem
|
||||
window.onunload = function() { };
|
||||
|
||||
// Global variable, shared between modules
|
||||
function playground_text(playground, hidden = true) {
|
||||
const code_block = playground.querySelector('code');
|
||||
|
||||
if (window.ace && code_block.classList.contains('editable')) {
|
||||
const editor = window.ace.edit(code_block);
|
||||
return editor.getValue();
|
||||
} else if (hidden) {
|
||||
return code_block.textContent;
|
||||
} else {
|
||||
return code_block.innerText;
|
||||
}
|
||||
}
|
||||
|
||||
(function codeSnippets() {
|
||||
function fetch_with_timeout(url, options, timeout = 6000) {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)),
|
||||
]);
|
||||
}
|
||||
|
||||
const playgrounds = Array.from(document.querySelectorAll('.playground'));
|
||||
if (playgrounds.length > 0) {
|
||||
fetch_with_timeout('https://play.rust-lang.org/meta/crates', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
// get list of crates available in the rust playground
|
||||
const playground_crates = response.crates.map(item => item['id']);
|
||||
playgrounds.forEach(block => handle_crate_list_update(block, playground_crates));
|
||||
});
|
||||
}
|
||||
|
||||
function handle_crate_list_update(playground_block, playground_crates) {
|
||||
// update the play buttons after receiving the response
|
||||
update_play_button(playground_block, playground_crates);
|
||||
|
||||
// and install on change listener to dynamically update ACE editors
|
||||
if (window.ace) {
|
||||
const code_block = playground_block.querySelector('code');
|
||||
if (code_block.classList.contains('editable')) {
|
||||
const editor = window.ace.edit(code_block);
|
||||
editor.addEventListener('change', () => {
|
||||
update_play_button(playground_block, playground_crates);
|
||||
});
|
||||
// add Ctrl-Enter command to execute rust code
|
||||
editor.commands.addCommand({
|
||||
name: 'run',
|
||||
bindKey: {
|
||||
win: 'Ctrl-Enter',
|
||||
mac: 'Ctrl-Enter',
|
||||
},
|
||||
exec: _editor => run_rust_code(playground_block),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updates the visibility of play button based on `no_run` class and
|
||||
// used crates vs ones available on https://play.rust-lang.org
|
||||
function update_play_button(pre_block, playground_crates) {
|
||||
const play_button = pre_block.querySelector('.play-button');
|
||||
|
||||
// skip if code is `no_run`
|
||||
if (pre_block.querySelector('code').classList.contains('no_run')) {
|
||||
play_button.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// get list of `extern crate`'s from snippet
|
||||
const txt = playground_text(pre_block);
|
||||
const re = /extern\s+crate\s+([a-zA-Z_0-9]+)\s*;/g;
|
||||
const snippet_crates = [];
|
||||
let item;
|
||||
while (item = re.exec(txt)) {
|
||||
snippet_crates.push(item[1]);
|
||||
}
|
||||
|
||||
// check if all used crates are available on play.rust-lang.org
|
||||
const all_available = snippet_crates.every(function(elem) {
|
||||
return playground_crates.indexOf(elem) > -1;
|
||||
});
|
||||
|
||||
if (all_available) {
|
||||
play_button.classList.remove('hidden');
|
||||
play_button.hidden = false;
|
||||
} else {
|
||||
play_button.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function run_rust_code(code_block) {
|
||||
let result_block = code_block.querySelector('.result');
|
||||
if (!result_block) {
|
||||
result_block = document.createElement('code');
|
||||
result_block.className = 'result hljs language-bash';
|
||||
|
||||
code_block.append(result_block);
|
||||
}
|
||||
|
||||
const text = playground_text(code_block);
|
||||
const classes = code_block.querySelector('code').classList;
|
||||
let edition = '2015';
|
||||
classes.forEach(className => {
|
||||
if (className.startsWith('edition')) {
|
||||
edition = className.slice(7);
|
||||
}
|
||||
});
|
||||
const params = {
|
||||
version: 'stable',
|
||||
optimize: '0',
|
||||
code: text,
|
||||
edition: edition,
|
||||
};
|
||||
|
||||
if (text.indexOf('#![feature') !== -1) {
|
||||
params.version = 'nightly';
|
||||
}
|
||||
|
||||
result_block.innerText = 'Running...';
|
||||
|
||||
fetch_with_timeout('https://play.rust-lang.org/evaluate.json', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
if (response.result.trim() === '') {
|
||||
result_block.innerText = 'No output';
|
||||
result_block.classList.add('result-no-output');
|
||||
} else {
|
||||
result_block.innerText = response.result;
|
||||
result_block.classList.remove('result-no-output');
|
||||
}
|
||||
})
|
||||
.catch(error => result_block.innerText = 'Playground Communication: ' + error.message);
|
||||
}
|
||||
|
||||
// Syntax highlighting Configuration
|
||||
hljs.configure({
|
||||
tabReplace: ' ', // 4 spaces
|
||||
languages: [], // Languages used for auto-detection
|
||||
});
|
||||
|
||||
const code_nodes = Array
|
||||
.from(document.querySelectorAll('code'))
|
||||
// Don't highlight `inline code` blocks in headers.
|
||||
.filter(function(node) {
|
||||
return !node.parentElement.classList.contains('header');
|
||||
});
|
||||
|
||||
if (window.ace) {
|
||||
// language-rust class needs to be removed for editable
|
||||
// blocks or highlightjs will capture events
|
||||
code_nodes
|
||||
.filter(function(node) {
|
||||
return node.classList.contains('editable');
|
||||
})
|
||||
.forEach(function(block) {
|
||||
block.classList.remove('language-rust');
|
||||
});
|
||||
|
||||
code_nodes
|
||||
.filter(function(node) {
|
||||
return !node.classList.contains('editable');
|
||||
})
|
||||
.forEach(function(block) {
|
||||
hljs.highlightBlock(block);
|
||||
});
|
||||
} else {
|
||||
code_nodes.forEach(function(block) {
|
||||
hljs.highlightBlock(block);
|
||||
});
|
||||
}
|
||||
|
||||
// Adding the hljs class gives code blocks the color css
|
||||
// even if highlighting doesn't apply
|
||||
code_nodes.forEach(function(block) {
|
||||
block.classList.add('hljs');
|
||||
});
|
||||
|
||||
Array.from(document.querySelectorAll('code.hljs')).forEach(function(block) {
|
||||
|
||||
const lines = Array.from(block.querySelectorAll('.boring'));
|
||||
// If no lines were hidden, return
|
||||
if (!lines.length) {
|
||||
return;
|
||||
}
|
||||
block.classList.add('hide-boring');
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'buttons';
|
||||
buttons.innerHTML = '<button title="Show hidden lines" \
|
||||
aria-label="Show hidden lines"></button>';
|
||||
buttons.firstChild.innerHTML = document.getElementById('fa-eye').innerHTML;
|
||||
|
||||
// add expand button
|
||||
const pre_block = block.parentNode;
|
||||
pre_block.insertBefore(buttons, pre_block.firstChild);
|
||||
|
||||
buttons.firstChild.addEventListener('click', function(e) {
|
||||
if (this.title === 'Show hidden lines') {
|
||||
this.innerHTML = document.getElementById('fa-eye-slash').innerHTML;
|
||||
this.title = 'Hide lines';
|
||||
this.setAttribute('aria-label', e.target.title);
|
||||
|
||||
block.classList.remove('hide-boring');
|
||||
} else if (this.title === 'Hide lines') {
|
||||
this.innerHTML = document.getElementById('fa-eye').innerHTML;
|
||||
this.title = 'Show hidden lines';
|
||||
this.setAttribute('aria-label', e.target.title);
|
||||
|
||||
block.classList.add('hide-boring');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (window.playground_copyable) {
|
||||
Array.from(document.querySelectorAll('pre code')).forEach(function(block) {
|
||||
const pre_block = block.parentNode;
|
||||
if (!pre_block.classList.contains('playground')) {
|
||||
let buttons = pre_block.querySelector('.buttons');
|
||||
if (!buttons) {
|
||||
buttons = document.createElement('div');
|
||||
buttons.className = 'buttons';
|
||||
pre_block.insertBefore(buttons, pre_block.firstChild);
|
||||
}
|
||||
|
||||
const clipButton = document.createElement('button');
|
||||
clipButton.className = 'clip-button';
|
||||
clipButton.title = 'Copy to clipboard';
|
||||
clipButton.setAttribute('aria-label', clipButton.title);
|
||||
clipButton.innerHTML = '<i class="tooltiptext"></i>';
|
||||
|
||||
buttons.insertBefore(clipButton, buttons.firstChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process playground code blocks
|
||||
Array.from(document.querySelectorAll('.playground')).forEach(function(pre_block) {
|
||||
// Add play button
|
||||
let buttons = pre_block.querySelector('.buttons');
|
||||
if (!buttons) {
|
||||
buttons = document.createElement('div');
|
||||
buttons.className = 'buttons';
|
||||
pre_block.insertBefore(buttons, pre_block.firstChild);
|
||||
}
|
||||
|
||||
const runCodeButton = document.createElement('button');
|
||||
runCodeButton.className = 'play-button';
|
||||
runCodeButton.hidden = true;
|
||||
runCodeButton.title = 'Run this code';
|
||||
runCodeButton.setAttribute('aria-label', runCodeButton.title);
|
||||
runCodeButton.innerHTML = document.getElementById('fa-play').innerHTML;
|
||||
|
||||
buttons.insertBefore(runCodeButton, buttons.firstChild);
|
||||
runCodeButton.addEventListener('click', () => {
|
||||
run_rust_code(pre_block);
|
||||
});
|
||||
|
||||
if (window.playground_copyable) {
|
||||
const copyCodeClipboardButton = document.createElement('button');
|
||||
copyCodeClipboardButton.className = 'clip-button';
|
||||
copyCodeClipboardButton.innerHTML = '<i class="tooltiptext"></i>';
|
||||
copyCodeClipboardButton.title = 'Copy to clipboard';
|
||||
copyCodeClipboardButton.setAttribute('aria-label', copyCodeClipboardButton.title);
|
||||
|
||||
buttons.insertBefore(copyCodeClipboardButton, buttons.firstChild);
|
||||
}
|
||||
|
||||
const code_block = pre_block.querySelector('code');
|
||||
if (window.ace && code_block.classList.contains('editable')) {
|
||||
const undoChangesButton = document.createElement('button');
|
||||
undoChangesButton.className = 'reset-button';
|
||||
undoChangesButton.title = 'Undo changes';
|
||||
undoChangesButton.setAttribute('aria-label', undoChangesButton.title);
|
||||
undoChangesButton.innerHTML +=
|
||||
document.getElementById('fa-clock-rotate-left').innerHTML;
|
||||
|
||||
buttons.insertBefore(undoChangesButton, buttons.firstChild);
|
||||
|
||||
undoChangesButton.addEventListener('click', function() {
|
||||
const editor = window.ace.edit(code_block);
|
||||
editor.setValue(editor.originalCode);
|
||||
editor.clearSelection();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(function themes() {
|
||||
const html = document.querySelector('html');
|
||||
const themeToggleButton = document.getElementById('mdbook-theme-toggle');
|
||||
const themePopup = document.getElementById('mdbook-theme-list');
|
||||
const themeColorMetaTag = document.querySelector('meta[name="theme-color"]');
|
||||
const themeIds = [];
|
||||
themePopup.querySelectorAll('button.theme').forEach(function(el) {
|
||||
themeIds.push(el.id);
|
||||
});
|
||||
const stylesheets = {
|
||||
ayuHighlight: document.querySelector('#mdbook-ayu-highlight-css'),
|
||||
tomorrowNight: document.querySelector('#mdbook-tomorrow-night-css'),
|
||||
highlight: document.querySelector('#mdbook-highlight-css'),
|
||||
};
|
||||
|
||||
function showThemes() {
|
||||
themePopup.style.display = 'block';
|
||||
themeToggleButton.setAttribute('aria-expanded', true);
|
||||
themePopup.querySelector('button#mdbook-theme-' + get_theme()).focus();
|
||||
}
|
||||
|
||||
function updateThemeSelected() {
|
||||
themePopup.querySelectorAll('.theme-selected').forEach(function(el) {
|
||||
el.classList.remove('theme-selected');
|
||||
});
|
||||
const selected = get_saved_theme() ?? 'default_theme';
|
||||
let element = themePopup.querySelector('button#mdbook-theme-' + selected);
|
||||
if (element === null) {
|
||||
// Fall back in case there is no "Default" item.
|
||||
element = themePopup.querySelector('button#mdbook-theme-' + get_theme());
|
||||
}
|
||||
element.classList.add('theme-selected');
|
||||
}
|
||||
|
||||
function hideThemes() {
|
||||
themePopup.style.display = 'none';
|
||||
themeToggleButton.setAttribute('aria-expanded', false);
|
||||
themeToggleButton.focus();
|
||||
}
|
||||
|
||||
function get_saved_theme() {
|
||||
let theme = null;
|
||||
try {
|
||||
theme = localStorage.getItem('mdbook-theme');
|
||||
} catch {
|
||||
// ignore error.
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
function delete_saved_theme() {
|
||||
localStorage.removeItem('mdbook-theme');
|
||||
}
|
||||
|
||||
function get_theme() {
|
||||
const theme = get_saved_theme();
|
||||
if (theme === null || theme === undefined || !themeIds.includes('mdbook-theme-' + theme)) {
|
||||
if (typeof default_dark_theme === 'undefined') {
|
||||
// A customized index.hbs might not define this, so fall back to
|
||||
// old behavior of determining the default on page load.
|
||||
return default_theme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? default_dark_theme
|
||||
: default_light_theme;
|
||||
} else {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
|
||||
let previousTheme = default_theme;
|
||||
function set_theme(theme, store = true) {
|
||||
let ace_theme;
|
||||
|
||||
if (theme === 'coal' || theme === 'navy') {
|
||||
stylesheets.ayuHighlight.disabled = true;
|
||||
stylesheets.tomorrowNight.disabled = false;
|
||||
stylesheets.highlight.disabled = true;
|
||||
|
||||
ace_theme = 'ace/theme/tomorrow_night';
|
||||
} else if (theme === 'ayu') {
|
||||
stylesheets.ayuHighlight.disabled = false;
|
||||
stylesheets.tomorrowNight.disabled = true;
|
||||
stylesheets.highlight.disabled = true;
|
||||
ace_theme = 'ace/theme/tomorrow_night';
|
||||
} else {
|
||||
stylesheets.ayuHighlight.disabled = true;
|
||||
stylesheets.tomorrowNight.disabled = true;
|
||||
stylesheets.highlight.disabled = false;
|
||||
ace_theme = 'ace/theme/dawn';
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
themeColorMetaTag.content = getComputedStyle(document.documentElement).backgroundColor;
|
||||
}, 1);
|
||||
|
||||
if (window.ace && window.editors) {
|
||||
window.editors.forEach(function(editor) {
|
||||
editor.setTheme(ace_theme);
|
||||
});
|
||||
}
|
||||
|
||||
if (store) {
|
||||
try {
|
||||
localStorage.setItem('mdbook-theme', theme);
|
||||
} catch {
|
||||
// ignore error.
|
||||
}
|
||||
}
|
||||
|
||||
html.classList.remove(previousTheme);
|
||||
html.classList.add(theme);
|
||||
previousTheme = theme;
|
||||
updateThemeSelected();
|
||||
}
|
||||
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
query.onchange = function() {
|
||||
set_theme(get_theme(), false);
|
||||
};
|
||||
|
||||
// Set theme.
|
||||
set_theme(get_theme(), false);
|
||||
|
||||
themeToggleButton.addEventListener('click', function() {
|
||||
if (themePopup.style.display === 'block') {
|
||||
hideThemes();
|
||||
} else {
|
||||
showThemes();
|
||||
}
|
||||
});
|
||||
|
||||
themePopup.addEventListener('click', function(e) {
|
||||
let theme;
|
||||
if (e.target.className === 'theme') {
|
||||
theme = e.target.id;
|
||||
} else if (e.target.parentElement.className === 'theme') {
|
||||
theme = e.target.parentElement.id;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
theme = theme.replace(/^mdbook-theme-/, '');
|
||||
|
||||
if (theme === 'default_theme' || theme === null) {
|
||||
delete_saved_theme();
|
||||
set_theme(get_theme(), false);
|
||||
} else {
|
||||
set_theme(theme);
|
||||
}
|
||||
});
|
||||
|
||||
themePopup.addEventListener('focusout', function(e) {
|
||||
// e.relatedTarget is null in Safari and Firefox on macOS (see workaround below)
|
||||
if (!!e.relatedTarget &&
|
||||
!themeToggleButton.contains(e.relatedTarget) &&
|
||||
!themePopup.contains(e.relatedTarget)
|
||||
) {
|
||||
hideThemes();
|
||||
}
|
||||
});
|
||||
|
||||
// Should not be needed, but it works around an issue on macOS & iOS:
|
||||
// https://github.com/rust-lang/mdBook/issues/628
|
||||
document.addEventListener('click', function(e) {
|
||||
if (themePopup.style.display === 'block' &&
|
||||
!themeToggleButton.contains(e.target) &&
|
||||
!themePopup.contains(e.target)
|
||||
) {
|
||||
hideThemes();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (!themePopup.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let li;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
hideThemes();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
li = document.activeElement.parentElement;
|
||||
if (li && li.previousElementSibling) {
|
||||
li.previousElementSibling.querySelector('button').focus();
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
li = document.activeElement.parentElement;
|
||||
if (li && li.nextElementSibling) {
|
||||
li.nextElementSibling.querySelector('button').focus();
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
themePopup.querySelector('li:first-child button').focus();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
themePopup.querySelector('li:last-child button').focus();
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(function sidebar() {
|
||||
const sidebar = document.getElementById('mdbook-sidebar');
|
||||
const sidebarLinks = document.querySelectorAll('#mdbook-sidebar a');
|
||||
const sidebarToggleButton = document.getElementById('mdbook-sidebar-toggle');
|
||||
const sidebarResizeHandle = document.getElementById('mdbook-sidebar-resize-handle');
|
||||
const sidebarCheckbox = document.getElementById('mdbook-sidebar-toggle-anchor');
|
||||
let firstContact = null;
|
||||
|
||||
|
||||
/* Because we cannot change the `display` using only CSS after/before the transition, we
|
||||
need JS to do it. We change the display to prevent the browsers search to find text inside
|
||||
the collapsed sidebar. */
|
||||
if (!document.documentElement.classList.contains('sidebar-visible')) {
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
sidebar.addEventListener('transitionend', () => {
|
||||
/* We only change the display to "none" if we're collapsing the sidebar. */
|
||||
if (!sidebarCheckbox.checked) {
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
});
|
||||
sidebarToggleButton.addEventListener('click', () => {
|
||||
/* To allow the sidebar expansion animation, we first need to put back the display. */
|
||||
if (!sidebarCheckbox.checked) {
|
||||
sidebar.style.display = '';
|
||||
// Workaround for Safari skipping the animation when changing
|
||||
// `display` and a transform in the same event loop. This forces a
|
||||
// reflow after updating the display.
|
||||
sidebar.offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
function showSidebar() {
|
||||
document.documentElement.classList.add('sidebar-visible');
|
||||
Array.from(sidebarLinks).forEach(function(link) {
|
||||
link.setAttribute('tabIndex', 0);
|
||||
});
|
||||
sidebarToggleButton.setAttribute('aria-expanded', true);
|
||||
sidebar.setAttribute('aria-hidden', false);
|
||||
try {
|
||||
localStorage.setItem('mdbook-sidebar', 'visible');
|
||||
} catch {
|
||||
// Ignore error.
|
||||
}
|
||||
}
|
||||
|
||||
function hideSidebar() {
|
||||
document.documentElement.classList.remove('sidebar-visible');
|
||||
Array.from(sidebarLinks).forEach(function(link) {
|
||||
link.setAttribute('tabIndex', -1);
|
||||
});
|
||||
sidebarToggleButton.setAttribute('aria-expanded', false);
|
||||
sidebar.setAttribute('aria-hidden', true);
|
||||
try {
|
||||
localStorage.setItem('mdbook-sidebar', 'hidden');
|
||||
} catch {
|
||||
// Ignore error.
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle sidebar
|
||||
sidebarCheckbox.addEventListener('change', function sidebarToggle() {
|
||||
if (sidebarCheckbox.checked) {
|
||||
const current_width = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--sidebar-target-width'), 10);
|
||||
if (current_width < 150) {
|
||||
document.documentElement.style.setProperty('--sidebar-target-width', '150px');
|
||||
}
|
||||
showSidebar();
|
||||
} else {
|
||||
hideSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
sidebarResizeHandle.addEventListener('mousedown', initResize, false);
|
||||
|
||||
function initResize() {
|
||||
window.addEventListener('mousemove', resize, false);
|
||||
window.addEventListener('mouseup', stopResize, false);
|
||||
document.documentElement.classList.add('sidebar-resizing');
|
||||
}
|
||||
function resize(e) {
|
||||
let pos = e.clientX - sidebar.offsetLeft;
|
||||
if (pos < 20) {
|
||||
hideSidebar();
|
||||
} else {
|
||||
if (!document.documentElement.classList.contains('sidebar-visible')) {
|
||||
showSidebar();
|
||||
}
|
||||
pos = Math.min(pos, window.innerWidth - 100);
|
||||
document.documentElement.style.setProperty('--sidebar-target-width', pos + 'px');
|
||||
}
|
||||
}
|
||||
//on mouseup remove windows functions mousemove & mouseup
|
||||
function stopResize() {
|
||||
document.documentElement.classList.remove('sidebar-resizing');
|
||||
window.removeEventListener('mousemove', resize, false);
|
||||
window.removeEventListener('mouseup', stopResize, false);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
firstContact = {
|
||||
x: e.touches[0].clientX,
|
||||
time: Date.now(),
|
||||
};
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
if (!firstContact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const curX = e.touches[0].clientX;
|
||||
const xDiff = curX - firstContact.x,
|
||||
tDiff = Date.now() - firstContact.time;
|
||||
|
||||
if (tDiff < 250 && Math.abs(xDiff) >= 150) {
|
||||
if (xDiff >= 0 && firstContact.x < Math.min(document.body.clientWidth * 0.25, 300)) {
|
||||
showSidebar();
|
||||
} else if (xDiff < 0 && curX < 300) {
|
||||
hideSidebar();
|
||||
}
|
||||
|
||||
firstContact = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
})();
|
||||
|
||||
(function chapterNavigation() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (window.search && window.search.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
const html = document.querySelector('html');
|
||||
|
||||
function next() {
|
||||
const nextButton = document.querySelector('.nav-chapters.next');
|
||||
if (nextButton) {
|
||||
window.location.href = nextButton.href;
|
||||
}
|
||||
}
|
||||
function prev() {
|
||||
const previousButton = document.querySelector('.nav-chapters.previous');
|
||||
if (previousButton) {
|
||||
window.location.href = previousButton.href;
|
||||
}
|
||||
}
|
||||
function showHelp() {
|
||||
const container = document.getElementById('mdbook-help-container');
|
||||
const overlay = document.getElementById('mdbook-help-popup');
|
||||
container.style.display = 'flex';
|
||||
|
||||
// Clicking outside the popup will dismiss it.
|
||||
const mouseHandler = event => {
|
||||
if (overlay.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.removeEventListener('mousedown', mouseHandler);
|
||||
hideHelp();
|
||||
};
|
||||
|
||||
// Pressing esc will dismiss the popup.
|
||||
const escapeKeyHandler = event => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.removeEventListener('keydown', escapeKeyHandler, true);
|
||||
hideHelp();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escapeKeyHandler, true);
|
||||
document.getElementById('mdbook-help-container')
|
||||
.addEventListener('mousedown', mouseHandler);
|
||||
}
|
||||
function hideHelp() {
|
||||
document.getElementById('mdbook-help-container').style.display = 'none';
|
||||
}
|
||||
|
||||
// Usually needs the Shift key to be pressed
|
||||
switch (e.key) {
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
showHelp();
|
||||
break;
|
||||
}
|
||||
|
||||
// Rest of the keys are only active when the Shift key is not pressed
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (html.dir === 'rtl') {
|
||||
prev();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (html.dir === 'rtl') {
|
||||
next();
|
||||
} else {
|
||||
prev();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(function clipboard() {
|
||||
const clipButtons = document.querySelectorAll('.clip-button');
|
||||
|
||||
function hideTooltip(elem) {
|
||||
elem.firstChild.innerText = '';
|
||||
elem.className = 'clip-button';
|
||||
}
|
||||
|
||||
function showTooltip(elem, msg) {
|
||||
elem.firstChild.innerText = msg;
|
||||
elem.className = 'clip-button tooltipped';
|
||||
}
|
||||
|
||||
const clipboardSnippets = new ClipboardJS('.clip-button', {
|
||||
text: function(trigger) {
|
||||
hideTooltip(trigger);
|
||||
const playground = trigger.closest('pre');
|
||||
return playground_text(playground, false);
|
||||
},
|
||||
});
|
||||
|
||||
Array.from(clipButtons).forEach(function(clipButton) {
|
||||
clipButton.addEventListener('mouseout', function(e) {
|
||||
hideTooltip(e.currentTarget);
|
||||
});
|
||||
});
|
||||
|
||||
clipboardSnippets.on('success', function(e) {
|
||||
e.clearSelection();
|
||||
showTooltip(e.trigger, 'Copied!');
|
||||
});
|
||||
|
||||
clipboardSnippets.on('error', function(e) {
|
||||
showTooltip(e.trigger, 'Clipboard error!');
|
||||
});
|
||||
})();
|
||||
|
||||
(function scrollToTop() {
|
||||
const menuTitle = document.querySelector('.menu-title');
|
||||
|
||||
menuTitle.addEventListener('click', function() {
|
||||
document.scrollingElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
})();
|
||||
|
||||
(function controllMenu() {
|
||||
const menu = document.getElementById('mdbook-menu-bar');
|
||||
|
||||
(function controllPosition() {
|
||||
let scrollTop = document.scrollingElement.scrollTop;
|
||||
let prevScrollTop = scrollTop;
|
||||
const minMenuY = -menu.clientHeight - 50;
|
||||
// When the script loads, the page can be at any scroll (e.g. if you refresh it).
|
||||
menu.style.top = scrollTop + 'px';
|
||||
// Same as parseInt(menu.style.top.slice(0, -2), but faster
|
||||
let topCache = menu.style.top.slice(0, -2);
|
||||
menu.classList.remove('sticky');
|
||||
let stickyCache = false; // Same as menu.classList.contains('sticky'), but faster
|
||||
document.addEventListener('scroll', function() {
|
||||
scrollTop = Math.max(document.scrollingElement.scrollTop, 0);
|
||||
// `null` means that it doesn't need to be updated
|
||||
let nextSticky = null;
|
||||
let nextTop = null;
|
||||
const scrollDown = scrollTop > prevScrollTop;
|
||||
const menuPosAbsoluteY = topCache - scrollTop;
|
||||
if (scrollDown) {
|
||||
nextSticky = false;
|
||||
if (menuPosAbsoluteY > 0) {
|
||||
nextTop = prevScrollTop;
|
||||
}
|
||||
} else {
|
||||
if (menuPosAbsoluteY > 0) {
|
||||
nextSticky = true;
|
||||
} else if (menuPosAbsoluteY < minMenuY) {
|
||||
nextTop = prevScrollTop + minMenuY;
|
||||
}
|
||||
}
|
||||
if (nextSticky === true && stickyCache === false) {
|
||||
menu.classList.add('sticky');
|
||||
stickyCache = true;
|
||||
} else if (nextSticky === false && stickyCache === true) {
|
||||
menu.classList.remove('sticky');
|
||||
stickyCache = false;
|
||||
}
|
||||
if (nextTop !== null) {
|
||||
menu.style.top = nextTop + 'px';
|
||||
topCache = nextTop;
|
||||
}
|
||||
prevScrollTop = scrollTop;
|
||||
}, { passive: true });
|
||||
})();
|
||||
(function controllBorder() {
|
||||
function updateBorder() {
|
||||
if (menu.offsetTop === 0) {
|
||||
menu.classList.remove('bordered');
|
||||
} else {
|
||||
menu.classList.add('bordered');
|
||||
}
|
||||
}
|
||||
updateBorder();
|
||||
document.addEventListener('scroll', updateBorder, { passive: true });
|
||||
})();
|
||||
})();
|
||||
555
crates/mdbook-html/front-end/searcher/searcher.js
Normal file
555
crates/mdbook-html/front-end/searcher/searcher.js
Normal file
@@ -0,0 +1,555 @@
|
||||
'use strict';
|
||||
|
||||
/* global Mark, elasticlunr, path_to_root */
|
||||
|
||||
window.search = window.search || {};
|
||||
(function search() {
|
||||
// Search functionality
|
||||
//
|
||||
// You can use !hasFocus() to prevent keyhandling in your key
|
||||
// event handlers while the user is typing their search.
|
||||
|
||||
if (!Mark || !elasticlunr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
// IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
|
||||
if (!String.prototype.startsWith) {
|
||||
String.prototype.startsWith = function(search, pos) {
|
||||
return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
|
||||
};
|
||||
}
|
||||
|
||||
const search_wrap = document.getElementById('mdbook-search-wrapper'),
|
||||
searchbar_outer = document.getElementById('mdbook-searchbar-outer'),
|
||||
searchbar = document.getElementById('mdbook-searchbar'),
|
||||
searchresults = document.getElementById('mdbook-searchresults'),
|
||||
searchresults_outer = document.getElementById('mdbook-searchresults-outer'),
|
||||
searchresults_header = document.getElementById('mdbook-searchresults-header'),
|
||||
searchicon = document.getElementById('mdbook-search-toggle'),
|
||||
content = document.getElementById('mdbook-content'),
|
||||
|
||||
// SVG text elements don't render if inside a <mark> tag.
|
||||
mark_exclude = ['text'],
|
||||
marker = new Mark(content),
|
||||
URL_SEARCH_PARAM = 'search',
|
||||
URL_MARK_PARAM = 'highlight';
|
||||
|
||||
let current_searchterm = '',
|
||||
doc_urls = [],
|
||||
search_options = {
|
||||
bool: 'AND',
|
||||
expand: true,
|
||||
fields: {
|
||||
title: {boost: 1},
|
||||
body: {boost: 1},
|
||||
breadcrumbs: {boost: 0},
|
||||
},
|
||||
},
|
||||
searchindex = null,
|
||||
results_options = {
|
||||
teaser_word_count: 30,
|
||||
limit_results: 30,
|
||||
},
|
||||
teaser_count = 0;
|
||||
|
||||
function hasFocus() {
|
||||
return searchbar === document.activeElement;
|
||||
}
|
||||
|
||||
function removeChildren(elem) {
|
||||
while (elem.firstChild) {
|
||||
elem.removeChild(elem.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse a url into its building blocks.
|
||||
function parseURL(url) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
return {
|
||||
source: url,
|
||||
protocol: a.protocol.replace(':', ''),
|
||||
host: a.hostname,
|
||||
port: a.port,
|
||||
params: (function() {
|
||||
const ret = {};
|
||||
const seg = a.search.replace(/^\?/, '').split('&');
|
||||
for (const part of seg) {
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
const s = part.split('=');
|
||||
ret[s[0]] = s[1];
|
||||
}
|
||||
return ret;
|
||||
})(),
|
||||
file: (a.pathname.match(/\/([^/?#]+)$/i) || ['', ''])[1],
|
||||
hash: a.hash.replace('#', ''),
|
||||
path: a.pathname.replace(/^([^/])/, '/$1'),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to recreate a url string from its building blocks.
|
||||
function renderURL(urlobject) {
|
||||
let url = urlobject.protocol + '://' + urlobject.host;
|
||||
if (urlobject.port !== '') {
|
||||
url += ':' + urlobject.port;
|
||||
}
|
||||
url += urlobject.path;
|
||||
let joiner = '?';
|
||||
for (const prop in urlobject.params) {
|
||||
if (Object.prototype.hasOwnProperty.call(urlobject.params, prop)) {
|
||||
url += joiner + prop + '=' + urlobject.params[prop];
|
||||
joiner = '&';
|
||||
}
|
||||
}
|
||||
if (urlobject.hash !== '') {
|
||||
url += '#' + urlobject.hash;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Helper to escape html special chars for displaying the teasers
|
||||
const escapeHTML = (function() {
|
||||
const MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
};
|
||||
const repl = function(c) {
|
||||
return MAP[c];
|
||||
};
|
||||
return function(s) {
|
||||
return s.replace(/[&<>'"]/g, repl);
|
||||
};
|
||||
})();
|
||||
|
||||
function formatSearchMetric(count, searchterm) {
|
||||
if (count === 1) {
|
||||
return count + ' search result for \'' + searchterm + '\':';
|
||||
} else if (count === 0) {
|
||||
return 'No search results for \'' + searchterm + '\'.';
|
||||
} else {
|
||||
return count + ' search results for \'' + searchterm + '\':';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSearchResult(result, searchterms) {
|
||||
const teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
|
||||
teaser_count++;
|
||||
|
||||
// The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
|
||||
const url = doc_urls[result.ref].split('#');
|
||||
if (url.length === 1) { // no anchor found
|
||||
url.push('');
|
||||
}
|
||||
|
||||
// encodeURIComponent escapes all chars that could allow an XSS except
|
||||
// for '. Due to that we also manually replace ' with its url-encoded
|
||||
// representation (%27).
|
||||
const encoded_search = encodeURIComponent(searchterms.join(' ')).replace(/'/g, '%27');
|
||||
|
||||
return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + encoded_search
|
||||
+ '#' + url[1] + '" aria-details="mdbook-teaser_' + teaser_count + '">'
|
||||
+ result.doc.breadcrumbs + '</a>'
|
||||
+ '<span class="teaser" id="mdbook-teaser_' + teaser_count
|
||||
+ '" aria-label="Search Result Teaser">' + teaser + '</span>';
|
||||
}
|
||||
|
||||
function makeTeaser(body, searchterms) {
|
||||
// The strategy is as follows:
|
||||
// First, assign a value to each word in the document:
|
||||
// Words that correspond to search terms (stemmer aware): 40
|
||||
// Normal words: 2
|
||||
// First word in a sentence: 8
|
||||
// Then use a sliding window with a constant number of words and count the
|
||||
// sum of the values of the words within the window. Then use the window that got the
|
||||
// maximum sum. If there are multiple maximas, then get the last one.
|
||||
// Enclose the terms in <em>.
|
||||
const stemmed_searchterms = searchterms.map(function(w) {
|
||||
return elasticlunr.stemmer(w.toLowerCase());
|
||||
});
|
||||
const searchterm_weight = 40;
|
||||
const weighted = []; // contains elements of ["word", weight, index_in_document]
|
||||
// split in sentences, then words
|
||||
const sentences = body.toLowerCase().split('. ');
|
||||
let index = 0;
|
||||
let value = 0;
|
||||
let searchterm_found = false;
|
||||
for (const sentenceindex in sentences) {
|
||||
const words = sentences[sentenceindex].split(' ');
|
||||
value = 8;
|
||||
for (const wordindex in words) {
|
||||
const word = words[wordindex];
|
||||
if (word.length > 0) {
|
||||
for (const searchtermindex in stemmed_searchterms) {
|
||||
if (elasticlunr.stemmer(word).startsWith(
|
||||
stemmed_searchterms[searchtermindex])
|
||||
) {
|
||||
value = searchterm_weight;
|
||||
searchterm_found = true;
|
||||
}
|
||||
}
|
||||
weighted.push([word, value, index]);
|
||||
value = 2;
|
||||
}
|
||||
index += word.length;
|
||||
index += 1; // ' ' or '.' if last word in sentence
|
||||
}
|
||||
index += 1; // because we split at a two-char boundary '. '
|
||||
}
|
||||
|
||||
if (weighted.length === 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const window_weight = [];
|
||||
const window_size = Math.min(weighted.length, results_options.teaser_word_count);
|
||||
|
||||
let cur_sum = 0;
|
||||
for (let wordindex = 0; wordindex < window_size; wordindex++) {
|
||||
cur_sum += weighted[wordindex][1];
|
||||
}
|
||||
window_weight.push(cur_sum);
|
||||
for (let wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
|
||||
cur_sum -= weighted[wordindex][1];
|
||||
cur_sum += weighted[wordindex + window_size][1];
|
||||
window_weight.push(cur_sum);
|
||||
}
|
||||
|
||||
let max_sum_window_index = 0;
|
||||
if (searchterm_found) {
|
||||
let max_sum = 0;
|
||||
// backwards
|
||||
for (let i = window_weight.length - 1; i >= 0; i--) {
|
||||
if (window_weight[i] > max_sum) {
|
||||
max_sum = window_weight[i];
|
||||
max_sum_window_index = i;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
max_sum_window_index = 0;
|
||||
}
|
||||
|
||||
// add <em/> around searchterms
|
||||
const teaser_split = [];
|
||||
index = weighted[max_sum_window_index][2];
|
||||
for (let i = max_sum_window_index; i < max_sum_window_index + window_size; i++) {
|
||||
const word = weighted[i];
|
||||
if (index < word[2]) {
|
||||
// missing text from index to start of `word`
|
||||
teaser_split.push(body.substring(index, word[2]));
|
||||
index = word[2];
|
||||
}
|
||||
if (word[1] === searchterm_weight) {
|
||||
teaser_split.push('<em>');
|
||||
}
|
||||
index = word[2] + word[0].length;
|
||||
teaser_split.push(body.substring(word[2], index));
|
||||
if (word[1] === searchterm_weight) {
|
||||
teaser_split.push('</em>');
|
||||
}
|
||||
}
|
||||
|
||||
return teaser_split.join('');
|
||||
}
|
||||
|
||||
function init(config) {
|
||||
results_options = config.results_options;
|
||||
search_options = config.search_options;
|
||||
doc_urls = config.doc_urls;
|
||||
searchindex = elasticlunr.Index.load(config.index);
|
||||
|
||||
searchbar_outer.classList.remove('searching');
|
||||
|
||||
searchbar.focus();
|
||||
|
||||
const searchterm = searchbar.value.trim();
|
||||
if (searchterm !== '') {
|
||||
searchbar.classList.add('active');
|
||||
doSearch(searchterm);
|
||||
}
|
||||
}
|
||||
|
||||
function initSearchInteractions(config) {
|
||||
// Set up events
|
||||
searchicon.addEventListener('click', () => {
|
||||
searchIconClickHandler();
|
||||
}, false);
|
||||
searchbar.addEventListener('keyup', () => {
|
||||
searchbarKeyUpHandler();
|
||||
}, false);
|
||||
document.addEventListener('keydown', e => {
|
||||
globalKeyHandler(e);
|
||||
}, false);
|
||||
// If the user uses the browser buttons, do the same as if a reload happened
|
||||
window.onpopstate = () => {
|
||||
doSearchOrMarkFromUrl();
|
||||
};
|
||||
// Suppress "submit" events so the page doesn't reload when the user presses Enter
|
||||
document.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
}, false);
|
||||
|
||||
// If reloaded, do the search or mark again, depending on the current url parameters
|
||||
doSearchOrMarkFromUrl();
|
||||
|
||||
// Exported functions
|
||||
config.hasFocus = hasFocus;
|
||||
}
|
||||
|
||||
initSearchInteractions(window.search);
|
||||
|
||||
function unfocusSearchbar() {
|
||||
// hacky, but just focusing a div only works once
|
||||
const tmp = document.createElement('input');
|
||||
tmp.setAttribute('style', 'position: absolute; opacity: 0;');
|
||||
searchicon.appendChild(tmp);
|
||||
tmp.focus();
|
||||
tmp.remove();
|
||||
}
|
||||
|
||||
// On reload or browser history backwards/forwards events, parse the url and do search or mark
|
||||
function doSearchOrMarkFromUrl() {
|
||||
// Check current URL for search request
|
||||
const url = parseURL(window.location.href);
|
||||
if (Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM)
|
||||
&& url.params[URL_SEARCH_PARAM] !== '') {
|
||||
showSearch(true);
|
||||
searchbar.value = decodeURIComponent(
|
||||
(url.params[URL_SEARCH_PARAM] + '').replace(/\+/g, '%20'));
|
||||
searchbarKeyUpHandler(); // -> doSearch()
|
||||
} else {
|
||||
showSearch(false);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(url.params, URL_MARK_PARAM)) {
|
||||
const words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
|
||||
marker.mark(words, {
|
||||
exclude: mark_exclude,
|
||||
});
|
||||
|
||||
const markers = document.querySelectorAll('mark');
|
||||
const hide = () => {
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
markers[i].classList.add('fade-out');
|
||||
window.setTimeout(() => {
|
||||
marker.unmark();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
markers[i].addEventListener('click', hide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for keyevents on `document`
|
||||
function globalKeyHandler(e) {
|
||||
if (e.altKey ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
e.shiftKey ||
|
||||
e.target.type === 'textarea' ||
|
||||
e.target.type === 'text' ||
|
||||
!hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
searchbar.classList.remove('active');
|
||||
setSearchUrlParameters('',
|
||||
searchbar.value.trim() !== '' ? 'push' : 'replace');
|
||||
if (hasFocus()) {
|
||||
unfocusSearchbar();
|
||||
}
|
||||
showSearch(false);
|
||||
marker.unmark();
|
||||
} else if (!hasFocus() && (e.key === 's' || e.key === '/')) {
|
||||
e.preventDefault();
|
||||
showSearch(true);
|
||||
window.scrollTo(0, 0);
|
||||
searchbar.select();
|
||||
} else if (hasFocus() && (e.key === 'ArrowDown'
|
||||
|| e.key === 'Enter')) {
|
||||
e.preventDefault();
|
||||
const first = searchresults.firstElementChild;
|
||||
if (first !== null) {
|
||||
unfocusSearchbar();
|
||||
first.classList.add('focus');
|
||||
if (e.key === 'Enter') {
|
||||
window.location.assign(first.querySelector('a'));
|
||||
}
|
||||
}
|
||||
} else if (!hasFocus() && (e.key === 'ArrowDown'
|
||||
|| e.key === 'ArrowUp'
|
||||
|| e.key === 'Enter')) {
|
||||
// not `:focus` because browser does annoying scrolling
|
||||
const focused = searchresults.querySelector('li.focus');
|
||||
if (!focused) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (e.key === 'ArrowDown') {
|
||||
const next = focused.nextElementSibling;
|
||||
if (next) {
|
||||
focused.classList.remove('focus');
|
||||
next.classList.add('focus');
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
focused.classList.remove('focus');
|
||||
const prev = focused.previousElementSibling;
|
||||
if (prev) {
|
||||
prev.classList.add('focus');
|
||||
} else {
|
||||
searchbar.select();
|
||||
}
|
||||
} else { // Enter
|
||||
window.location.assign(focused.querySelector('a'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSearchScript(url, id) {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
searchbar_outer.classList.add('searching');
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.id = id;
|
||||
script.onload = () => init(window.search);
|
||||
script.onerror = error => {
|
||||
console.error(`Failed to load \`${url}\`: ${error}`);
|
||||
};
|
||||
document.head.append(script);
|
||||
}
|
||||
|
||||
function showSearch(yes) {
|
||||
if (yes) {
|
||||
loadSearchScript(
|
||||
window.path_to_searchindex_js ||
|
||||
path_to_root + '{{ resource "searchindex.js" }}',
|
||||
'mdbook-search-index');
|
||||
search_wrap.classList.remove('hidden');
|
||||
searchicon.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
search_wrap.classList.add('hidden');
|
||||
searchicon.setAttribute('aria-expanded', 'false');
|
||||
const results = searchresults.children;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
results[i].classList.remove('focus');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showResults(yes) {
|
||||
if (yes) {
|
||||
searchresults_outer.classList.remove('hidden');
|
||||
} else {
|
||||
searchresults_outer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for search icon
|
||||
function searchIconClickHandler() {
|
||||
if (search_wrap.classList.contains('hidden')) {
|
||||
showSearch(true);
|
||||
window.scrollTo(0, 0);
|
||||
searchbar.select();
|
||||
} else {
|
||||
showSearch(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for keyevents while the searchbar is focused
|
||||
function searchbarKeyUpHandler() {
|
||||
const searchterm = searchbar.value.trim();
|
||||
if (searchterm !== '') {
|
||||
searchbar.classList.add('active');
|
||||
doSearch(searchterm);
|
||||
} else {
|
||||
searchbar.classList.remove('active');
|
||||
showResults(false);
|
||||
removeChildren(searchresults);
|
||||
}
|
||||
|
||||
setSearchUrlParameters(searchterm, 'push_if_new_search_else_replace');
|
||||
|
||||
// Remove marks
|
||||
marker.unmark();
|
||||
}
|
||||
|
||||
// Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and
|
||||
// `#heading-anchor`. `action` can be one of "push", "replace",
|
||||
// "push_if_new_search_else_replace" and replaces or pushes a new browser history item.
|
||||
// "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
|
||||
function setSearchUrlParameters(searchterm, action) {
|
||||
const url = parseURL(window.location.href);
|
||||
const first_search = !Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM);
|
||||
|
||||
if (searchterm !== '' || action === 'push_if_new_search_else_replace') {
|
||||
url.params[URL_SEARCH_PARAM] = searchterm;
|
||||
delete url.params[URL_MARK_PARAM];
|
||||
url.hash = '';
|
||||
} else {
|
||||
delete url.params[URL_MARK_PARAM];
|
||||
delete url.params[URL_SEARCH_PARAM];
|
||||
}
|
||||
// A new search will also add a new history item, so the user can go back
|
||||
// to the page prior to searching. A updated search term will only replace
|
||||
// the url.
|
||||
if (action === 'push' || action === 'push_if_new_search_else_replace' && first_search ) {
|
||||
history.pushState({}, document.title, renderURL(url));
|
||||
} else if (action === 'replace' ||
|
||||
action === 'push_if_new_search_else_replace' &&
|
||||
!first_search
|
||||
) {
|
||||
history.replaceState({}, document.title, renderURL(url));
|
||||
}
|
||||
}
|
||||
|
||||
function doSearch(searchterm) {
|
||||
// Don't search the same twice
|
||||
if (current_searchterm === searchterm) {
|
||||
return;
|
||||
}
|
||||
searchbar_outer.classList.add('searching');
|
||||
if (searchindex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
current_searchterm = searchterm;
|
||||
|
||||
// Do the actual search
|
||||
const results = searchindex.search(searchterm, search_options);
|
||||
const resultcount = Math.min(results.length, results_options.limit_results);
|
||||
|
||||
// Display search metrics
|
||||
searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
|
||||
|
||||
// Clear and insert results
|
||||
const searchterms = searchterm.split(' ');
|
||||
removeChildren(searchresults);
|
||||
for (let i = 0; i < resultcount ; i++) {
|
||||
const resultElem = document.createElement('li');
|
||||
resultElem.innerHTML = formatSearchResult(results[i], searchterms);
|
||||
searchresults.appendChild(resultElem);
|
||||
}
|
||||
|
||||
// Display results
|
||||
showResults(true);
|
||||
searchbar_outer.classList.remove('searching');
|
||||
}
|
||||
|
||||
// Exported functions
|
||||
search.hasFocus = hasFocus;
|
||||
})(window.search);
|
||||
367
crates/mdbook-html/front-end/templates/index.hbs
Normal file
367
crates/mdbook-html/front-end/templates/index.hbs
Normal file
@@ -0,0 +1,367 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="{{ language }}" class="{{ default_theme }} sidebar-visible" dir="{{ text_direction }}">
|
||||
<head>
|
||||
<!-- Book generated using mdBook -->
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
{{#if is_print }}
|
||||
<meta name="robots" content="noindex">
|
||||
{{/if}}
|
||||
{{#if base_url}}
|
||||
<base href="{{ base_url }}">
|
||||
{{/if}}
|
||||
|
||||
|
||||
<!-- Custom HTML head -->
|
||||
{{> head}}
|
||||
|
||||
<meta name="description" content="{{ description }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{{#if favicon_svg}}
|
||||
<link rel="icon" href="{{ resource "favicon.svg" }}">
|
||||
{{/if}}
|
||||
{{#if favicon_png}}
|
||||
<link rel="shortcut icon" href="{{ resource "favicon.png" }}">
|
||||
{{/if}}
|
||||
<link rel="stylesheet" href="{{ resource "css/variables.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/general.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/chrome.css" }}">
|
||||
{{#if print_enable}}
|
||||
<link rel="stylesheet" href="{{ resource "css/print.css" }}" media="print">
|
||||
{{/if}}
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="{{ resource "fonts/fonts.css" }}">
|
||||
|
||||
<!-- Highlight.js Stylesheets -->
|
||||
<link rel="stylesheet" id="mdbook-highlight-css" href="{{ resource "highlight.css" }}">
|
||||
<link rel="stylesheet" id="mdbook-tomorrow-night-css" href="{{ resource "tomorrow-night.css" }}">
|
||||
<link rel="stylesheet" id="mdbook-ayu-highlight-css" href="{{ resource "ayu-highlight.css" }}">
|
||||
|
||||
<!-- Custom theme stylesheets -->
|
||||
{{#each additional_css}}
|
||||
<link rel="stylesheet" href="{{ resource this }}">
|
||||
{{/each}}
|
||||
|
||||
{{#if mathjax_support}}
|
||||
<!-- MathJax -->
|
||||
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
|
||||
{{/if}}
|
||||
|
||||
<!-- Provide site root and default themes to javascript -->
|
||||
<script>
|
||||
const path_to_root = "{{ path_to_root }}";
|
||||
const default_light_theme = "{{ default_theme }}";
|
||||
const default_dark_theme = "{{ preferred_dark_theme }}";
|
||||
{{#if search_js}}
|
||||
window.path_to_searchindex_js = "{{ resource "searchindex.js" }}";
|
||||
{{/if}}
|
||||
</script>
|
||||
<!-- Start loading toc.js asap -->
|
||||
<script src="{{ resource "toc.js" }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mdbook-help-container">
|
||||
<div id="mdbook-help-popup">
|
||||
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
|
||||
<div>
|
||||
<p>Press <kbd>←</kbd> or <kbd>→</kbd> to navigate between chapters</p>
|
||||
{{#if search_enabled}}
|
||||
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
|
||||
{{/if}}
|
||||
<p>Press <kbd>?</kbd> to show this help</p>
|
||||
<p>Press <kbd>Esc</kbd> to hide this help</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mdbook-body-container">
|
||||
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
||||
<script>
|
||||
try {
|
||||
let theme = localStorage.getItem('mdbook-theme');
|
||||
let sidebar = localStorage.getItem('mdbook-sidebar');
|
||||
|
||||
if (theme.startsWith('"') && theme.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
||||
}
|
||||
|
||||
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
||||
}
|
||||
} catch (e) { }
|
||||
</script>
|
||||
|
||||
<!-- Set the theme before any content is loaded, prevents flash -->
|
||||
<script>
|
||||
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
|
||||
let theme;
|
||||
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
||||
if (theme === null || theme === undefined) { theme = default_theme; }
|
||||
const html = document.documentElement;
|
||||
html.classList.remove('{{ default_theme }}')
|
||||
html.classList.add(theme);
|
||||
html.classList.add("js");
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="mdbook-sidebar-toggle-anchor" class="hidden">
|
||||
|
||||
<!-- Hide / unhide sidebar before it is displayed -->
|
||||
<script>
|
||||
let sidebar = null;
|
||||
const sidebar_toggle = document.getElementById("mdbook-sidebar-toggle-anchor");
|
||||
if (document.body.clientWidth >= 1080) {
|
||||
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
||||
sidebar = sidebar || 'visible';
|
||||
} else {
|
||||
sidebar = 'hidden';
|
||||
sidebar_toggle.checked = false;
|
||||
}
|
||||
if (sidebar === 'visible') {
|
||||
sidebar_toggle.checked = true;
|
||||
} else {
|
||||
html.classList.remove('sidebar-visible');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav id="mdbook-sidebar" class="sidebar" aria-label="Table of contents">
|
||||
<!-- populated by js -->
|
||||
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
|
||||
<noscript>
|
||||
<iframe class="sidebar-iframe-outer" src="{{ path_to_root }}toc.html"></iframe>
|
||||
</noscript>
|
||||
<div id="mdbook-sidebar-resize-handle" class="sidebar-resize-handle">
|
||||
<div class="sidebar-resize-indicator"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="mdbook-page-wrapper" class="page-wrapper">
|
||||
|
||||
<div class="page">
|
||||
{{> header}}
|
||||
<div id="mdbook-menu-bar-hover-placeholder"></div>
|
||||
<div id="mdbook-menu-bar" class="menu-bar sticky">
|
||||
<div class="left-buttons">
|
||||
<label id="mdbook-sidebar-toggle" class="icon-button" for="mdbook-sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="mdbook-sidebar">
|
||||
{{fa "solid" "bars"}}
|
||||
</label>
|
||||
<button id="mdbook-theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="mdbook-theme-list">
|
||||
{{fa "solid" "paintbrush"}}
|
||||
</button>
|
||||
<ul id="mdbook-theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-default_theme">Auto</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-light">Light</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-rust">Rust</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-coal">Coal</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-navy">Navy</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-ayu">Ayu</button></li>
|
||||
</ul>
|
||||
{{#if search_enabled}}
|
||||
<button id="mdbook-search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="mdbook-searchbar">
|
||||
{{fa "solid" "magnifying-glass"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<h1 class="menu-title">{{ book_title }}</h1>
|
||||
|
||||
<div class="right-buttons">
|
||||
{{#if print_enable}}
|
||||
<a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book">
|
||||
{{fa "solid" "print" "print-button"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_url}}
|
||||
<a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository">
|
||||
{{fa git_repository_icon_class git_repository_icon}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_edit_url}}
|
||||
<a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit" rel="edit">
|
||||
{{fa "solid" "pencil" "git-edit-button"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if search_enabled}}
|
||||
<div id="mdbook-search-wrapper" class="hidden">
|
||||
<form id="mdbook-searchbar-outer" class="searchbar-outer">
|
||||
<div class="search-wrapper">
|
||||
<input type="search" id="mdbook-searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="mdbook-searchresults-outer" aria-describedby="searchresults-header">
|
||||
<div class="spinner-wrapper">
|
||||
{{fa "solid" "spinner" "fa-spin"}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="mdbook-searchresults-outer" class="searchresults-outer hidden">
|
||||
<div id="mdbook-searchresults-header" class="searchresults-header"></div>
|
||||
<ul id="mdbook-searchresults">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
||||
<script>
|
||||
document.getElementById('mdbook-sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
||||
document.getElementById('mdbook-sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
||||
Array.from(document.querySelectorAll('#mdbook-sidebar a')).forEach(function(link) {
|
||||
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="mdbook-content" class="content">
|
||||
<main>
|
||||
{{{ content }}}
|
||||
</main>
|
||||
|
||||
<nav class="nav-wrapper" aria-label="Page navigation">
|
||||
<!-- Mobile navigation buttons -->
|
||||
{{#if previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{previous.link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{next.link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
<div style="clear: both"></div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
||||
{{#if previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{previous.link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{next.link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
{{#if (eq text_direction "rtl")}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<template id=fa-eye>{{fa "solid" "eye"}}</template>
|
||||
<template id=fa-eye-slash>{{fa "solid" "eye-slash"}}</template>
|
||||
<template id=fa-copy>{{fa "regular" "copy"}}</template>
|
||||
<template id=fa-play>{{fa "solid" "play"}}</template>
|
||||
<template id=fa-clock-rotate-left>{{fa "solid" "clock-rotate-left"}}</template>
|
||||
|
||||
{{#if live_reload_endpoint}}
|
||||
<!-- Livereload script (if served using the cli tool) -->
|
||||
<script>
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}";
|
||||
const socket = new WebSocket(wsAddress);
|
||||
socket.onmessage = function (event) {
|
||||
if (event.data === "reload") {
|
||||
socket.close();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
window.onbeforeunload = function() {
|
||||
socket.close();
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_line_numbers}}
|
||||
<script>
|
||||
window.playground_line_numbers = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_copyable}}
|
||||
<script>
|
||||
window.playground_copyable = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_js}}
|
||||
<script src="{{ resource "ace.js" }}"></script>
|
||||
<script src="{{ resource "mode-rust.js" }}"></script>
|
||||
<script src="{{ resource "editor.js" }}"></script>
|
||||
<script src="{{ resource "theme-dawn.js" }}"></script>
|
||||
<script src="{{ resource "theme-tomorrow_night.js" }}"></script>
|
||||
{{/if}}
|
||||
|
||||
{{#if search_js}}
|
||||
<script src="{{ resource "elasticlunr.min.js" }}"></script>
|
||||
<script src="{{ resource "mark.min.js" }}"></script>
|
||||
<script src="{{ resource "searcher.js" }}"></script>
|
||||
{{/if}}
|
||||
|
||||
<script src="{{ resource "clipboard.min.js" }}"></script>
|
||||
<script src="{{ resource "highlight.js" }}"></script>
|
||||
<script src="{{ resource "book.js" }}"></script>
|
||||
|
||||
<!-- Custom JS scripts -->
|
||||
{{#each additional_js}}
|
||||
<script src="{{ resource this}}"></script>
|
||||
{{/each}}
|
||||
|
||||
{{#if is_print}}
|
||||
{{#if mathjax_support}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
MathJax.Hub.Register.StartupHook('End', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{else}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
</script>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if fragment_map}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fragmentMap =
|
||||
{{{fragment_map}}}
|
||||
;
|
||||
const target = fragmentMap[window.location.hash];
|
||||
if (target) {
|
||||
let url = new URL(target, window.location.href);
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
36
crates/mdbook-html/front-end/templates/redirect.hbs
Normal file
36
crates/mdbook-html/front-end/templates/redirect.hbs
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
<meta http-equiv="refresh" content="0; URL={{url}}">
|
||||
<link rel="canonical" href="{{url}}">
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to... <a href="{{url}}">{{url}}</a>.</p>
|
||||
|
||||
<script>
|
||||
// This handles redirects that involve fragments.
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fragmentMap =
|
||||
{{{fragment_map}}}
|
||||
;
|
||||
const fragment = window.location.hash;
|
||||
if (fragment) {
|
||||
let redirectUrl = "{{url}}";
|
||||
const target = fragmentMap[fragment];
|
||||
if (target) {
|
||||
let url = new URL(target, window.location.href);
|
||||
redirectUrl = url.href;
|
||||
} else {
|
||||
let url = new URL(redirectUrl, window.location.href);
|
||||
url.hash = window.location.hash;
|
||||
redirectUrl = url.href;
|
||||
}
|
||||
window.location.replace(redirectUrl);
|
||||
}
|
||||
// else redirect handled by http-equiv
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
40
crates/mdbook-html/front-end/templates/toc.html.hbs
Normal file
40
crates/mdbook-html/front-end/templates/toc.html.hbs
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="{{ language }}" class="{{ default_theme }}" dir="{{ text_direction }}">
|
||||
<head>
|
||||
<!-- sidebar iframe generated using mdBook
|
||||
|
||||
This is a frame, and not included directly in the page, to control the total size of the
|
||||
book. The TOC contains an entry for each page, so if each page includes a copy of the TOC,
|
||||
the total size of the page becomes O(n**2).
|
||||
|
||||
The frame is only used as a fallback when JS is turned off. When it's on, the sidebar is
|
||||
instead added to the main page by `toc.js` instead. The JavaScript mode is better
|
||||
because, when running in a `file:///` URL, the iframed page would not be Same-Origin as
|
||||
the rest of the page, so the sidebar and the main page theme would fall out of sync.
|
||||
-->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex">
|
||||
{{#if base_url}}
|
||||
<base href="{{ base_url }}">
|
||||
{{/if}}
|
||||
<!-- Custom HTML head -->
|
||||
{{> head}}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="stylesheet" href="{{ resource "css/variables.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/general.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/chrome.css" }}">
|
||||
{{#if print_enable}}
|
||||
<link rel="stylesheet" href="{{ resource "css/print.css" }}" media="print">
|
||||
{{/if}}
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="{{ resource "fonts/fonts.css" }}">
|
||||
<!-- Custom theme stylesheets -->
|
||||
{{#each additional_css}}
|
||||
<link rel="stylesheet" href="{{ resource this }}">
|
||||
{{/each}}
|
||||
</head>
|
||||
<body class="sidebar-iframe-inner">
|
||||
{{#toc}}{{/toc}}
|
||||
</body>
|
||||
</html>
|
||||
448
crates/mdbook-html/front-end/templates/toc.js.hbs
Normal file
448
crates/mdbook-html/front-end/templates/toc.js.hbs
Normal file
@@ -0,0 +1,448 @@
|
||||
// Populate the sidebar
|
||||
//
|
||||
// This is a script, and not included directly in the page, to control the total size of the book.
|
||||
// The TOC contains an entry for each page, so if each page includes a copy of the TOC,
|
||||
// the total size of the page becomes O(n**2).
|
||||
class MDBookSidebarScrollbox extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
connectedCallback() {
|
||||
this.innerHTML = '{{#toc}}{{/toc}}';
|
||||
// Set the current, active page, and reveal it if it's hidden
|
||||
let current_page = document.location.href.toString().split('#')[0].split('?')[0];
|
||||
if (current_page.endsWith('/')) {
|
||||
current_page += 'index.html';
|
||||
}
|
||||
const links = Array.prototype.slice.call(this.querySelectorAll('a'));
|
||||
const l = links.length;
|
||||
for (let i = 0; i < l; ++i) {
|
||||
const link = links[i];
|
||||
const href = link.getAttribute('href');
|
||||
if (href && !href.startsWith('#') && !/^(?:[a-z+]+:)?\/\//.test(href)) {
|
||||
link.href = path_to_root + href;
|
||||
}
|
||||
// The 'index' page is supposed to alias the first chapter in the book.
|
||||
if (link.href === current_page
|
||||
|| i === 0
|
||||
&& path_to_root === ''
|
||||
&& current_page.endsWith('/index.html')) {
|
||||
link.classList.add('active');
|
||||
let parent = link.parentElement;
|
||||
while (parent) {
|
||||
if (parent.tagName === 'LI' && parent.classList.contains('chapter-item')) {
|
||||
parent.classList.add('expanded');
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Track and set sidebar scroll position
|
||||
this.addEventListener('click', e => {
|
||||
if (e.target.tagName === 'A') {
|
||||
sessionStorage.setItem('sidebar-scroll', this.scrollTop);
|
||||
}
|
||||
}, { passive: true });
|
||||
const sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
|
||||
sessionStorage.removeItem('sidebar-scroll');
|
||||
if (sidebarScrollTop) {
|
||||
// preserve sidebar scroll position when navigating via links within sidebar
|
||||
this.scrollTop = sidebarScrollTop;
|
||||
} else {
|
||||
// scroll sidebar to current active section when navigating via
|
||||
// 'next/previous chapter' buttons
|
||||
const activeSection = document.querySelector('#mdbook-sidebar .active');
|
||||
if (activeSection) {
|
||||
activeSection.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
// Toggle buttons
|
||||
const sidebarAnchorToggles = document.querySelectorAll('.chapter-fold-toggle');
|
||||
function toggleSection(ev) {
|
||||
ev.currentTarget.parentElement.parentElement.classList.toggle('expanded');
|
||||
}
|
||||
Array.from(sidebarAnchorToggles).forEach(el => {
|
||||
el.addEventListener('click', toggleSection);
|
||||
});
|
||||
}
|
||||
}
|
||||
window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox);
|
||||
|
||||
{{#if sidebar_header_nav}}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Support for dynamically adding headers to the sidebar.
|
||||
|
||||
(function() {
|
||||
// This is used to detect which direction the page has scrolled since the
|
||||
// last scroll event.
|
||||
let lastKnownScrollPosition = 0;
|
||||
// This is the threshold in px from the top of the screen where it will
|
||||
// consider a header the "current" header when scrolling down.
|
||||
const defaultDownThreshold = 150;
|
||||
// Same as defaultDownThreshold, except when scrolling up.
|
||||
const defaultUpThreshold = 300;
|
||||
// The threshold is a virtual horizontal line on the screen where it
|
||||
// considers the "current" header to be above the line. The threshold is
|
||||
// modified dynamically to handle headers that are near the bottom of the
|
||||
// screen, and to slightly offset the behavior when scrolling up vs down.
|
||||
let threshold = defaultDownThreshold;
|
||||
// This is used to disable updates while scrolling. This is needed when
|
||||
// clicking the header in the sidebar, which triggers a scroll event. It
|
||||
// is somewhat finicky to detect when the scroll has finished, so this
|
||||
// uses a relatively dumb system of disabling scroll updates for a short
|
||||
// time after the click.
|
||||
let disableScroll = false;
|
||||
// Array of header elements on the page.
|
||||
let headers;
|
||||
// Array of li elements that are initially collapsed headers in the sidebar.
|
||||
// I'm not sure why eslint seems to have a false positive here.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let headerToggles = [];
|
||||
// This is a debugging tool for the threshold which you can enable in the console.
|
||||
let thresholdDebug = false;
|
||||
|
||||
// Updates the threshold based on the scroll position.
|
||||
function updateThreshold() {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
// The number of pixels below the viewport, at most documentHeight.
|
||||
// This is used to push the threshold down to the bottom of the page
|
||||
// as the user scrolls towards the bottom.
|
||||
const pixelsBelow = Math.max(0, documentHeight - (scrollTop + windowHeight));
|
||||
// The number of pixels above the viewport, at least defaultDownThreshold.
|
||||
// Similar to pixelsBelow, this is used to push the threshold back towards
|
||||
// the top when reaching the top of the page.
|
||||
const pixelsAbove = Math.max(0, defaultDownThreshold - scrollTop);
|
||||
// How much the threshold should be offset once it gets close to the
|
||||
// bottom of the page.
|
||||
const bottomAdd = Math.max(0, windowHeight - pixelsBelow - defaultDownThreshold);
|
||||
let adjustedBottomAdd = bottomAdd;
|
||||
|
||||
// Adjusts bottomAdd for a small document. The calculation above
|
||||
// assumes the document is at least twice the windowheight in size. If
|
||||
// it is less than that, then bottomAdd needs to be shrunk
|
||||
// proportional to the difference in size.
|
||||
if (documentHeight < windowHeight * 2) {
|
||||
const maxPixelsBelow = documentHeight - windowHeight;
|
||||
const t = 1 - pixelsBelow / Math.max(1, maxPixelsBelow);
|
||||
const clamp = Math.max(0, Math.min(1, t));
|
||||
adjustedBottomAdd *= clamp;
|
||||
}
|
||||
|
||||
let scrollingDown = true;
|
||||
if (scrollTop < lastKnownScrollPosition) {
|
||||
scrollingDown = false;
|
||||
}
|
||||
|
||||
if (scrollingDown) {
|
||||
// When scrolling down, move the threshold up towards the default
|
||||
// downwards threshold position. If near the bottom of the page,
|
||||
// adjustedBottomAdd will offset the threshold towards the bottom
|
||||
// of the page.
|
||||
const amountScrolledDown = scrollTop - lastKnownScrollPosition;
|
||||
const adjustedDefault = defaultDownThreshold + adjustedBottomAdd;
|
||||
threshold = Math.max(adjustedDefault, threshold - amountScrolledDown);
|
||||
} else {
|
||||
// When scrolling up, move the threshold down towards the default
|
||||
// upwards threshold position. If near the bottom of the page,
|
||||
// quickly transition the threshold back up where it normally
|
||||
// belongs.
|
||||
const amountScrolledUp = lastKnownScrollPosition - scrollTop;
|
||||
const adjustedDefault = defaultUpThreshold - pixelsAbove
|
||||
+ Math.max(0, adjustedBottomAdd - defaultDownThreshold);
|
||||
threshold = Math.min(adjustedDefault, threshold + amountScrolledUp);
|
||||
}
|
||||
|
||||
if (documentHeight <= windowHeight) {
|
||||
threshold = 0;
|
||||
}
|
||||
|
||||
if (thresholdDebug) {
|
||||
const id = 'mdbook-threshold-debug-data';
|
||||
let data = document.getElementById(id);
|
||||
if (data === null) {
|
||||
data = document.createElement('div');
|
||||
data.id = id;
|
||||
data.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 10px;
|
||||
background-color: 0xeeeeee;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(data);
|
||||
}
|
||||
data.innerHTML = `
|
||||
<table>
|
||||
<tr><td>documentHeight</td><td>${documentHeight.toFixed(1)}</td></tr>
|
||||
<tr><td>windowHeight</td><td>${windowHeight.toFixed(1)}</td></tr>
|
||||
<tr><td>scrollTop</td><td>${scrollTop.toFixed(1)}</td></tr>
|
||||
<tr><td>pixelsAbove</td><td>${pixelsAbove.toFixed(1)}</td></tr>
|
||||
<tr><td>pixelsBelow</td><td>${pixelsBelow.toFixed(1)}</td></tr>
|
||||
<tr><td>bottomAdd</td><td>${bottomAdd.toFixed(1)}</td></tr>
|
||||
<tr><td>adjustedBottomAdd</td><td>${adjustedBottomAdd.toFixed(1)}</td></tr>
|
||||
<tr><td>scrollingDown</td><td>${scrollingDown}</td></tr>
|
||||
<tr><td>threshold</td><td>${threshold.toFixed(1)}</td></tr>
|
||||
</table>
|
||||
`;
|
||||
drawDebugLine();
|
||||
}
|
||||
|
||||
lastKnownScrollPosition = scrollTop;
|
||||
}
|
||||
|
||||
function drawDebugLine() {
|
||||
if (!document.body) {
|
||||
return;
|
||||
}
|
||||
const id = 'mdbook-threshold-debug-line';
|
||||
const existingLine = document.getElementById(id);
|
||||
if (existingLine) {
|
||||
existingLine.remove();
|
||||
}
|
||||
const line = document.createElement('div');
|
||||
line.id = id;
|
||||
line.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${threshold}px;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 2px;
|
||||
background-color: red;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(line);
|
||||
}
|
||||
|
||||
function mdbookEnableThresholdDebug() {
|
||||
thresholdDebug = true;
|
||||
updateThreshold();
|
||||
drawDebugLine();
|
||||
}
|
||||
|
||||
window.mdbookEnableThresholdDebug = mdbookEnableThresholdDebug;
|
||||
|
||||
// Updates which headers in the sidebar should be expanded. If the current
|
||||
// header is inside a collapsed group, then it, and all its parents should
|
||||
// be expanded.
|
||||
function updateHeaderExpanded(currentA) {
|
||||
// Add expanded to all header-item li ancestors.
|
||||
let current = currentA.parentElement;
|
||||
while (current) {
|
||||
if (current.tagName === 'LI' && current.classList.contains('header-item')) {
|
||||
current.classList.add('expanded');
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Updates which header is marked as the "current" header in the sidebar.
|
||||
// This is done with a virtual Y threshold, where headers at or below
|
||||
// that line will be considered the current one.
|
||||
function updateCurrentHeader() {
|
||||
if (!headers || !headers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the classes, which will be rebuilt below.
|
||||
const els = document.getElementsByClassName('current-header');
|
||||
for (const el of els) {
|
||||
el.classList.remove('current-header');
|
||||
}
|
||||
for (const toggle of headerToggles) {
|
||||
toggle.classList.remove('expanded');
|
||||
}
|
||||
|
||||
// Find the last header that is above the threshold.
|
||||
let lastHeader = null;
|
||||
for (const header of headers) {
|
||||
const rect = header.getBoundingClientRect();
|
||||
if (rect.top <= threshold) {
|
||||
lastHeader = header;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastHeader === null) {
|
||||
lastHeader = headers[0];
|
||||
const rect = lastHeader.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
if (rect.top >= windowHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the anchor in the summary.
|
||||
const href = '#' + lastHeader.id;
|
||||
const a = [...document.querySelectorAll('.header-in-summary')]
|
||||
.find(element => element.getAttribute('href') === href);
|
||||
if (!a) {
|
||||
return;
|
||||
}
|
||||
|
||||
a.classList.add('current-header');
|
||||
|
||||
updateHeaderExpanded(a);
|
||||
}
|
||||
|
||||
// Updates which header is "current" based on the threshold line.
|
||||
function reloadCurrentHeader() {
|
||||
if (disableScroll) {
|
||||
return;
|
||||
}
|
||||
updateThreshold();
|
||||
updateCurrentHeader();
|
||||
}
|
||||
|
||||
|
||||
// When clicking on a header in the sidebar, this adjusts the threshold so
|
||||
// that it is located next to the header. This is so that header becomes
|
||||
// "current".
|
||||
function headerThresholdClick(event) {
|
||||
// See disableScroll description why this is done.
|
||||
disableScroll = true;
|
||||
setTimeout(() => {
|
||||
disableScroll = false;
|
||||
}, 100);
|
||||
// requestAnimationFrame is used to delay the update of the "current"
|
||||
// header until after the scroll is done, and the header is in the new
|
||||
// position.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// Closest is needed because if it has child elements like <code>.
|
||||
const a = event.target.closest('a');
|
||||
const href = a.getAttribute('href');
|
||||
const targetId = href.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
threshold = targetElement.getBoundingClientRect().bottom;
|
||||
updateCurrentHeader();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Takes the nodes from the given head and copies them over to the
|
||||
// destination, along with some filtering.
|
||||
function filterHeader(source, dest) {
|
||||
const clone = source.cloneNode(true);
|
||||
clone.querySelectorAll('mark').forEach(mark => {
|
||||
mark.replaceWith(...mark.childNodes);
|
||||
});
|
||||
dest.append(...clone.childNodes);
|
||||
}
|
||||
|
||||
// Scans page for headers and adds them to the sidebar.
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const activeSection = document.querySelector('#mdbook-sidebar .active');
|
||||
if (activeSection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const main = document.getElementsByTagName('main')[0];
|
||||
headers = Array.from(main.querySelectorAll('h2, h3, h4, h5, h6'))
|
||||
.filter(h => h.id !== '' && h.children.length && h.children[0].tagName === 'A');
|
||||
|
||||
if (headers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a tree of headers in the sidebar.
|
||||
|
||||
const stack = [];
|
||||
|
||||
const firstLevel = parseInt(headers[0].tagName.charAt(1));
|
||||
for (let i = 1; i < firstLevel; i++) {
|
||||
const ol = document.createElement('ol');
|
||||
ol.classList.add('section');
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1].ol.appendChild(ol);
|
||||
}
|
||||
stack.push({level: i + 1, ol: ol});
|
||||
}
|
||||
|
||||
// The level where it will start folding deeply nested headers.
|
||||
const foldLevel = 3;
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const header = headers[i];
|
||||
const level = parseInt(header.tagName.charAt(1));
|
||||
|
||||
const currentLevel = stack[stack.length - 1].level;
|
||||
if (level > currentLevel) {
|
||||
// Begin nesting to this level.
|
||||
for (let nextLevel = currentLevel + 1; nextLevel <= level; nextLevel++) {
|
||||
const ol = document.createElement('ol');
|
||||
ol.classList.add('section');
|
||||
const last = stack[stack.length - 1];
|
||||
const lastChild = last.ol.lastChild;
|
||||
// Handle the case where jumping more than one nesting
|
||||
// level, which doesn't have a list item to place this new
|
||||
// list inside of.
|
||||
if (lastChild) {
|
||||
lastChild.appendChild(ol);
|
||||
} else {
|
||||
last.ol.appendChild(ol);
|
||||
}
|
||||
stack.push({level: nextLevel, ol: ol});
|
||||
}
|
||||
} else if (level < currentLevel) {
|
||||
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('header-item');
|
||||
li.classList.add('expanded');
|
||||
if (level < foldLevel) {
|
||||
li.classList.add('expanded');
|
||||
}
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('chapter-link-wrapper');
|
||||
const a = document.createElement('a');
|
||||
span.appendChild(a);
|
||||
a.href = '#' + header.id;
|
||||
a.classList.add('header-in-summary');
|
||||
filterHeader(header.children[0], a);
|
||||
a.addEventListener('click', headerThresholdClick);
|
||||
const nextHeader = headers[i + 1];
|
||||
if (nextHeader !== undefined) {
|
||||
const nextLevel = parseInt(nextHeader.tagName.charAt(1));
|
||||
if (nextLevel > level && level >= foldLevel) {
|
||||
const toggle = document.createElement('a');
|
||||
toggle.classList.add('chapter-fold-toggle');
|
||||
toggle.classList.add('header-toggle');
|
||||
toggle.addEventListener('click', () => {
|
||||
li.classList.toggle('expanded');
|
||||
});
|
||||
const toggleDiv = document.createElement('div');
|
||||
toggleDiv.textContent = '❱';
|
||||
toggle.appendChild(toggleDiv);
|
||||
span.appendChild(toggle);
|
||||
headerToggles.push(li);
|
||||
}
|
||||
}
|
||||
li.appendChild(span);
|
||||
|
||||
const currentParent = stack[stack.length - 1];
|
||||
currentParent.ol.appendChild(li);
|
||||
}
|
||||
|
||||
const onThisPage = document.createElement('div');
|
||||
onThisPage.classList.add('on-this-page');
|
||||
onThisPage.append(stack[0].ol);
|
||||
const activeItemSpan = activeSection.parentElement;
|
||||
activeItemSpan.after(onThisPage);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', reloadCurrentHeader);
|
||||
document.addEventListener('scroll', reloadCurrentHeader, { passive: true });
|
||||
})();
|
||||
|
||||
{{/if}}
|
||||
26
crates/mdbook-html/src/html/admonitions.rs
Normal file
26
crates/mdbook-html/src/html/admonitions.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use pulldown_cmark::BlockQuoteKind;
|
||||
|
||||
// This icon is from GitHub, MIT License, see https://github.com/primer/octicons
|
||||
const ICON_NOTE: &str = r#"<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path>"#;
|
||||
|
||||
// This icon is from GitHub, MIT License, see https://github.com/primer/octicons
|
||||
const ICON_TIP: &str = r#"<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path>"#;
|
||||
|
||||
// This icon is from GitHub, MIT License, see https://github.com/primer/octicons
|
||||
const ICON_IMPORTANT: &str = r#"<path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path>"#;
|
||||
|
||||
// This icon is from GitHub, MIT License, see https://github.com/primer/octicons
|
||||
const ICON_WARNING: &str = r#"<path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path>"#;
|
||||
|
||||
// This icon is from GitHub, MIT License, see https://github.com/primer/octicons
|
||||
const ICON_CAUTION: &str = r#"<path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path>"#;
|
||||
|
||||
pub(crate) fn select_tag(kind: BlockQuoteKind) -> (&'static str, &'static str, &'static str) {
|
||||
match kind {
|
||||
BlockQuoteKind::Note => ("note", ICON_NOTE, "Note"),
|
||||
BlockQuoteKind::Tip => ("tip", ICON_TIP, "Tip"),
|
||||
BlockQuoteKind::Important => ("important", ICON_IMPORTANT, "Important"),
|
||||
BlockQuoteKind::Warning => ("warning", ICON_WARNING, "Warning"),
|
||||
BlockQuoteKind::Caution => ("caution", ICON_CAUTION, "Caution"),
|
||||
}
|
||||
}
|
||||
193
crates/mdbook-html/src/html/hide_lines.rs
Normal file
193
crates/mdbook-html/src/html/hide_lines.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Support for hiding code lines.
|
||||
|
||||
use crate::html::{Element, Node};
|
||||
use ego_tree::{NodeId, Tree};
|
||||
use html5ever::tendril::StrTendril;
|
||||
use mdbook_core::static_regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Wraps hidden lines in a `<span>` for the given code block.
|
||||
pub(crate) fn hide_lines(
|
||||
tree: &mut Tree<Node>,
|
||||
code_id: NodeId,
|
||||
hidelines: &HashMap<String, String>,
|
||||
) {
|
||||
let mut node = tree.get_mut(code_id).unwrap();
|
||||
let el = node.value().as_element().unwrap();
|
||||
|
||||
let classes: Vec<_> = el.attr("class").unwrap_or_default().split(' ').collect();
|
||||
let language = classes
|
||||
.iter()
|
||||
.filter_map(|cls| cls.strip_prefix("language-"))
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let hideline_info = classes
|
||||
.iter()
|
||||
.filter_map(|cls| cls.strip_prefix("hidelines="))
|
||||
.map(|prefix| prefix.to_string())
|
||||
.next();
|
||||
|
||||
if let Some(mut child) = node.first_child()
|
||||
&& let Node::Text(text) = child.value()
|
||||
{
|
||||
if language == "rust" {
|
||||
let new_nodes = hide_lines_rust(text);
|
||||
child.detach();
|
||||
let root = tree.extend_tree(new_nodes);
|
||||
let root_id = root.id();
|
||||
let mut node = tree.get_mut(code_id).unwrap();
|
||||
node.reparent_from_id_append(root_id);
|
||||
} else {
|
||||
// Use the prefix from the code block, else the prefix from config.
|
||||
let hidelines_prefix = hideline_info
|
||||
.as_deref()
|
||||
.or_else(|| hidelines.get(&language).map(|p| p.as_str()));
|
||||
if let Some(prefix) = hidelines_prefix {
|
||||
let new_nodes = hide_lines_with_prefix(text, prefix);
|
||||
child.detach();
|
||||
let root = tree.extend_tree(new_nodes);
|
||||
let root_id = root.id();
|
||||
let mut node = tree.get_mut(code_id).unwrap();
|
||||
node.reparent_from_id_append(root_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps hidden lines in a `<span>` specifically for Rust code blocks.
|
||||
fn hide_lines_rust(text: &StrTendril) -> Tree<Node> {
|
||||
static_regex!(BORING_LINES_REGEX, r"^(\s*)#(.?)(.*)$");
|
||||
|
||||
let mut tree = Tree::new(Node::Fragment);
|
||||
let mut root = tree.root_mut();
|
||||
let mut lines = text.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
// Don't include newline on the last line.
|
||||
let newline = if lines.peek().is_none() { "" } else { "\n" };
|
||||
if let Some(caps) = BORING_LINES_REGEX.captures(line) {
|
||||
if &caps[2] == "#" {
|
||||
root.append(Node::Text(
|
||||
format!("{}{}{}{newline}", &caps[1], &caps[2], &caps[3]).into(),
|
||||
));
|
||||
continue;
|
||||
} else if matches!(&caps[2], "" | " ") {
|
||||
let mut span = Element::new("span");
|
||||
span.insert_attr("class", "boring".into());
|
||||
let mut span = root.append(Node::Element(span));
|
||||
span.append(Node::Text(
|
||||
format!("{}{}{newline}", &caps[1], &caps[3]).into(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
root.append(Node::Text(format!("{line}{newline}").into()));
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// Wraps hidden lines in a `<span>` tag for lines starting with the given prefix.
|
||||
fn hide_lines_with_prefix(content: &str, prefix: &str) -> Tree<Node> {
|
||||
let mut tree = Tree::new(Node::Fragment);
|
||||
let mut root = tree.root_mut();
|
||||
for line in content.lines() {
|
||||
if line.trim_start().starts_with(prefix) {
|
||||
let pos = line.find(prefix).unwrap();
|
||||
let (ws, rest) = (&line[..pos], &line[pos + prefix.len()..]);
|
||||
let mut span = Element::new("span");
|
||||
span.insert_attr("class", "boring".into());
|
||||
let mut span = root.append(Node::Element(span));
|
||||
span.append(Node::Text(format!("{ws}{rest}\n").into()));
|
||||
} else {
|
||||
root.append(Node::Text(format!("{line}\n").into()));
|
||||
}
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// If this code text is missing an `fn main`, the wrap it with `fn main` in a
|
||||
/// fashion similar to rustdoc, with the wrapper hidden.
|
||||
pub(crate) fn wrap_rust_main(text: &str) -> Option<String> {
|
||||
if !text.contains("fn main") && !text.contains("quick_main!") {
|
||||
let (attrs, code) = partition_rust_source(text);
|
||||
let newline = if code.is_empty() || code.ends_with('\n') {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
Some(format!(
|
||||
"# #![allow(unused)]\n{attrs}# fn main() {{\n{code}{newline}# }}"
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits Rust inner attributes from the given source string.
|
||||
///
|
||||
/// Returns `(inner_attrs, rest_of_code)`.
|
||||
fn partition_rust_source(s: &str) -> (&str, &str) {
|
||||
static_regex!(
|
||||
HEADER_RE,
|
||||
r"^(?mx)
|
||||
(
|
||||
(?:
|
||||
^[ \t]*\#!\[.* (?:\r?\n)?
|
||||
|
|
||||
^\s* (?:\r?\n)?
|
||||
)*
|
||||
)"
|
||||
);
|
||||
let split_idx = match HEADER_RE.captures(s) {
|
||||
Some(caps) => {
|
||||
let attributes = &caps[1];
|
||||
if attributes.trim().is_empty() {
|
||||
// Don't include pure whitespace as an attribute. The
|
||||
// whitespace in the regex is intended to handle multiple
|
||||
// attributes *separated* by potential whitespace.
|
||||
0
|
||||
} else {
|
||||
attributes.len()
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
s.split_at(split_idx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_partitions_rust_source() {
|
||||
assert_eq!(partition_rust_source(""), ("", ""));
|
||||
assert_eq!(partition_rust_source("let x = 1;"), ("", "let x = 1;"));
|
||||
assert_eq!(
|
||||
partition_rust_source("fn main()\n{ let x = 1; }\n"),
|
||||
("", "fn main()\n{ let x = 1; }\n")
|
||||
);
|
||||
assert_eq!(
|
||||
partition_rust_source("#![allow(foo)]"),
|
||||
("#![allow(foo)]", "")
|
||||
);
|
||||
assert_eq!(
|
||||
partition_rust_source("#![allow(foo)]\n"),
|
||||
("#![allow(foo)]\n", "")
|
||||
);
|
||||
assert_eq!(
|
||||
partition_rust_source("#![allow(foo)]\nlet x = 1;"),
|
||||
("#![allow(foo)]\n", "let x = 1;")
|
||||
);
|
||||
assert_eq!(
|
||||
partition_rust_source(
|
||||
"\n\
|
||||
#![allow(foo)]\n\
|
||||
\n\
|
||||
#![allow(bar)]\n\
|
||||
\n\
|
||||
let x = 1;"
|
||||
),
|
||||
("\n#![allow(foo)]\n\n#![allow(bar)]\n\n", "let x = 1;")
|
||||
);
|
||||
assert_eq!(
|
||||
partition_rust_source(" // Example"),
|
||||
("", " // Example")
|
||||
);
|
||||
}
|
||||
108
crates/mdbook-html/src/html/mod.rs
Normal file
108
crates/mdbook-html/src/html/mod.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! HTML rendering support.
|
||||
//!
|
||||
//! This module's primary entry point is [`render_markdown`] which will take
|
||||
//! markdown text and render it to HTML. In summary, the general procedure of
|
||||
//! that function is:
|
||||
//!
|
||||
//! 1. Use [`pulldown_cmark`] to parse the markdown and generate events.
|
||||
//! 2. [`tree`] converts those events to a tree data structure.
|
||||
//! 1. Parse HTML inside the markdown using [`tokenizer`].
|
||||
//! 2. Apply various transformations to the tree data structure, such as adding header links.
|
||||
//! 3. Serialize the tree to HTML in [`serialize()`].
|
||||
|
||||
use ego_tree::Tree;
|
||||
use mdbook_core::book::{Book, Chapter};
|
||||
use mdbook_core::config::{HtmlConfig, RustEdition};
|
||||
use mdbook_markdown::{MarkdownOptions, new_cmark_parser};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod admonitions;
|
||||
mod hide_lines;
|
||||
mod print;
|
||||
mod serialize;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tokenizer;
|
||||
mod tree;
|
||||
|
||||
pub(crate) use hide_lines::{hide_lines, wrap_rust_main};
|
||||
pub(crate) use print::render_print_page;
|
||||
pub(crate) use serialize::serialize;
|
||||
pub(crate) use tree::{Element, Node};
|
||||
|
||||
/// Options for converting a single chapter's markdown to HTML.
|
||||
pub(crate) struct HtmlRenderOptions<'a> {
|
||||
/// Options for parsing markdown.
|
||||
pub markdown_options: MarkdownOptions,
|
||||
/// The chapter's location, relative to the `SUMMARY.md` file.
|
||||
pub path: &'a Path,
|
||||
/// The default Rust edition, used to set the proper class on the code blocks.
|
||||
pub edition: Option<RustEdition>,
|
||||
/// The [`HtmlConfig`], whose options affect how the HTML is generated.
|
||||
pub config: &'a HtmlConfig,
|
||||
}
|
||||
|
||||
impl<'a> HtmlRenderOptions<'a> {
|
||||
/// Creates a new [`HtmlRenderOptions`].
|
||||
pub(crate) fn new(
|
||||
path: &'a Path,
|
||||
config: &'a HtmlConfig,
|
||||
edition: Option<RustEdition>,
|
||||
) -> HtmlRenderOptions<'a> {
|
||||
let mut markdown_options = MarkdownOptions::default();
|
||||
markdown_options.smart_punctuation = config.smart_punctuation;
|
||||
markdown_options.definition_lists = config.definition_lists;
|
||||
markdown_options.admonitions = config.admonitions;
|
||||
HtmlRenderOptions {
|
||||
markdown_options,
|
||||
path,
|
||||
edition,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders markdown to HTML.
|
||||
pub(crate) fn render_markdown(text: &str, options: &HtmlRenderOptions<'_>) -> String {
|
||||
let tree = build_tree(text, options);
|
||||
let mut output = String::new();
|
||||
serialize::serialize(&tree, &mut output);
|
||||
output
|
||||
}
|
||||
|
||||
/// Renders markdown to a [`Tree`].
|
||||
fn build_tree(text: &str, options: &HtmlRenderOptions<'_>) -> Tree<Node> {
|
||||
let events = new_cmark_parser(text, &options.markdown_options);
|
||||
tree::MarkdownTreeBuilder::build(options, events)
|
||||
}
|
||||
|
||||
/// The parsed chapter, and some information about the chapter.
|
||||
pub(crate) struct ChapterTree<'book> {
|
||||
pub(crate) chapter: &'book Chapter,
|
||||
/// The path to the chapter relative to the root with the `.html` extension.
|
||||
pub(crate) html_path: PathBuf,
|
||||
/// The chapter tree.
|
||||
pub(crate) tree: Tree<Node>,
|
||||
}
|
||||
|
||||
/// Creates all of the [`ChapterTree`]s for the book.
|
||||
pub(crate) fn build_trees<'book>(
|
||||
book: &'book Book,
|
||||
html_config: &HtmlConfig,
|
||||
edition: Option<RustEdition>,
|
||||
) -> Vec<ChapterTree<'book>> {
|
||||
book.chapters()
|
||||
.map(|ch| {
|
||||
let path = ch.path.as_ref().unwrap();
|
||||
let html_path = ch.path.as_ref().unwrap().with_extension("html");
|
||||
let options = HtmlRenderOptions::new(path, html_config, edition);
|
||||
let tree = build_tree(&ch.content, &options);
|
||||
|
||||
ChapterTree {
|
||||
chapter: ch,
|
||||
html_path,
|
||||
tree,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
214
crates/mdbook-html/src/html/print.rs
Normal file
214
crates/mdbook-html/src/html/print.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! Support for generating the print page.
|
||||
//!
|
||||
//! The print page takes all the individual chapters (as `Tree<Node>`
|
||||
//! elements) and modifies the chapters so that they work on a consolidated
|
||||
//! print page, and then serializes it all as one HTML page.
|
||||
|
||||
use super::Node;
|
||||
use crate::html::{ChapterTree, Element, serialize};
|
||||
use crate::utils::{ToUrlPath, id_from_content, normalize_path, unique_id};
|
||||
use mdbook_core::static_regex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
/// Takes all the chapter trees, modifies them to be suitable to render for
|
||||
/// the print page, and returns an string of all the chapters rendered to a
|
||||
/// single HTML page.
|
||||
pub(crate) fn render_print_page(mut chapter_trees: Vec<ChapterTree<'_>>) -> String {
|
||||
let (id_remap, mut id_counter) = make_ids_unique(&mut chapter_trees);
|
||||
let path_to_root_id = make_root_id_map(&mut chapter_trees, &mut id_counter);
|
||||
rewrite_links(&mut chapter_trees, &id_remap, &path_to_root_id);
|
||||
|
||||
let mut print_content = String::new();
|
||||
for ChapterTree { tree, .. } in chapter_trees {
|
||||
if !print_content.is_empty() {
|
||||
// Add page break between chapters
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/break-before and https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-before
|
||||
// Add both two CSS properties because of the compatibility issue
|
||||
print_content
|
||||
.push_str(r#"<div style="break-before: page; page-break-before: always;"></div>"#);
|
||||
}
|
||||
serialize(&tree, &mut print_content);
|
||||
}
|
||||
print_content
|
||||
}
|
||||
|
||||
/// Make all IDs unique, and create a map from old to new IDs.
|
||||
///
|
||||
/// The first map is a map of the chapter path to the IDs that were rewritten
|
||||
/// in that chapter (old ID to new ID).
|
||||
///
|
||||
/// The second map is a map of every ID seen to the number of times it has
|
||||
/// been seen. This is used to generate unique IDs.
|
||||
fn make_ids_unique(
|
||||
chapter_trees: &mut [ChapterTree<'_>],
|
||||
) -> (HashMap<PathBuf, HashMap<String, String>>, HashSet<String>) {
|
||||
let mut id_remap = HashMap::new();
|
||||
let mut id_counter = HashSet::new();
|
||||
for ChapterTree {
|
||||
html_path, tree, ..
|
||||
} in chapter_trees
|
||||
{
|
||||
for value in tree.values_mut() {
|
||||
if let Node::Element(el) = value
|
||||
&& let Some(id) = el.attr("id")
|
||||
{
|
||||
let new_id = unique_id(id, &mut id_counter);
|
||||
if new_id != id {
|
||||
let id = id.to_string();
|
||||
el.insert_attr("id", new_id.clone().into());
|
||||
|
||||
let map: &mut HashMap<_, _> = id_remap.entry(html_path.clone()).or_default();
|
||||
map.insert(id, new_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(id_remap, id_counter)
|
||||
}
|
||||
|
||||
/// Generates a map of a chapter path to the ID of the top of the chapter.
|
||||
///
|
||||
/// If a chapter is missing an `h1` tag, then one is synthesized so that the
|
||||
/// print output has something to link to.
|
||||
fn make_root_id_map(
|
||||
chapter_trees: &mut [ChapterTree<'_>],
|
||||
id_counter: &mut HashSet<String>,
|
||||
) -> HashMap<PathBuf, String> {
|
||||
let mut path_to_root_id = HashMap::new();
|
||||
for ChapterTree {
|
||||
chapter,
|
||||
html_path,
|
||||
tree,
|
||||
..
|
||||
} in chapter_trees
|
||||
{
|
||||
let mut h1_found = false;
|
||||
for value in tree.values_mut() {
|
||||
if let Node::Element(el) = value {
|
||||
if el.name() == "h1" {
|
||||
if let Some(id) = el.attr("id") {
|
||||
h1_found = true;
|
||||
path_to_root_id.insert(html_path.clone(), id.to_string());
|
||||
}
|
||||
break;
|
||||
} else if matches!(el.name(), "h2" | "h3" | "h4" | "h5" | "h6") {
|
||||
// h1 not found.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !h1_found {
|
||||
// Synthesize a root id to be able to link to the start of the page.
|
||||
// TODO: This might want to be a warning? Chapters generally
|
||||
// should start with an h1.
|
||||
let mut h1 = Element::new("h1");
|
||||
let id = id_from_content(&chapter.name);
|
||||
let id = unique_id(&id, id_counter);
|
||||
h1.insert_attr("id", id.clone().into());
|
||||
let mut root = tree.root_mut();
|
||||
let mut h1 = root.prepend(Node::Element(h1));
|
||||
let mut a = Element::new("a");
|
||||
a.insert_attr("href", format!("#{id}").into());
|
||||
a.insert_attr("class", "header".into());
|
||||
let mut a = h1.append(Node::Element(a));
|
||||
a.append(Node::Text(chapter.name.clone().into()));
|
||||
path_to_root_id.insert(html_path.clone(), id);
|
||||
}
|
||||
}
|
||||
|
||||
path_to_root_id
|
||||
}
|
||||
|
||||
/// Rewrite links so that they point to IDs on the print page.
|
||||
fn rewrite_links(
|
||||
chapter_trees: &mut [ChapterTree<'_>],
|
||||
id_remap: &HashMap<PathBuf, HashMap<String, String>>,
|
||||
path_to_root_id: &HashMap<PathBuf, String>,
|
||||
) {
|
||||
static_regex!(
|
||||
LINK,
|
||||
r"(?x)
|
||||
(?P<scheme>^[a-z][a-z0-9+.-]*:)?
|
||||
(?P<path>[^\#]+)?
|
||||
(?:\#(?P<anchor>.*))?"
|
||||
);
|
||||
|
||||
// Rewrite path links to go to the appropriate place.
|
||||
for ChapterTree {
|
||||
html_path, tree, ..
|
||||
} in chapter_trees
|
||||
{
|
||||
let base = html_path.parent().expect("path can't be empty");
|
||||
|
||||
for value in tree.values_mut() {
|
||||
let Node::Element(el) = value else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(el.name(), "a" | "img") {
|
||||
continue;
|
||||
}
|
||||
for attr in ["href", "src", "xlink:href"] {
|
||||
let Some(dest) = el.attr(attr) else {
|
||||
continue;
|
||||
};
|
||||
let Some(caps) = LINK.captures(&dest) else {
|
||||
continue;
|
||||
};
|
||||
if caps.name("scheme").is_some() {
|
||||
continue;
|
||||
}
|
||||
// The lookup_key is the key to look up in the remap table.
|
||||
let mut lookup_key = html_path.clone();
|
||||
if let Some(href_path) = caps.name("path")
|
||||
&& let href_path = href_path.as_str()
|
||||
&& !href_path.is_empty()
|
||||
{
|
||||
lookup_key.pop();
|
||||
lookup_key.push(href_path);
|
||||
let normalized = normalize_path(&lookup_key);
|
||||
// If this points outside of the book, don't modify it.
|
||||
let is_outside = matches!(
|
||||
normalized.components().next(),
|
||||
Some(Component::ParentDir | Component::RootDir)
|
||||
);
|
||||
if is_outside || !href_path.ends_with(".html") {
|
||||
// Make the link relative to the print page location.
|
||||
let mut rel_path = normalize_path(&base.join(href_path)).to_url_path();
|
||||
if let Some(anchor) = caps.name("anchor") {
|
||||
rel_path.push('#');
|
||||
rel_path.push_str(anchor.as_str());
|
||||
}
|
||||
el.insert_attr(attr, rel_path.into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let lookup_key = normalize_path(&lookup_key);
|
||||
|
||||
let anchor = caps.name("anchor");
|
||||
let id = match anchor {
|
||||
Some(anchor_id) => {
|
||||
let anchor_id = anchor_id.as_str().to_string();
|
||||
match id_remap.get(&lookup_key) {
|
||||
Some(id_map) => match id_map.get(&anchor_id) {
|
||||
Some(new_id) => new_id.clone(),
|
||||
None => anchor_id,
|
||||
},
|
||||
None => {
|
||||
// Assume the anchor goes to some non-remapped
|
||||
// ID that already exists.
|
||||
anchor_id
|
||||
}
|
||||
}
|
||||
}
|
||||
None => match path_to_root_id.get(&lookup_key) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
el.insert_attr(attr, format!("#{id}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
crates/mdbook-html/src/html/serialize.rs
Normal file
112
crates/mdbook-html/src/html/serialize.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Serializes the [`Node`] tree to an HTML string.
|
||||
|
||||
use super::tree::is_void_element;
|
||||
use super::tree::{Element, Node};
|
||||
use ego_tree::{Tree, iter::Edge};
|
||||
use html5ever::{local_name, ns};
|
||||
use mdbook_core::utils::{escape_html, escape_html_attribute};
|
||||
use std::ops::Deref;
|
||||
|
||||
/// Serializes the given tree of [`Node`] elements to an HTML string.
|
||||
pub(crate) fn serialize(tree: &Tree<Node>, output: &mut String) {
|
||||
for edge in tree.root().traverse() {
|
||||
match edge {
|
||||
Edge::Open(node) => match node.value() {
|
||||
Node::Element(el) => serialize_start(el, output),
|
||||
Node::Text(text) => {
|
||||
output.push_str(&escape_html(text));
|
||||
}
|
||||
Node::Comment(comment) => {
|
||||
output.push_str("<!--");
|
||||
output.push_str(comment);
|
||||
output.push_str("-->");
|
||||
}
|
||||
Node::Fragment => {}
|
||||
Node::RawData(html) => {
|
||||
output.push_str(html);
|
||||
}
|
||||
},
|
||||
Edge::Close(node) => {
|
||||
if let Node::Element(el) = node.value() {
|
||||
serialize_end(el, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this HTML element wants a newline to keep the emitted
|
||||
/// output more readable.
|
||||
fn wants_pretty_html_newline(name: &str) -> bool {
|
||||
matches!(name, |"blockquote"| "dd"
|
||||
| "div"
|
||||
| "dl"
|
||||
| "dt"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "hr"
|
||||
| "li"
|
||||
| "ol"
|
||||
| "p"
|
||||
| "pre"
|
||||
| "table"
|
||||
| "tbody"
|
||||
| "thead"
|
||||
| "tr"
|
||||
| "ul")
|
||||
}
|
||||
|
||||
/// Emit the start tag of an element.
|
||||
fn serialize_start(el: &Element, output: &mut String) {
|
||||
let el_name = el.name();
|
||||
if wants_pretty_html_newline(el_name) {
|
||||
if !output.is_empty() {
|
||||
if !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push('<');
|
||||
output.push_str(el_name);
|
||||
for (attr_name, value) in &el.attrs {
|
||||
output.push(' ');
|
||||
match attr_name.ns {
|
||||
ns!() => (),
|
||||
ns!(xml) => output.push_str("xml:"),
|
||||
ns!(xmlns) => {
|
||||
if el.name.local != local_name!("xmlns") {
|
||||
output.push_str("xmlns:");
|
||||
}
|
||||
}
|
||||
ns!(xlink) => output.push_str("xlink:"),
|
||||
_ => (), // TODO what should it do here?
|
||||
}
|
||||
output.push_str(attr_name.local.deref());
|
||||
output.push_str("=\"");
|
||||
output.push_str(&escape_html_attribute(&value));
|
||||
output.push('"');
|
||||
}
|
||||
if el.self_closing {
|
||||
output.push_str(" /");
|
||||
}
|
||||
output.push('>');
|
||||
}
|
||||
|
||||
/// Emit the end tag of an element.
|
||||
fn serialize_end(el: &Element, output: &mut String) {
|
||||
// Void elements do not have an end tag.
|
||||
if el.self_closing || is_void_element(el.name()) {
|
||||
return;
|
||||
}
|
||||
let name = el.name();
|
||||
output.push_str("</");
|
||||
output.push_str(name);
|
||||
output.push('>');
|
||||
if wants_pretty_html_newline(name) {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
53
crates/mdbook-html/src/html/tests.rs
Normal file
53
crates/mdbook-html/src/html/tests.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::html::tokenizer::parse_html;
|
||||
use html5ever::tokenizer::{Tag, TagKind, Token};
|
||||
|
||||
// Basic tokenizer behavior of a script.
|
||||
#[test]
|
||||
fn parse_html_script() {
|
||||
let script = r#"
|
||||
if (3 < 5 > 10)
|
||||
{
|
||||
alert("The sky is falling!");
|
||||
}
|
||||
"#;
|
||||
let t = format!("<script>{script}</script>");
|
||||
let ts = parse_html(&t);
|
||||
eprintln!("{ts:#?}",);
|
||||
let mut output = String::new();
|
||||
let mut in_script = false;
|
||||
for t in ts {
|
||||
match t {
|
||||
Token::ParseError(e) => panic!("{e:?}"),
|
||||
Token::CharacterTokens(s) => {
|
||||
if in_script {
|
||||
output.push_str(&s)
|
||||
}
|
||||
}
|
||||
Token::TagToken(Tag {
|
||||
kind: TagKind::StartTag,
|
||||
..
|
||||
}) => in_script = true,
|
||||
Token::TagToken(Tag {
|
||||
kind: TagKind::EndTag,
|
||||
..
|
||||
}) => in_script = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(output, script);
|
||||
}
|
||||
|
||||
// What happens if a script doesn't end.
|
||||
#[test]
|
||||
fn parse_html_script_unclosed() {
|
||||
let t = r#"<script>
|
||||
// Test
|
||||
"#;
|
||||
let ts = parse_html(t);
|
||||
eprintln!("{ts:#?}",);
|
||||
for t in ts {
|
||||
if let Token::ParseError(e) = t {
|
||||
panic!("{e:?}",);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
crates/mdbook-html/src/html/tokenizer.rs
Normal file
83
crates/mdbook-html/src/html/tokenizer.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! Support for parsing HTML.
|
||||
//!
|
||||
//! The primary entry point is [`parse_html`] which uses [`html5ever`] to
|
||||
//! tokenize the input.
|
||||
|
||||
use html5ever::TokenizerResult;
|
||||
use html5ever::tendril::ByteTendril;
|
||||
use html5ever::tokenizer::states::RawKind;
|
||||
use html5ever::tokenizer::{
|
||||
BufferQueue, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Collector for HTML tokens.
|
||||
#[derive(Default)]
|
||||
struct TokenCollector {
|
||||
/// Parsed HTML tokens.
|
||||
tokens: RefCell<Vec<Token>>,
|
||||
}
|
||||
|
||||
impl TokenSink for TokenCollector {
|
||||
type Handle = ();
|
||||
|
||||
fn process_token(&self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
|
||||
match &token {
|
||||
Token::DoctypeToken(_) => {}
|
||||
Token::TagToken(tag) => {
|
||||
let tag_name = tag.name.as_bytes();
|
||||
// TODO: This could probably use special support for SVG and MathML.
|
||||
if tag_name == b"script" {
|
||||
match tag.kind {
|
||||
TagKind::StartTag => {
|
||||
self.tokens.borrow_mut().push(token);
|
||||
return TokenSinkResult::RawData(RawKind::ScriptData);
|
||||
}
|
||||
TagKind::EndTag => {}
|
||||
}
|
||||
}
|
||||
if tag_name == b"style" {
|
||||
match tag.kind {
|
||||
TagKind::StartTag => {
|
||||
self.tokens.borrow_mut().push(token);
|
||||
return TokenSinkResult::RawData(RawKind::Rawtext);
|
||||
}
|
||||
TagKind::EndTag => {}
|
||||
}
|
||||
}
|
||||
self.tokens.borrow_mut().push(token);
|
||||
}
|
||||
Token::CommentToken(_) => {
|
||||
self.tokens.borrow_mut().push(token);
|
||||
}
|
||||
Token::CharacterTokens(_) => {
|
||||
self.tokens.borrow_mut().push(token);
|
||||
}
|
||||
Token::NullCharacterToken => {}
|
||||
Token::EOFToken => {}
|
||||
Token::ParseError(_) => {
|
||||
self.tokens.borrow_mut().push(token);
|
||||
}
|
||||
}
|
||||
TokenSinkResult::Continue
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse HTML into tokens.
|
||||
pub(crate) fn parse_html(html: &str) -> Vec<Token> {
|
||||
let tendril: ByteTendril = html.as_bytes().into();
|
||||
let mut queue = BufferQueue::default();
|
||||
queue.push_back(tendril.try_reinterpret().unwrap());
|
||||
|
||||
let collector = TokenCollector::default();
|
||||
let tok = Tokenizer::new(collector, TokenizerOpts::default());
|
||||
let result = tok.feed(&mut queue);
|
||||
assert_eq!(result, TokenizerResult::Done);
|
||||
assert!(
|
||||
queue.is_empty(),
|
||||
"queue wasn't empty: {:?}",
|
||||
queue.pop_front()
|
||||
);
|
||||
tok.end();
|
||||
tok.sink.tokens.take()
|
||||
}
|
||||
1068
crates/mdbook-html/src/html/tree.rs
Normal file
1068
crates/mdbook-html/src/html/tree.rs
Normal file
File diff suppressed because it is too large
Load Diff
696
crates/mdbook-html/src/html_handlebars/hbs_renderer.rs
Normal file
696
crates/mdbook-html/src/html_handlebars/hbs_renderer.rs
Normal file
@@ -0,0 +1,696 @@
|
||||
use super::helpers;
|
||||
use super::static_files::StaticFiles;
|
||||
use crate::html::ChapterTree;
|
||||
use crate::html::{build_trees, render_markdown, serialize};
|
||||
use crate::theme::Theme;
|
||||
use crate::utils::ToUrlPath;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use handlebars::Handlebars;
|
||||
use mdbook_core::book::{Book, BookItem, Chapter};
|
||||
use mdbook_core::config::{BookConfig, Config, HtmlConfig};
|
||||
use mdbook_core::utils::fs;
|
||||
use mdbook_renderer::{RenderContext, Renderer};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::error;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
/// The HTML renderer for mdBook.
|
||||
#[derive(Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct HtmlHandlebars;
|
||||
|
||||
impl HtmlHandlebars {
|
||||
/// Returns a new instance of [`HtmlHandlebars`].
|
||||
pub fn new() -> Self {
|
||||
HtmlHandlebars
|
||||
}
|
||||
|
||||
fn render_chapter(
|
||||
&self,
|
||||
chapter_tree: &ChapterTree<'_>,
|
||||
prev_ch: Option<&Chapter>,
|
||||
next_ch: Option<&Chapter>,
|
||||
mut ctx: RenderChapterContext<'_>,
|
||||
) -> Result<()> {
|
||||
// FIXME: This should be made DRY-er and rely less on mutable state
|
||||
let ch = chapter_tree.chapter;
|
||||
|
||||
let path = ch.path.as_ref().unwrap();
|
||||
// "print.html" is used for the print page.
|
||||
if path == Path::new("print.md") {
|
||||
bail!("{} is reserved for internal use", path.display());
|
||||
};
|
||||
|
||||
if let Some(ref edit_url_template) = ctx.html_config.edit_url_template {
|
||||
let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned()
|
||||
+ "/"
|
||||
+ ch.source_path
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
let edit_url = edit_url_template.replace("{path}", &full_path);
|
||||
ctx.data
|
||||
.insert("git_repository_edit_url".to_owned(), json!(edit_url));
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
serialize(&chapter_tree.tree, &mut content);
|
||||
|
||||
let ctx_path = path
|
||||
.to_str()
|
||||
.with_context(|| "Could not convert path to str")?;
|
||||
let filepath = Path::new(&ctx_path).with_extension("html");
|
||||
|
||||
let book_title = ctx
|
||||
.data
|
||||
.get("book_title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("");
|
||||
|
||||
let title = if let Some(title) = ctx.chapter_titles.get(path) {
|
||||
title.clone()
|
||||
} else if book_title.is_empty() {
|
||||
ch.name.clone()
|
||||
} else {
|
||||
ch.name.clone() + " - " + book_title
|
||||
};
|
||||
|
||||
ctx.data.insert("path".to_owned(), json!(path));
|
||||
ctx.data.insert("content".to_owned(), json!(content));
|
||||
ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
|
||||
ctx.data.insert("title".to_owned(), json!(title));
|
||||
ctx.data
|
||||
.insert("path_to_root".to_owned(), json!(fs::path_to_root(path)));
|
||||
if let Some(ref section) = ch.number {
|
||||
ctx.data
|
||||
.insert("section".to_owned(), json!(section.to_string()));
|
||||
}
|
||||
|
||||
let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?;
|
||||
if !redirects.is_empty() {
|
||||
ctx.data.insert(
|
||||
"fragment_map".to_owned(),
|
||||
json!(serde_json::to_string(&redirects)?),
|
||||
);
|
||||
}
|
||||
|
||||
let mut nav = |name: &str, ch: Option<&Chapter>| {
|
||||
let Some(ch) = ch else { return };
|
||||
let path = ch
|
||||
.path
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.with_extension("html")
|
||||
.to_url_path();
|
||||
let obj = json!( {
|
||||
"title": ch.name,
|
||||
"link": path,
|
||||
});
|
||||
ctx.data.insert(name.to_string(), obj);
|
||||
};
|
||||
nav("previous", prev_ch);
|
||||
nav("next", next_ch);
|
||||
|
||||
// Render the handlebars template with the data
|
||||
debug!("Render template");
|
||||
let rendered = ctx.handlebars.render("index", &ctx.data)?;
|
||||
|
||||
// Write to file
|
||||
let out_path = ctx.destination.join(filepath);
|
||||
fs::write(&out_path, rendered)?;
|
||||
|
||||
if prev_ch.is_none() {
|
||||
ctx.data.insert("path".to_owned(), json!("index.md"));
|
||||
ctx.data.insert("path_to_root".to_owned(), json!(""));
|
||||
ctx.data.insert("is_index".to_owned(), json!(true));
|
||||
let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
|
||||
debug!("Creating index.html from {}", ctx_path);
|
||||
fs::write(ctx.destination.join("index.html"), rendered_index)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_404(
|
||||
&self,
|
||||
ctx: &RenderContext,
|
||||
html_config: &HtmlConfig,
|
||||
src_dir: &Path,
|
||||
handlebars: &mut Handlebars<'_>,
|
||||
data: &mut serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let content_404 = if let Some(ref filename) = html_config.input_404 {
|
||||
let path = src_dir.join(filename);
|
||||
fs::read_to_string(&path).with_context(|| "failed to read the 404 input file")?
|
||||
} else {
|
||||
// 404 input not explicitly configured try the default file 404.md
|
||||
let default_404_location = src_dir.join("404.md");
|
||||
if default_404_location.exists() {
|
||||
fs::read_to_string(&default_404_location)
|
||||
.with_context(|| "failed to read the 404 input file")?
|
||||
} else {
|
||||
"# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
|
||||
navigation bar or search to continue."
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
let options = crate::html::HtmlRenderOptions::new(
|
||||
Path::new("404.md"),
|
||||
html_config,
|
||||
ctx.config.rust.edition,
|
||||
);
|
||||
let html_content_404 = render_markdown(&content_404, &options);
|
||||
|
||||
let mut data_404 = data.clone();
|
||||
let base_url = if let Some(site_url) = &html_config.site_url {
|
||||
site_url
|
||||
} else {
|
||||
debug!(
|
||||
"HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
|
||||
this to ensure the 404 page work correctly, especially if your site is hosted in a \
|
||||
subdirectory on the HTTP server."
|
||||
);
|
||||
"/"
|
||||
};
|
||||
data_404.insert("base_url".to_owned(), json!(base_url));
|
||||
// Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly
|
||||
data_404.insert("path".to_owned(), json!("404.md"));
|
||||
data_404.insert("content".to_owned(), json!(html_content_404));
|
||||
|
||||
let mut title = String::from("Page not found");
|
||||
if let Some(book_title) = &ctx.config.book.title {
|
||||
title.push_str(" - ");
|
||||
title.push_str(book_title);
|
||||
}
|
||||
data_404.insert("title".to_owned(), json!(title));
|
||||
let rendered = handlebars.render("index", &data_404)?;
|
||||
|
||||
let output_file = ctx.destination.join(html_config.get_404_output_file());
|
||||
fs::write(output_file, rendered)?;
|
||||
debug!("Creating 404.html ✓");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_print_page(
|
||||
&self,
|
||||
ctx: &RenderContext,
|
||||
handlebars: &Handlebars<'_>,
|
||||
data: &mut serde_json::Map<String, serde_json::Value>,
|
||||
chapter_trees: Vec<ChapterTree<'_>>,
|
||||
) -> Result<String> {
|
||||
let print_content = crate::html::render_print_page(chapter_trees);
|
||||
|
||||
if let Some(ref title) = ctx.config.book.title {
|
||||
data.insert("title".to_owned(), json!(title));
|
||||
} else {
|
||||
// Make sure that the Print chapter does not display the title from
|
||||
// the last rendered chapter by removing it from its context
|
||||
data.remove("title");
|
||||
}
|
||||
data.insert("is_print".to_owned(), json!(true));
|
||||
data.insert("path".to_owned(), json!("print.md"));
|
||||
data.insert("content".to_owned(), json!(print_content));
|
||||
data.insert(
|
||||
"path_to_root".to_owned(),
|
||||
json!(fs::path_to_root(Path::new("print.md"))),
|
||||
);
|
||||
|
||||
debug!("Render template");
|
||||
let rendered = handlebars.render("index", &data)?;
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
|
||||
handlebars.register_helper(
|
||||
"toc",
|
||||
Box::new(helpers::toc::RenderToc {
|
||||
no_section_label: html_config.no_section_label,
|
||||
}),
|
||||
);
|
||||
handlebars.register_helper("fa", Box::new(helpers::fontawesome::fa_helper));
|
||||
}
|
||||
|
||||
fn emit_redirects(
|
||||
&self,
|
||||
root: &Path,
|
||||
handlebars: &Handlebars<'_>,
|
||||
redirects: &HashMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if redirects.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Emitting redirects");
|
||||
let redirects = combine_fragment_redirects(redirects);
|
||||
|
||||
for (original, (dest, fragment_map)) in redirects {
|
||||
// Note: all paths are relative to the build directory, so the
|
||||
// leading slash in an absolute path means nothing (and would mess
|
||||
// up `root.join(original)`).
|
||||
let original = original.trim_start_matches('/');
|
||||
let filename = root.join(original);
|
||||
if filename.exists() {
|
||||
// This redirect is handled by the in-page fragment mapper.
|
||||
continue;
|
||||
}
|
||||
if dest.is_empty() {
|
||||
bail!(
|
||||
"redirect entry for `{original}` only has source paths with `#` fragments\n\
|
||||
There must be an entry without the `#` fragment to determine the default \
|
||||
destination."
|
||||
);
|
||||
}
|
||||
debug!("Redirecting \"{}\" → \"{}\"", original, dest);
|
||||
self.emit_redirect(handlebars, &filename, &dest, &fragment_map)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_redirect(
|
||||
&self,
|
||||
handlebars: &Handlebars<'_>,
|
||||
original: &Path,
|
||||
destination: &str,
|
||||
fragment_map: &BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = original.parent() {
|
||||
fs::create_dir_all(parent)?
|
||||
}
|
||||
|
||||
let js_map = serde_json::to_string(fragment_map)?;
|
||||
|
||||
let ctx = json!({
|
||||
"fragment_map": js_map,
|
||||
"url": destination,
|
||||
});
|
||||
let rendered = handlebars.render("redirect", &ctx).with_context(|| {
|
||||
format!(
|
||||
"Unable to create a redirect file at `{}`",
|
||||
original.display()
|
||||
)
|
||||
})?;
|
||||
fs::write(original, rendered)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer for HtmlHandlebars {
|
||||
fn name(&self) -> &str {
|
||||
"html"
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &RenderContext) -> Result<()> {
|
||||
let book_config = &ctx.config.book;
|
||||
let html_config = ctx.config.html_config().unwrap_or_default();
|
||||
let src_dir = ctx.root.join(&ctx.config.book.src);
|
||||
let destination = &ctx.destination;
|
||||
let book = &ctx.book;
|
||||
let build_dir = ctx.root.join(&ctx.config.build.build_dir);
|
||||
|
||||
if destination.exists() {
|
||||
fs::remove_dir_content(destination)
|
||||
.with_context(|| "Unable to remove stale HTML output")?;
|
||||
}
|
||||
|
||||
trace!("render");
|
||||
let mut handlebars = Handlebars::new();
|
||||
|
||||
let theme_dir = match html_config.theme {
|
||||
Some(ref theme) => {
|
||||
let dir = ctx.root.join(theme);
|
||||
if !dir.is_dir() {
|
||||
bail!("theme dir {} does not exist", dir.display());
|
||||
}
|
||||
dir
|
||||
}
|
||||
None => ctx.root.join("theme"),
|
||||
};
|
||||
|
||||
let theme = Theme::new(theme_dir);
|
||||
|
||||
debug!("Register the index handlebars template");
|
||||
handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
|
||||
|
||||
debug!("Register the head handlebars template");
|
||||
handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?;
|
||||
|
||||
debug!("Register the redirect handlebars template");
|
||||
handlebars
|
||||
.register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?;
|
||||
|
||||
debug!("Register the header handlebars template");
|
||||
handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
|
||||
|
||||
debug!("Register the toc handlebars template");
|
||||
handlebars.register_template_string("toc_js", String::from_utf8(theme.toc_js.clone())?)?;
|
||||
handlebars
|
||||
.register_template_string("toc_html", String::from_utf8(theme.toc_html.clone())?)?;
|
||||
|
||||
debug!("Register handlebars helpers");
|
||||
self.register_hbs_helpers(&mut handlebars, &html_config);
|
||||
|
||||
let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?;
|
||||
|
||||
let chapter_trees = build_trees(book, &html_config, ctx.config.rust.edition);
|
||||
|
||||
fs::create_dir_all(destination)
|
||||
.with_context(|| "Unexpected error when constructing destination path")?;
|
||||
|
||||
let mut static_files = StaticFiles::new(&theme, &html_config, &ctx.root)?;
|
||||
|
||||
// Render search index
|
||||
#[cfg(feature = "search")]
|
||||
{
|
||||
let default = mdbook_core::config::Search::default();
|
||||
let search = html_config.search.as_ref().unwrap_or(&default);
|
||||
if search.enable {
|
||||
super::search::create_files(&search, &mut static_files, &chapter_trees)?;
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Render toc js");
|
||||
{
|
||||
let rendered_toc = handlebars.render("toc_js", &data)?;
|
||||
static_files.add_builtin("toc.js", rendered_toc.as_bytes());
|
||||
debug!("Creating toc.js ✓");
|
||||
}
|
||||
|
||||
if html_config.hash_files {
|
||||
static_files.hash_files()?;
|
||||
}
|
||||
|
||||
debug!("Copy static files");
|
||||
let resource_helper = static_files
|
||||
.write_files(&destination)
|
||||
.with_context(|| "Unable to copy across static files")?;
|
||||
|
||||
handlebars.register_helper("resource", Box::new(resource_helper));
|
||||
|
||||
debug!("Render toc html");
|
||||
{
|
||||
data.insert("is_toc_html".to_owned(), json!(true));
|
||||
data.insert("path".to_owned(), json!("toc.html"));
|
||||
let rendered_toc = handlebars.render("toc_html", &data)?;
|
||||
fs::write(destination.join("toc.html"), rendered_toc)?;
|
||||
debug!("Creating toc.html ✓");
|
||||
data.remove("path");
|
||||
data.remove("is_toc_html");
|
||||
}
|
||||
|
||||
fs::write(
|
||||
destination.join(".nojekyll"),
|
||||
b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
|
||||
)?;
|
||||
|
||||
if let Some(cname) = &html_config.cname {
|
||||
fs::write(destination.join("CNAME"), format!("{cname}\n"))?;
|
||||
}
|
||||
|
||||
for (i, chapter_tree) in chapter_trees.iter().enumerate() {
|
||||
let previous = (i != 0).then(|| chapter_trees[i - 1].chapter);
|
||||
let next = (i != chapter_trees.len() - 1).then(|| chapter_trees[i + 1].chapter);
|
||||
let ctx = RenderChapterContext {
|
||||
handlebars: &handlebars,
|
||||
destination: destination.to_path_buf(),
|
||||
data: data.clone(),
|
||||
book_config: book_config.clone(),
|
||||
html_config: html_config.clone(),
|
||||
chapter_titles: &ctx.chapter_titles,
|
||||
};
|
||||
self.render_chapter(chapter_tree, previous, next, ctx)?;
|
||||
}
|
||||
|
||||
// Render 404 page
|
||||
if html_config.input_404 != Some("".to_string()) {
|
||||
self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?;
|
||||
}
|
||||
|
||||
// Render the print version.
|
||||
if html_config.print.enable {
|
||||
let print_rendered =
|
||||
self.render_print_page(ctx, &handlebars, &mut data, chapter_trees)?;
|
||||
|
||||
fs::write(destination.join("print.html"), print_rendered)?;
|
||||
debug!("Creating print.html ✓");
|
||||
}
|
||||
|
||||
self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
|
||||
.context("Unable to emit redirects")?;
|
||||
|
||||
// Copy all remaining files, avoid a recursive copy from/to the book build dir
|
||||
fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?;
|
||||
|
||||
info!("HTML book written to `{}`", destination.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_data(
|
||||
root: &Path,
|
||||
book: &Book,
|
||||
config: &Config,
|
||||
html_config: &HtmlConfig,
|
||||
theme: &Theme,
|
||||
) -> Result<serde_json::Map<String, serde_json::Value>> {
|
||||
trace!("make_data");
|
||||
|
||||
let mut data = serde_json::Map::new();
|
||||
data.insert(
|
||||
"language".to_owned(),
|
||||
json!(config.book.language.clone().unwrap_or_default()),
|
||||
);
|
||||
data.insert(
|
||||
"text_direction".to_owned(),
|
||||
json!(config.book.realized_text_direction()),
|
||||
);
|
||||
data.insert(
|
||||
"book_title".to_owned(),
|
||||
json!(config.book.title.clone().unwrap_or_default()),
|
||||
);
|
||||
data.insert(
|
||||
"description".to_owned(),
|
||||
json!(config.book.description.clone().unwrap_or_default()),
|
||||
);
|
||||
if theme.favicon_png.is_some() {
|
||||
data.insert("favicon_png".to_owned(), json!("favicon.png"));
|
||||
}
|
||||
if theme.favicon_svg.is_some() {
|
||||
data.insert("favicon_svg".to_owned(), json!("favicon.svg"));
|
||||
}
|
||||
if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint {
|
||||
data.insert(
|
||||
"live_reload_endpoint".to_owned(),
|
||||
json!(live_reload_endpoint),
|
||||
);
|
||||
}
|
||||
|
||||
let default_theme = match html_config.default_theme {
|
||||
Some(ref theme) => theme.to_lowercase(),
|
||||
None => "light".to_string(),
|
||||
};
|
||||
data.insert("default_theme".to_owned(), json!(default_theme));
|
||||
|
||||
let preferred_dark_theme = match html_config.preferred_dark_theme {
|
||||
Some(ref theme) => theme.to_lowercase(),
|
||||
None => "navy".to_string(),
|
||||
};
|
||||
data.insert(
|
||||
"preferred_dark_theme".to_owned(),
|
||||
json!(preferred_dark_theme),
|
||||
);
|
||||
|
||||
if html_config.mathjax_support {
|
||||
data.insert("mathjax_support".to_owned(), json!(true));
|
||||
}
|
||||
|
||||
// Add check to see if there is an additional style
|
||||
if !html_config.additional_css.is_empty() {
|
||||
let mut css = Vec::new();
|
||||
for style in &html_config.additional_css {
|
||||
match style.strip_prefix(root) {
|
||||
Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
|
||||
Err(_) => css.push(style.to_str().expect("Could not convert to str")),
|
||||
}
|
||||
}
|
||||
data.insert("additional_css".to_owned(), json!(css));
|
||||
}
|
||||
|
||||
// Add check to see if there is an additional script
|
||||
if !html_config.additional_js.is_empty() {
|
||||
let mut js = Vec::new();
|
||||
for script in &html_config.additional_js {
|
||||
match script.strip_prefix(root) {
|
||||
Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
|
||||
Err(_) => js.push(script.to_str().expect("Could not convert to str")),
|
||||
}
|
||||
}
|
||||
data.insert("additional_js".to_owned(), json!(js));
|
||||
}
|
||||
|
||||
if html_config.playground.editable && html_config.playground.copy_js {
|
||||
data.insert("playground_js".to_owned(), json!(true));
|
||||
if html_config.playground.line_numbers {
|
||||
data.insert("playground_line_numbers".to_owned(), json!(true));
|
||||
}
|
||||
}
|
||||
if html_config.playground.copyable {
|
||||
data.insert("playground_copyable".to_owned(), json!(true));
|
||||
}
|
||||
|
||||
data.insert("print_enable".to_owned(), json!(html_config.print.enable));
|
||||
data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
|
||||
data.insert("fold_level".to_owned(), json!(html_config.fold.level));
|
||||
data.insert(
|
||||
"sidebar_header_nav".to_owned(),
|
||||
json!(html_config.sidebar_header_nav),
|
||||
);
|
||||
|
||||
let search = html_config.search.clone();
|
||||
if cfg!(feature = "search") {
|
||||
let search = search.unwrap_or_default();
|
||||
data.insert("search_enabled".to_owned(), json!(search.enable));
|
||||
data.insert(
|
||||
"search_js".to_owned(),
|
||||
json!(search.enable && search.copy_js),
|
||||
);
|
||||
} else if search.is_some() {
|
||||
warn!("mdBook compiled without search support, ignoring `output.html.search` table");
|
||||
warn!(
|
||||
"please reinstall with `cargo install mdbook --force --features search`to use the \
|
||||
search feature"
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(ref git_repository_url) = html_config.git_repository_url {
|
||||
data.insert("git_repository_url".to_owned(), json!(git_repository_url));
|
||||
}
|
||||
|
||||
let git_repository_icon = match html_config.git_repository_icon {
|
||||
Some(ref git_repository_icon) => git_repository_icon,
|
||||
None => "fab-github",
|
||||
};
|
||||
let git_repository_icon_class = match git_repository_icon.split('-').next() {
|
||||
Some("fa") => "regular",
|
||||
Some("fas") => "solid",
|
||||
Some("fab") => "brands",
|
||||
_ => "regular",
|
||||
};
|
||||
data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
|
||||
data.insert(
|
||||
"git_repository_icon_class".to_owned(),
|
||||
json!(git_repository_icon_class),
|
||||
);
|
||||
|
||||
let mut chapters = vec![];
|
||||
|
||||
for item in book.iter() {
|
||||
// Create the data to inject in the template
|
||||
let mut chapter = BTreeMap::new();
|
||||
|
||||
match *item {
|
||||
BookItem::PartTitle(ref title) => {
|
||||
chapter.insert("part".to_owned(), json!(title));
|
||||
}
|
||||
BookItem::Chapter(ref ch) => {
|
||||
if let Some(ref section) = ch.number {
|
||||
chapter.insert("section".to_owned(), json!(section.to_string()));
|
||||
}
|
||||
|
||||
chapter.insert(
|
||||
"has_sub_items".to_owned(),
|
||||
json!((!ch.sub_items.is_empty()).to_string()),
|
||||
);
|
||||
|
||||
chapter.insert("name".to_owned(), json!(ch.name));
|
||||
if let Some(ref path) = ch.path {
|
||||
let p = path
|
||||
.to_str()
|
||||
.with_context(|| "Could not convert path to str")?;
|
||||
chapter.insert("path".to_owned(), json!(p));
|
||||
}
|
||||
}
|
||||
BookItem::Separator => {
|
||||
chapter.insert("spacer".to_owned(), json!("_spacer_"));
|
||||
}
|
||||
}
|
||||
|
||||
chapters.push(chapter);
|
||||
}
|
||||
|
||||
data.insert("chapters".to_owned(), json!(chapters));
|
||||
|
||||
debug!("[*]: JSON constructed");
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
struct RenderChapterContext<'a> {
|
||||
handlebars: &'a Handlebars<'a>,
|
||||
destination: PathBuf,
|
||||
data: serde_json::Map<String, serde_json::Value>,
|
||||
book_config: BookConfig,
|
||||
html_config: HtmlConfig,
|
||||
chapter_titles: &'a HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
/// Redirect mapping.
|
||||
///
|
||||
/// The key is the source path (like `foo/bar.html`). The value is a tuple
|
||||
/// `(destination_path, fragment_map)`. The `destination_path` is the page to
|
||||
/// redirect to. `fragment_map` is the map of fragments that override the
|
||||
/// destination. For example, a fragment `#foo` could redirect to any other
|
||||
/// page or site.
|
||||
type CombinedRedirects = BTreeMap<String, (String, BTreeMap<String, String>)>;
|
||||
fn combine_fragment_redirects(redirects: &HashMap<String, String>) -> CombinedRedirects {
|
||||
let mut combined: CombinedRedirects = BTreeMap::new();
|
||||
// This needs to extract the fragments to generate the fragment map.
|
||||
for (original, new) in redirects {
|
||||
if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
|
||||
let e = combined.entry(source_path.to_string()).or_default();
|
||||
if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
|
||||
error!(
|
||||
"internal error: found duplicate fragment redirect \
|
||||
{old} for {source_path}#{source_fragment}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let e = combined.entry(original.to_string()).or_default();
|
||||
e.0 = new.clone();
|
||||
}
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
/// Collects fragment redirects for an existing page.
|
||||
///
|
||||
/// The returned map has keys like `#foo` and the value is the new destination
|
||||
/// path or URL.
|
||||
fn collect_redirects_for_path(
|
||||
path: &Path,
|
||||
redirects: &HashMap<String, String>,
|
||||
) -> Result<BTreeMap<String, String>> {
|
||||
let path = format!("/{}", path.to_url_path());
|
||||
if redirects.contains_key(&path) {
|
||||
bail!(
|
||||
"redirect found for existing chapter at `{path}`\n\
|
||||
Either delete the redirect or remove the chapter."
|
||||
);
|
||||
}
|
||||
|
||||
let key_prefix = format!("{path}#");
|
||||
let map = redirects
|
||||
.iter()
|
||||
.filter_map(|(source, dest)| {
|
||||
source
|
||||
.strip_prefix(&key_prefix)
|
||||
.map(|fragment| (format!("#{fragment}"), dest.to_string()))
|
||||
})
|
||||
.collect();
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use font_awesome_as_a_crate as fa;
|
||||
use handlebars::{
|
||||
Context, Handlebars, Helper, Output, RenderContext, RenderError, RenderErrorReason,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use tracing::trace;
|
||||
|
||||
pub(crate) fn fa_helper(
|
||||
h: &Helper<'_>,
|
||||
_r: &Handlebars<'_>,
|
||||
_ctx: &Context,
|
||||
_rc: &mut RenderContext<'_, '_>,
|
||||
out: &mut dyn Output,
|
||||
) -> Result<(), RenderError> {
|
||||
trace!("fa_helper (handlebars helper)");
|
||||
|
||||
let type_ = h
|
||||
.param(0)
|
||||
.and_then(|v| v.value().as_str())
|
||||
.and_then(|v| fa::Type::from_str(v).ok())
|
||||
.ok_or_else(|| {
|
||||
RenderErrorReason::Other(
|
||||
"Param 0 with String type is required for fontawesome helper.".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let name = h.param(1).and_then(|v| v.value().as_str()).ok_or_else(|| {
|
||||
RenderErrorReason::Other(
|
||||
"Param 1 with String type is required for fontawesome helper.".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
trace!("fa_helper: {} {}", type_, name);
|
||||
|
||||
let name = name
|
||||
.strip_prefix("fa-")
|
||||
.or_else(|| name.strip_prefix("fab-"))
|
||||
.or_else(|| name.strip_prefix("fas-"))
|
||||
.unwrap_or(name);
|
||||
|
||||
if let Some(id) = h.param(2).and_then(|v| v.value().as_str()) {
|
||||
out.write(&format!("<span class=fa-svg id=\"{}\">", id))?;
|
||||
} else {
|
||||
out.write("<span class=fa-svg>")?;
|
||||
}
|
||||
out.write(
|
||||
fa::svg(type_, name)
|
||||
.map_err(|_| RenderErrorReason::Other(format!("Missing font {}", name)))?,
|
||||
)?;
|
||||
out.write("</span>")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3
crates/mdbook-html/src/html_handlebars/helpers/mod.rs
Normal file
3
crates/mdbook-html/src/html_handlebars/helpers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod fontawesome;
|
||||
pub(crate) mod resources;
|
||||
pub(crate) mod toc;
|
||||
45
crates/mdbook-html/src/html_handlebars/helpers/resources.rs
Normal file
45
crates/mdbook-html/src/html_handlebars/helpers/resources.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mdbook_core::utils;
|
||||
|
||||
use handlebars::{
|
||||
Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
|
||||
};
|
||||
|
||||
// Handlebars helper to find filenames with hashes in them
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ResourceHelper {
|
||||
pub hash_map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl HelperDef for ResourceHelper {
|
||||
fn call<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
h: &Helper<'rc>,
|
||||
_r: &'reg Handlebars<'_>,
|
||||
ctx: &'rc Context,
|
||||
rc: &mut RenderContext<'reg, 'rc>,
|
||||
out: &mut dyn Output,
|
||||
) -> Result<(), RenderError> {
|
||||
let param = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| {
|
||||
RenderErrorReason::Other(
|
||||
"Param 0 with String type is required for resource helper.".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let base_path = rc
|
||||
.evaluate(ctx, "@root/path")?
|
||||
.as_json()
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
RenderErrorReason::Other("Type error for `path`, string expected".to_owned())
|
||||
})?
|
||||
.replace("\"", "");
|
||||
|
||||
let path_to_root = utils::fs::path_to_root(&base_path);
|
||||
|
||||
out.write(&path_to_root)?;
|
||||
out.write(self.hash_map.get(param).map(|p| &p[..]).unwrap_or(¶m))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
use std::path::Path;
|
||||
use std::{cmp::Ordering, collections::BTreeMap};
|
||||
|
||||
use crate::utils;
|
||||
use crate::utils::bracket_escape;
|
||||
|
||||
use crate::utils::ToUrlPath;
|
||||
use handlebars::{
|
||||
Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
|
||||
};
|
||||
use mdbook_core::utils::escape_html_attribute;
|
||||
use std::path::Path;
|
||||
use std::{cmp::Ordering, collections::BTreeMap};
|
||||
|
||||
// Handlebars helper to construct TOC
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenderToc {
|
||||
pub(crate) struct RenderToc {
|
||||
pub no_section_label: bool,
|
||||
}
|
||||
|
||||
@@ -32,21 +30,6 @@ impl HelperDef for RenderToc {
|
||||
RenderErrorReason::Other("Could not decode the JSON data".to_owned()).into()
|
||||
})
|
||||
})?;
|
||||
let current_path = rc
|
||||
.evaluate(ctx, "@root/path")?
|
||||
.as_json()
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
RenderErrorReason::Other("Type error for `path`, string expected".to_owned())
|
||||
})?
|
||||
.replace('\"', "");
|
||||
|
||||
let current_section = rc
|
||||
.evaluate(ctx, "@root/section")?
|
||||
.as_json()
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default();
|
||||
|
||||
let fold_enable = rc
|
||||
.evaluate(ctx, "@root/fold_enable")?
|
||||
@@ -64,53 +47,55 @@ impl HelperDef for RenderToc {
|
||||
RenderErrorReason::Other("Type error for `fold_level`, u64 expected".to_owned())
|
||||
})?;
|
||||
|
||||
// If true, then this is the iframe and we need target="_parent"
|
||||
let is_toc_html = rc
|
||||
.evaluate(ctx, "@root/is_toc_html")?
|
||||
.as_json()
|
||||
.as_bool()
|
||||
.unwrap_or(false);
|
||||
|
||||
out.write("<ol class=\"chapter\">")?;
|
||||
|
||||
let mut current_level = 1;
|
||||
// The "index" page, which has this attribute set, is supposed to alias the first chapter in
|
||||
// the book, i.e. the first link. There seems to be no easy way to determine which chapter
|
||||
// the "index" is aliasing from within the renderer, so this is used instead to force the
|
||||
// first link to be active. See further below.
|
||||
let mut is_first_chapter = ctx.data().get("is_index").is_some();
|
||||
let mut first = true;
|
||||
|
||||
for item in chapters {
|
||||
let (section, level) = if let Some(s) = item.get("section") {
|
||||
(s.as_str(), s.matches('.').count())
|
||||
} else {
|
||||
("", 1)
|
||||
};
|
||||
let level = item
|
||||
.get("section")
|
||||
.map(|s| s.matches('.').count())
|
||||
.unwrap_or(1);
|
||||
|
||||
let is_expanded =
|
||||
if !fold_enable || (!section.is_empty() && current_section.starts_with(section)) {
|
||||
// Expand if folding is disabled, or if the section is an
|
||||
// ancestor or the current section itself.
|
||||
true
|
||||
} else {
|
||||
// Levels that are larger than this would be folded.
|
||||
level - 1 < fold_level as usize
|
||||
};
|
||||
// Expand if folding is disabled, or if levels that are larger than this would not
|
||||
// be folded.
|
||||
let is_expanded = !fold_enable || level - 1 < (fold_level as usize);
|
||||
|
||||
match level.cmp(¤t_level) {
|
||||
Ordering::Greater => {
|
||||
while level > current_level {
|
||||
out.write("<li>")?;
|
||||
out.write("<ol class=\"section\">")?;
|
||||
current_level += 1;
|
||||
}
|
||||
write_li_open_tag(out, is_expanded, false)?;
|
||||
// There is an assumption that when descending, it can
|
||||
// only go one level down at a time. This should be
|
||||
// enforced by the nature of markdown lists and the
|
||||
// summary parser.
|
||||
assert_eq!(level, current_level + 1);
|
||||
current_level += 1;
|
||||
out.write("<ol class=\"section\">")?;
|
||||
write_li_open_tag(out, is_expanded)?;
|
||||
}
|
||||
Ordering::Less => {
|
||||
while level < current_level {
|
||||
out.write("</ol>")?;
|
||||
out.write("</li>")?;
|
||||
out.write("</ol>")?;
|
||||
current_level -= 1;
|
||||
}
|
||||
write_li_open_tag(out, is_expanded, false)?;
|
||||
write_li_open_tag(out, is_expanded)?;
|
||||
}
|
||||
Ordering::Equal => {
|
||||
write_li_open_tag(out, is_expanded, !item.contains_key("section"))?;
|
||||
if !first {
|
||||
out.write("</li>")?;
|
||||
}
|
||||
write_li_open_tag(out, is_expanded)?;
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
|
||||
// Spacer
|
||||
if item.contains_key("spacer") {
|
||||
@@ -121,41 +106,33 @@ impl HelperDef for RenderToc {
|
||||
// Part title
|
||||
if let Some(title) = item.get("part") {
|
||||
out.write("<li class=\"part-title\">")?;
|
||||
out.write(&bracket_escape(title))?;
|
||||
out.write(&escape_html_attribute(title))?;
|
||||
out.write("</li>")?;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.write("<span class=\"chapter-link-wrapper\">")?;
|
||||
|
||||
// Link
|
||||
let path_exists: bool;
|
||||
match item.get("path") {
|
||||
let path_exists = match item.get("path") {
|
||||
Some(path) if !path.is_empty() => {
|
||||
out.write("<a href=\"")?;
|
||||
let tmp = Path::new(path)
|
||||
.with_extension("html")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
// Hack for windows who tends to use `\` as separator instead of `/`
|
||||
.replace('\\', "/");
|
||||
let tmp = Path::new(path).with_extension("html").to_url_path();
|
||||
|
||||
// Add link
|
||||
out.write(&utils::fs::path_to_root(¤t_path))?;
|
||||
out.write(&tmp)?;
|
||||
out.write("\"")?;
|
||||
|
||||
if path == ¤t_path || is_first_chapter {
|
||||
is_first_chapter = false;
|
||||
out.write(" class=\"active\"")?;
|
||||
}
|
||||
|
||||
out.write(">")?;
|
||||
path_exists = true;
|
||||
out.write(if is_toc_html {
|
||||
"\" target=\"_parent\">"
|
||||
} else {
|
||||
"\">"
|
||||
})?;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
out.write("<div>")?;
|
||||
path_exists = false;
|
||||
out.write("<span>")?;
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !self.no_section_label {
|
||||
// Section does not necessarily exist
|
||||
@@ -167,47 +144,41 @@ impl HelperDef for RenderToc {
|
||||
}
|
||||
|
||||
if let Some(name) = item.get("name") {
|
||||
out.write(&bracket_escape(name))?
|
||||
out.write(&escape_html_attribute(name))?;
|
||||
}
|
||||
|
||||
if path_exists {
|
||||
out.write("</a>")?;
|
||||
} else {
|
||||
out.write("</div>")?;
|
||||
out.write("</span>")?;
|
||||
}
|
||||
|
||||
// Render expand/collapse toggle
|
||||
if let Some(flag) = item.get("has_sub_items") {
|
||||
let has_sub_items = flag.parse::<bool>().unwrap_or_default();
|
||||
if fold_enable && has_sub_items {
|
||||
out.write("<a class=\"toggle\"><div>❱</div></a>")?;
|
||||
// The <div> here is to manage rotating the element when
|
||||
// the chapter title is long and word-wraps.
|
||||
out.write("<a class=\"chapter-fold-toggle\"><div>❱</div></a>")?;
|
||||
}
|
||||
}
|
||||
out.write("</li>")?;
|
||||
out.write("</span>")?;
|
||||
}
|
||||
while current_level > 1 {
|
||||
out.write("</ol>")?;
|
||||
while current_level > 0 {
|
||||
out.write("</li>")?;
|
||||
out.write("</ol>")?;
|
||||
current_level -= 1;
|
||||
}
|
||||
|
||||
out.write("</ol>")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_li_open_tag(
|
||||
out: &mut dyn Output,
|
||||
is_expanded: bool,
|
||||
is_affix: bool,
|
||||
) -> Result<(), std::io::Error> {
|
||||
fn write_li_open_tag(out: &mut dyn Output, is_expanded: bool) -> Result<(), std::io::Error> {
|
||||
let mut li = String::from("<li class=\"chapter-item ");
|
||||
if is_expanded {
|
||||
li.push_str("expanded ");
|
||||
}
|
||||
if is_affix {
|
||||
li.push_str("affix ");
|
||||
}
|
||||
li.push_str("\">");
|
||||
out.write(&li)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
#![allow(missing_docs)] // FIXME: Document this
|
||||
|
||||
pub use self::hbs_renderer::HtmlHandlebars;
|
||||
|
||||
mod hbs_renderer;
|
||||
mod helpers;
|
||||
|
||||
#[cfg(feature = "search")]
|
||||
mod search;
|
||||
mod static_files;
|
||||
|
||||
pub use self::hbs_renderer::HtmlHandlebars;
|
||||
445
crates/mdbook-html/src/html_handlebars/search.rs
Normal file
445
crates/mdbook-html/src/html_handlebars/search.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use super::static_files::StaticFiles;
|
||||
use crate::html::{ChapterTree, Node};
|
||||
use crate::theme::searcher;
|
||||
use crate::utils::ToUrlPath;
|
||||
use anyhow::{Result, bail};
|
||||
use ego_tree::iter::Edge;
|
||||
use elasticlunr::{Index, IndexBuilder};
|
||||
use mdbook_core::book::Chapter;
|
||||
use mdbook_core::config::{Search, SearchChapterSettings};
|
||||
use mdbook_core::static_regex;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const MAX_WORD_LENGTH_TO_INDEX: usize = 80;
|
||||
|
||||
/// Tokenizes in the same way as elasticlunr-rs (for English), but also drops long tokens.
|
||||
fn tokenize(text: &str) -> Vec<String> {
|
||||
text.split(|c: char| c.is_whitespace() || c == '-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| s.len() <= MAX_WORD_LENGTH_TO_INDEX)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Creates all files required for search.
|
||||
pub(super) fn create_files(
|
||||
search_config: &Search,
|
||||
static_files: &mut StaticFiles,
|
||||
chapter_trees: &[ChapterTree<'_>],
|
||||
) -> Result<()> {
|
||||
let mut index = IndexBuilder::new()
|
||||
.add_field_with_tokenizer("title", Box::new(&tokenize))
|
||||
.add_field_with_tokenizer("body", Box::new(&tokenize))
|
||||
.add_field_with_tokenizer("breadcrumbs", Box::new(&tokenize))
|
||||
.build();
|
||||
|
||||
// These are links to all of the headings in all of the chapters.
|
||||
let mut doc_urls = Vec::new();
|
||||
|
||||
let chapter_configs = sort_search_config(&search_config.chapter);
|
||||
validate_chapter_config(&chapter_configs, chapter_trees)?;
|
||||
|
||||
for ct in chapter_trees {
|
||||
let path = settings_path(ct.chapter);
|
||||
let chapter_settings = get_chapter_settings(&chapter_configs, path);
|
||||
if !chapter_settings.enable.unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
index_chapter(&mut index, search_config, &mut doc_urls, ct)?;
|
||||
}
|
||||
|
||||
let index = write_to_json(index, search_config, doc_urls)?;
|
||||
debug!("Writing search index ✓");
|
||||
if index.len() > 10_000_000 {
|
||||
warn!("search index is very large ({} bytes)", index.len());
|
||||
}
|
||||
|
||||
if search_config.copy_js {
|
||||
static_files.add_builtin(
|
||||
"searchindex.js",
|
||||
// To reduce the size of the generated JSON by preventing all `"` characters to be
|
||||
// escaped, we instead surround the string with much less common `'` character.
|
||||
format!(
|
||||
"window.search = Object.assign(window.search, JSON.parse('{}'));",
|
||||
index.replace("\\", "\\\\").replace("'", "\\'")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
static_files.add_builtin("searcher.js", searcher::JS);
|
||||
static_files.add_builtin("mark.min.js", searcher::MARK_JS);
|
||||
static_files.add_builtin("elasticlunr.min.js", searcher::ELASTICLUNR_JS);
|
||||
debug!("Copying search files ✓");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uses the given arguments to construct a search document, then inserts it to the given index.
|
||||
fn add_doc(
|
||||
index: &mut Index,
|
||||
doc_urls: &mut Vec<String>,
|
||||
anchor_base: &str,
|
||||
heading_id: &str,
|
||||
items: &[&str],
|
||||
) {
|
||||
let mut url = anchor_base.to_string();
|
||||
if !heading_id.is_empty() {
|
||||
url.push('#');
|
||||
url.push_str(heading_id);
|
||||
}
|
||||
|
||||
let doc_ref = doc_urls.len().to_string();
|
||||
doc_urls.push(url);
|
||||
|
||||
let items = items.iter().map(|&x| collapse_whitespace(x.trim()));
|
||||
index.add_doc(&doc_ref, items);
|
||||
}
|
||||
|
||||
/// Adds the chapter to the search index.
|
||||
fn index_chapter(
|
||||
index: &mut Index,
|
||||
search_config: &Search,
|
||||
doc_urls: &mut Vec<String>,
|
||||
chapter_tree: &ChapterTree<'_>,
|
||||
) -> Result<()> {
|
||||
let anchor_base = chapter_tree.html_path.to_url_path();
|
||||
|
||||
let mut in_heading = false;
|
||||
let max_section_depth = search_config.heading_split_level;
|
||||
let mut section_id = None;
|
||||
let mut heading = String::new();
|
||||
let mut body = String::new();
|
||||
let mut breadcrumbs = chapter_tree.chapter.parent_names.clone();
|
||||
|
||||
breadcrumbs.push(chapter_tree.chapter.name.clone());
|
||||
|
||||
let mut traverse = chapter_tree.tree.root().traverse();
|
||||
|
||||
while let Some(edge) = traverse.next() {
|
||||
match edge {
|
||||
Edge::Open(node) => match node.value() {
|
||||
Node::Element(el) => {
|
||||
if let Some(level) = el.heading_level()
|
||||
&& level <= max_section_depth
|
||||
&& let Some(heading_id) = el.attr("id")
|
||||
{
|
||||
if !heading.is_empty() {
|
||||
// Section finished, the next heading is following now
|
||||
// Write the data to the index, and clear it for the next section
|
||||
add_doc(
|
||||
index,
|
||||
doc_urls,
|
||||
&anchor_base,
|
||||
section_id.unwrap(),
|
||||
&[&heading, &body, &breadcrumbs.join(" » ")],
|
||||
);
|
||||
heading.clear();
|
||||
body.clear();
|
||||
breadcrumbs.pop();
|
||||
}
|
||||
section_id = Some(heading_id);
|
||||
in_heading = true;
|
||||
} else if matches!(el.name(), "script" | "style") {
|
||||
// Skip this node.
|
||||
while let Some(edge) = traverse.next() {
|
||||
if let Edge::Close(close) = edge
|
||||
&& close == node
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Insert spaces where HTML output would usually separate text
|
||||
// to ensure words don't get merged together
|
||||
} else if in_heading {
|
||||
heading.push(' ');
|
||||
} else {
|
||||
body.push(' ');
|
||||
}
|
||||
}
|
||||
Node::Text(text) => {
|
||||
if in_heading {
|
||||
heading.push_str(text);
|
||||
} else {
|
||||
body.push_str(text);
|
||||
}
|
||||
}
|
||||
Node::Comment(_) => {}
|
||||
Node::Fragment => {}
|
||||
Node::RawData(_) => {}
|
||||
},
|
||||
Edge::Close(node) => match node.value() {
|
||||
Node::Element(el) => {
|
||||
if let Some(level) = el.heading_level()
|
||||
&& level <= max_section_depth
|
||||
{
|
||||
in_heading = false;
|
||||
breadcrumbs.push(heading.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if !body.is_empty() || !heading.is_empty() {
|
||||
// Make sure the last section is added to the index
|
||||
let title = if heading.is_empty() {
|
||||
if let Some(chapter) = breadcrumbs.first() {
|
||||
chapter
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
&heading
|
||||
};
|
||||
add_doc(
|
||||
index,
|
||||
doc_urls,
|
||||
&anchor_base,
|
||||
section_id.unwrap_or_default(),
|
||||
&[title, &body, &breadcrumbs.join(" » ")],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_to_json(index: Index, search_config: &Search, doc_urls: Vec<String>) -> Result<String> {
|
||||
use elasticlunr::config::{SearchBool, SearchOptions, SearchOptionsField};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResultsOptions {
|
||||
limit_results: u32,
|
||||
teaser_word_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchindexJson {
|
||||
/// The options used for displaying search results
|
||||
results_options: ResultsOptions,
|
||||
/// The searchoptions for elasticlunr.js
|
||||
search_options: SearchOptions,
|
||||
/// Used to lookup a document's URL from an integer document ref.
|
||||
doc_urls: Vec<String>,
|
||||
/// The index for elasticlunr.js
|
||||
index: elasticlunr::Index,
|
||||
}
|
||||
|
||||
let mut fields = BTreeMap::new();
|
||||
let mut opt = SearchOptionsField::default();
|
||||
let mut insert_boost = |key: &str, boost| {
|
||||
opt.boost = Some(boost);
|
||||
fields.insert(key.into(), opt);
|
||||
};
|
||||
insert_boost("title", search_config.boost_title);
|
||||
insert_boost("body", search_config.boost_paragraph);
|
||||
insert_boost("breadcrumbs", search_config.boost_hierarchy);
|
||||
|
||||
let search_options = SearchOptions {
|
||||
bool: if search_config.use_boolean_and {
|
||||
SearchBool::And
|
||||
} else {
|
||||
SearchBool::Or
|
||||
},
|
||||
expand: search_config.expand,
|
||||
fields,
|
||||
};
|
||||
|
||||
let results_options = ResultsOptions {
|
||||
limit_results: search_config.limit_results,
|
||||
teaser_word_count: search_config.teaser_word_count,
|
||||
};
|
||||
|
||||
let json_contents = SearchindexJson {
|
||||
results_options,
|
||||
search_options,
|
||||
doc_urls,
|
||||
index,
|
||||
};
|
||||
|
||||
// By converting to serde_json::Value as an intermediary, we use a
|
||||
// BTreeMap internally and can force a stable ordering of map keys.
|
||||
let json_contents = serde_json::to_value(&json_contents)?;
|
||||
let json_contents = serde_json::to_string(&json_contents)?;
|
||||
|
||||
Ok(json_contents)
|
||||
}
|
||||
|
||||
fn settings_path(ch: &Chapter) -> &Path {
|
||||
ch.source_path
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| ch.path.as_deref().unwrap())
|
||||
}
|
||||
|
||||
fn validate_chapter_config(
|
||||
chapter_configs: &[(PathBuf, SearchChapterSettings)],
|
||||
chapter_trees: &[ChapterTree<'_>],
|
||||
) -> Result<()> {
|
||||
for (path, _) in chapter_configs {
|
||||
let found = chapter_trees
|
||||
.iter()
|
||||
.any(|ct| settings_path(ct.chapter).starts_with(path));
|
||||
if !found {
|
||||
bail!(
|
||||
"[output.html.search.chapter] key `{}` does not match any chapter paths",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sort_search_config(
|
||||
map: &HashMap<String, SearchChapterSettings>,
|
||||
) -> Vec<(PathBuf, SearchChapterSettings)> {
|
||||
let mut settings: Vec<_> = map
|
||||
.iter()
|
||||
.map(|(key, value)| (PathBuf::from(key), value.clone()))
|
||||
.collect();
|
||||
// Note: This is case-sensitive, and assumes the author uses the same case
|
||||
// as the actual filename.
|
||||
settings.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
settings
|
||||
}
|
||||
|
||||
fn get_chapter_settings(
|
||||
chapter_configs: &[(PathBuf, SearchChapterSettings)],
|
||||
source_path: &Path,
|
||||
) -> SearchChapterSettings {
|
||||
let mut result = SearchChapterSettings::default();
|
||||
for (path, config) in chapter_configs {
|
||||
if source_path.starts_with(path) {
|
||||
result.enable = config.enable.or(result.enable);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Replaces multiple consecutive whitespace characters with a single space character.
|
||||
fn collapse_whitespace(text: &str) -> Cow<'_, str> {
|
||||
static_regex!(WS, r"\s\s+");
|
||||
WS.replace_all(text, " ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapter_settings_priority() {
|
||||
let cfg = r#"
|
||||
[output.html.search.chapter]
|
||||
"cli/watch.md" = { enable = true }
|
||||
"cli" = { enable = false }
|
||||
"cli/inner/foo.md" = { enable = false }
|
||||
"cli/inner" = { enable = true }
|
||||
"foo" = {} # Just to make sure empty table is allowed.
|
||||
"#;
|
||||
let cfg: mdbook_core::config::Config = toml::from_str(cfg).unwrap();
|
||||
let html = cfg.html_config().unwrap();
|
||||
let chapter_configs = sort_search_config(&html.search.unwrap().chapter);
|
||||
for (path, enable) in [
|
||||
("foo.md", None),
|
||||
("cli/watch.md", Some(true)),
|
||||
("cli/index.md", Some(false)),
|
||||
("cli/inner/index.md", Some(true)),
|
||||
("cli/inner/foo.md", Some(false)),
|
||||
] {
|
||||
let mut settings = SearchChapterSettings::default();
|
||||
settings.enable = enable;
|
||||
assert_eq!(
|
||||
get_chapter_settings(&chapter_configs, Path::new(path)),
|
||||
settings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_basic() {
|
||||
assert_eq!(tokenize("hello world"), vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_with_hyphens() {
|
||||
assert_eq!(
|
||||
tokenize("hello-world test-case"),
|
||||
vec!["hello", "world", "test", "case"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_mixed_whitespace() {
|
||||
assert_eq!(
|
||||
tokenize("hello\tworld\ntest\r\ncase"),
|
||||
vec!["hello", "world", "test", "case"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_empty_string() {
|
||||
assert_eq!(tokenize(""), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_only_whitespace() {
|
||||
assert_eq!(tokenize(" \t\n "), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_case_normalization() {
|
||||
assert_eq!(tokenize("Hello WORLD Test"), vec!["hello", "world", "test"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_trim_whitespace() {
|
||||
assert_eq!(tokenize(" hello world "), vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_long_words_filtered() {
|
||||
let long_word = "a".repeat(MAX_WORD_LENGTH_TO_INDEX + 1);
|
||||
let short_word = "a".repeat(MAX_WORD_LENGTH_TO_INDEX);
|
||||
let input = format!("{} hello {}", long_word, short_word);
|
||||
assert_eq!(tokenize(&input), vec!["hello", &short_word]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_max_length_word() {
|
||||
let max_word = "a".repeat(MAX_WORD_LENGTH_TO_INDEX);
|
||||
assert_eq!(tokenize(&max_word), vec![max_word]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_special_characters() {
|
||||
assert_eq!(
|
||||
tokenize("hello,world.test!case?"),
|
||||
vec!["hello,world.test!case?"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_unicode() {
|
||||
assert_eq!(
|
||||
tokenize("café naïve résumé"),
|
||||
vec!["café", "naïve", "résumé"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_unicode_rtl_hebre() {
|
||||
assert_eq!(tokenize("שלום עולם"), vec!["שלום", "עולם"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_numbers() {
|
||||
assert_eq!(
|
||||
tokenize("test123 456-789 hello"),
|
||||
vec!["test123", "456", "789", "hello"]
|
||||
);
|
||||
}
|
||||
}
|
||||
320
crates/mdbook-html/src/html_handlebars/static_files.rs
Normal file
320
crates/mdbook-html/src/html_handlebars/static_files.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! Support for writing static files.
|
||||
|
||||
use super::helpers::resources::ResourceHelper;
|
||||
use crate::theme::{self, Theme, playground_editor};
|
||||
use anyhow::{Context, Result};
|
||||
use mdbook_core::config::HtmlConfig;
|
||||
use mdbook_core::static_regex;
|
||||
use mdbook_core::utils::fs;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Map static files to their final names and contents.
|
||||
///
|
||||
/// It performs [fingerprinting], if you call the `hash_files` method.
|
||||
/// If hash-files is turned off, then the files will not be renamed.
|
||||
/// It also writes files to their final destination, when `write_files` is called,
|
||||
/// and interprets the `{{ resource }}` directives to allow assets to name each other.
|
||||
///
|
||||
/// [fingerprinting]: https://guides.rubyonrails.org/asset_pipeline.html#fingerprinting-versioning-with-digest-based-urls
|
||||
pub(super) struct StaticFiles {
|
||||
static_files: Vec<StaticFile>,
|
||||
hash_map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
enum StaticFile {
|
||||
Builtin {
|
||||
data: Vec<u8>,
|
||||
filename: String,
|
||||
},
|
||||
Additional {
|
||||
input_location: PathBuf,
|
||||
filename: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl StaticFiles {
|
||||
pub(super) fn new(theme: &Theme, html_config: &HtmlConfig, root: &Path) -> Result<StaticFiles> {
|
||||
let static_files = Vec::new();
|
||||
let mut this = StaticFiles {
|
||||
hash_map: HashMap::new(),
|
||||
static_files,
|
||||
};
|
||||
|
||||
this.add_builtin("book.js", &theme.js);
|
||||
this.add_builtin("css/general.css", &theme.general_css);
|
||||
this.add_builtin("css/chrome.css", &theme.chrome_css);
|
||||
if html_config.print.enable {
|
||||
this.add_builtin("css/print.css", &theme.print_css);
|
||||
}
|
||||
this.add_builtin("css/variables.css", &theme.variables_css);
|
||||
if let Some(contents) = &theme.favicon_png {
|
||||
this.add_builtin("favicon.png", contents);
|
||||
}
|
||||
if let Some(contents) = &theme.favicon_svg {
|
||||
this.add_builtin("favicon.svg", contents);
|
||||
}
|
||||
this.add_builtin("highlight.css", &theme.highlight_css);
|
||||
this.add_builtin("tomorrow-night.css", &theme.tomorrow_night_css);
|
||||
this.add_builtin("ayu-highlight.css", &theme.ayu_highlight_css);
|
||||
this.add_builtin("highlight.js", &theme.highlight_js);
|
||||
this.add_builtin("clipboard.min.js", &theme.clipboard_js);
|
||||
if theme.fonts_css.is_none() {
|
||||
this.add_builtin("fonts/fonts.css", theme::fonts::CSS);
|
||||
for (file_name, contents) in theme::fonts::LICENSES.iter() {
|
||||
this.add_builtin(file_name, contents);
|
||||
}
|
||||
for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
|
||||
this.add_builtin(file_name, contents);
|
||||
}
|
||||
this.add_builtin(
|
||||
theme::fonts::SOURCE_CODE_PRO.0,
|
||||
theme::fonts::SOURCE_CODE_PRO.1,
|
||||
);
|
||||
} else if let Some(fonts_css) = &theme.fonts_css {
|
||||
if !fonts_css.is_empty() {
|
||||
this.add_builtin("fonts/fonts.css", fonts_css);
|
||||
}
|
||||
}
|
||||
|
||||
let playground_config = &html_config.playground;
|
||||
|
||||
// Ace is a very large dependency, so only load it when requested
|
||||
if playground_config.editable && playground_config.copy_js {
|
||||
// Load the editor
|
||||
this.add_builtin("editor.js", playground_editor::JS);
|
||||
this.add_builtin("ace.js", playground_editor::ACE_JS);
|
||||
this.add_builtin("mode-rust.js", playground_editor::MODE_RUST_JS);
|
||||
this.add_builtin("theme-dawn.js", playground_editor::THEME_DAWN_JS);
|
||||
this.add_builtin(
|
||||
"theme-tomorrow_night.js",
|
||||
playground_editor::THEME_TOMORROW_NIGHT_JS,
|
||||
);
|
||||
}
|
||||
|
||||
let custom_files = html_config
|
||||
.additional_css
|
||||
.iter()
|
||||
.chain(html_config.additional_js.iter());
|
||||
|
||||
for custom_file in custom_files {
|
||||
let input_location = root.join(custom_file);
|
||||
|
||||
this.static_files.push(StaticFile::Additional {
|
||||
input_location,
|
||||
filename: custom_file
|
||||
.to_str()
|
||||
.with_context(|| "resource file names must be valid utf8")?
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
for input_location in theme.font_files.iter().cloned() {
|
||||
let filename = Path::new("fonts")
|
||||
.join(input_location.file_name().unwrap())
|
||||
.to_str()
|
||||
.with_context(|| "resource file names must be valid utf8")?
|
||||
.to_owned();
|
||||
this.static_files.push(StaticFile::Additional {
|
||||
input_location,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub(super) fn add_builtin(&mut self, filename: &str, data: &[u8]) {
|
||||
self.static_files.push(StaticFile::Builtin {
|
||||
filename: filename.to_owned(),
|
||||
data: data.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates this [`StaticFiles`] to hash the contents for determining the
|
||||
/// filename for each resource.
|
||||
pub(super) fn hash_files(&mut self) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
for static_file in &mut self.static_files {
|
||||
match static_file {
|
||||
&mut StaticFile::Builtin {
|
||||
ref mut filename,
|
||||
ref data,
|
||||
} => {
|
||||
let mut parts = filename.splitn(2, '.');
|
||||
let parts = parts.next().and_then(|p| Some((p, parts.next()?)));
|
||||
if let Some((name, suffix)) = parts {
|
||||
if name != "" && suffix != "" && suffix != "txt" {
|
||||
let hex = hex::encode(&Sha256::digest(data)[..4]);
|
||||
let new_filename = format!("{}-{}.{}", name, hex, suffix);
|
||||
self.hash_map.insert(filename.clone(), new_filename.clone());
|
||||
*filename = new_filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
&mut StaticFile::Additional {
|
||||
ref mut filename,
|
||||
ref input_location,
|
||||
} => {
|
||||
let mut parts = filename.splitn(2, '.');
|
||||
let parts = parts.next().and_then(|p| Some((p, parts.next()?)));
|
||||
if let Some((name, suffix)) = parts {
|
||||
if name != "" && suffix != "" {
|
||||
let mut digest = Sha256::new();
|
||||
let mut input_file =
|
||||
std::fs::File::open(input_location).with_context(|| {
|
||||
format!("failed to open `{filename}` for hashing")
|
||||
})?;
|
||||
let mut buf = vec![0; 1024];
|
||||
loop {
|
||||
let amt = input_file
|
||||
.read(&mut buf)
|
||||
.with_context(|| "read static file for hashing")?;
|
||||
if amt == 0 {
|
||||
break;
|
||||
};
|
||||
digest.update(&buf[..amt]);
|
||||
}
|
||||
let hex = hex::encode(&digest.finalize()[..4]);
|
||||
let new_filename = format!("{}-{}.{}", name, hex, suffix);
|
||||
self.hash_map.insert(filename.clone(), new_filename.clone());
|
||||
*filename = new_filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn write_files(self, destination: &Path) -> Result<ResourceHelper> {
|
||||
use regex::bytes::Captures;
|
||||
// The `{{ resource "name" }}` directive in static resources look like
|
||||
// handlebars syntax, even if they technically aren't.
|
||||
static_regex!(RESOURCE, bytes, r#"\{\{ resource "([^"]+)" \}\}"#);
|
||||
fn replace_all<'a>(
|
||||
hash_map: &HashMap<String, String>,
|
||||
data: &'a [u8],
|
||||
filename: &str,
|
||||
) -> Cow<'a, [u8]> {
|
||||
RESOURCE.replace_all(data, move |captures: &Captures<'_>| {
|
||||
let name = captures
|
||||
.get(1)
|
||||
.expect("capture 1 in resource regex")
|
||||
.as_bytes();
|
||||
let name = std::str::from_utf8(name).expect("resource name with invalid utf8");
|
||||
let resource_filename = hash_map.get(name).map(|s| &s[..]).unwrap_or(name);
|
||||
let path_to_root = fs::path_to_root(filename);
|
||||
format!("{}{}", path_to_root, resource_filename)
|
||||
.as_bytes()
|
||||
.to_owned()
|
||||
})
|
||||
}
|
||||
for static_file in &self.static_files {
|
||||
match static_file {
|
||||
StaticFile::Builtin { filename, data } => {
|
||||
debug!("Writing builtin -> {}", filename);
|
||||
let data = if filename.ends_with(".css") || filename.ends_with(".js") {
|
||||
replace_all(&self.hash_map, data, filename)
|
||||
} else {
|
||||
Cow::Borrowed(&data[..])
|
||||
};
|
||||
let path = destination.join(filename);
|
||||
fs::write(path, &data)?;
|
||||
}
|
||||
StaticFile::Additional {
|
||||
input_location,
|
||||
filename,
|
||||
} => {
|
||||
let output_location = destination.join(filename);
|
||||
debug!(
|
||||
"Copying {} -> {}",
|
||||
input_location.display(),
|
||||
output_location.display()
|
||||
);
|
||||
if let Some(parent) = output_location.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Unable to create {}", parent.display()))?;
|
||||
}
|
||||
if filename.ends_with(".css") || filename.ends_with(".js") {
|
||||
let data = fs::read_to_string(input_location)?;
|
||||
let data = replace_all(&self.hash_map, data.as_bytes(), filename);
|
||||
let path = destination.join(filename);
|
||||
fs::write(path, &data)?;
|
||||
} else {
|
||||
std::fs::copy(input_location, &output_location).with_context(|| {
|
||||
format!(
|
||||
"Unable to copy {} to {}",
|
||||
input_location.display(),
|
||||
output_location.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let hash_map = self.hash_map;
|
||||
Ok(ResourceHelper { hash_map })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::Theme;
|
||||
use mdbook_core::config::HtmlConfig;
|
||||
use mdbook_core::utils::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_write_directive() {
|
||||
let theme = Theme {
|
||||
index: Vec::new(),
|
||||
head: Vec::new(),
|
||||
redirect: Vec::new(),
|
||||
header: Vec::new(),
|
||||
chrome_css: Vec::new(),
|
||||
general_css: Vec::new(),
|
||||
print_css: Vec::new(),
|
||||
variables_css: Vec::new(),
|
||||
favicon_png: Some(Vec::new()),
|
||||
favicon_svg: Some(Vec::new()),
|
||||
js: Vec::new(),
|
||||
highlight_css: Vec::new(),
|
||||
tomorrow_night_css: Vec::new(),
|
||||
ayu_highlight_css: Vec::new(),
|
||||
highlight_js: Vec::new(),
|
||||
clipboard_js: Vec::new(),
|
||||
toc_js: Vec::new(),
|
||||
toc_html: Vec::new(),
|
||||
fonts_css: None,
|
||||
font_files: Vec::new(),
|
||||
};
|
||||
let temp_dir = TempDir::with_prefix("mdbook-").unwrap();
|
||||
let reference_js = Path::new("static-files-test-case-reference.js");
|
||||
let mut html_config = HtmlConfig::default();
|
||||
html_config.additional_js.push(reference_js.to_owned());
|
||||
fs::write(
|
||||
temp_dir.path().join(reference_js),
|
||||
br#"{{ resource "book.js" }}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut static_files = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
|
||||
static_files.hash_files().unwrap();
|
||||
static_files.write_files(temp_dir.path()).unwrap();
|
||||
// custom JS winds up referencing book.js
|
||||
let reference_js_content = fs::read_to_string(
|
||||
temp_dir
|
||||
.path()
|
||||
.join("static-files-test-case-reference-635c9cdc.js"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!("book-e3b0c442.js", reference_js_content);
|
||||
// book.js winds up empty
|
||||
let book_js_content = fs::read_to_string(temp_dir.path().join("book-e3b0c442.js")).unwrap();
|
||||
assert_eq!("", book_js_content);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user